Compare commits

...
Author SHA1 Message Date
Tanish Manku eb584ff926 surface gallery load errors with message and copy-to-clipboard
When listUserMediaFiles fails (HTTP error, network error, null reference,
or any other exception), the gallery now shows the error message with
a "Copy error" button instead of silently falling through to "No items yet".

This is a diagnostic aid for a macOS-only blank gallery bug where the
root cause is unknown (CORS, session cookie, or runtime exception) and
there is no access to production logs.
2026-04-13 16:01:19 -07:00
Brandon Thomas b7c8151d28 error utils (reusable) (#1355) 2026-04-13 17:47:06 -04:00
Brandon Thomas 640e392d6c observability into more unknown pages (#1354) 2026-04-13 17:35:54 -04:00
Brandon Thomas 45651103f0 kinovi credits check + alerting (#1352) 2026-04-12 22:33:31 -04:00
Brandon Thomas cba8e2c074 kinovi jobs - better logging (#1351) 2026-04-12 21:24:36 -04:00
Brandon Thomas 07d9d537dd page on image batch failures (#1350) 2026-04-12 19:11:24 -04:00
Brandon Thomas 5770534fc3 error messages on failure to download (#1349) 2026-04-12 18:40:00 -04:00
Brandon Thomas 6fe0909879 kinovi order polling threshold in hours (#1348) 2026-04-12 17:18:17 -04:00
Brandon Thomas 834f0b48a9 fal webhook fixes 2 (#1347) 2026-04-12 05:32:16 -04:00
Brandon Thomas d64944b1c3 artcraft 0.26 -> 0.27 (#1346) 2026-04-12 03:10:01 -04:00
Brandon Thomas 03edd5faf6 tauri cors fix (#1344) 2026-04-12 03:03:07 -04:00
Brandon Thomas 0ef57ff97e fal webhook error handling (#1343)
extractors for fal webhook data with tests
2026-04-12 02:04:22 -04:00
Brandon Thomas adda7483e8 fix billing pages (#1342) 2026-04-11 19:45:58 -04:00
Tanish Manku e77b946d57 fix brief No items yet flash after username fetch resolves (#1336)
setInitialLoading(true) and setUsername(name) are batched together so
React never produces a frame where username is set but initialLoading is
still false, which would fall through to the No items yet branch before
refreshGallery has a chance to set initialLoading(true) itself.
2026-04-10 08:11:59 -07:00
Brandon Thomas 3a6fad1812 bump artcraft 0.25 -> 0.26 (#1335) 2026-04-10 10:45:31 -04:00
Brandon Thomas 9e04990224 character tokens in omni api (#1333) 2026-04-10 10:25:31 -04:00
Brandon Thomas 885b68630a bump artcraft 0.24 -> 0.25 (#1332) 2026-04-10 10:15:04 -04:00
Brandon Thomas 9fee0b1c99 rename omni image params before widespread use (#1331) 2026-04-10 09:08:31 -04:00
Brandon Thomas 631b98f605 omni video handling + tests for seedream 1.5 (rest to follow) (#1330)
adjusts handling, and begins to add unit tests to ensure this works
2026-04-10 09:04:26 -04:00
Brandon Thomas 3334cf7b81 omni image tests (#1329)
This is a substantial number of tests to ensure we don't screw up parameters when migrating over to "omni image generation" from the distinct one-off, model-by-model endpoints.
2026-04-10 01:18:40 -04:00
Tanish Manku 50d8abcfb1 Fix gallery showing blank when session fetch fails or is slow (#1328)
- Remove useless forceFilterRef and its stale useEffect; use forceFilter
prop directly in JSX
- Show skeleton immediately while username is being fetched (!username
condition) instead of flashing "No items yet"
- Track username fetch failure with usernameError state and surface an
error message with a working Retry button
- Fix retry: setUsername("") was a no-op (same value → React bails out);
use usernameRetryCount in deps to actually re-trigger the fetch
- Guard error UI with allItems.length === 0 so stale items stay visible
if a retry fails mid-session
2026-04-09 19:11:02 -07:00
Tanish Mankuandbflatastic bce8d166e0 Feature: Multifile upload (#1294)
Notable behavior:

- Mixing supported and/or unsupported file types will only keep files of
file type that match the first supported file type in the list received
from browser (e.g. PNG, OBJ, EXE, JPG, OBJ order selection of files will
only show PNG, JPG for image upload)
- Removing files until only one is left will result in the sidebar
vanishing and only a single preview showing. More files can be added by
selecting multiple files again through the file selection button
- Failure to upload has been handled but not tested. It is expected any
failed uploads could be retried with a "retry failed" button. Any
individual fail can also be retried via a retry button upon hovering on
the exclamation mark by the failed file's name
- All multi-file uploads are queued async and likely wouldn't be ordered
as shown in the preview list (first-come-first-serve)
- Potentially deadlocks if a task never responds. Likely handled by
backend timeouts. This deadlock state is only partial and not lossy as
successfully uploaded files will appear in gallery even if the task is
cancelled. Cancellation can be attempted at any point during the upload.
Uploads shown in progress may be successful after cancellation depending
on the internal upload progress.
- TopBar upload images button now opens the image modal instead for
consistency and same-component use.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-04-09 09:07:21 -07:00
frankywashereandClaude Opus 4.6 5fc27a5153 Fix blank screen crash and text selection loss in video prompt box (#1315)
## Summary
- **Fix blank screen crash** during video recreate flow: typing text,
adjusting time slider, typing again, then clicking the text box caused
an uncaught DOM exception in MentionTextarea's contentEditable caret
operations, which unmounted the entire React tree (no error boundaries
existed)
- **Fix text selection disappearing** on mouse release in
MentionTextarea: click handler and sync useEffect were destroying active
selections by rewriting innerHTML and restoring only a collapsed caret
- **Add React Error Boundary** around PromptBoxVideo as a safety net

## Changes
### MentionTextarea.tsx
- Wrap `getCaretOffset`, `setCaretOffset`, `scrollCaretIntoView`,
`handleInput`, sync `useEffect`, and `getOffsetRect` in try/catch to
prevent uncaught DOMExceptions
- Add null guard for `childNodes` iteration and `parentNode` access
- Skip `detectMention` in `handleClick` when user has active text
selection
- Skip innerHTML rewrite in sync useEffect when editor has selected text

### PromptBoxVideo.tsx
- Stabilize `characterNames` with `useMemo` to prevent `mentionColorMap`
from rebuilding every render
- Move empty characters array to module-level constant for stable
reference
- Add cleanup `useEffect` for `durationTimerRef` to prevent stale timer
after unmount

### New: PromptBoxErrorBoundary.tsx
- React Error Boundary with "Try again" recovery button

### ImageToVideo.tsx
- Wrap `<PromptBoxVideo>` with `<PromptBoxErrorBoundary>`

## Reproduction steps (original bug)
1. Click "Recreate" on a video
2. Type text into the prompt box
3. Adjust the time slider
4. Type text again
5. Adjust the time slider and click the text box → **UI goes blank**

## Test plan
- [x] Reproduced blank screen bug on Mac before fix
- [x] Verified fix: repeated recreation flow no longer causes blank
screen
- [x] Verified text selection persists after mouse release
- [x] Video generation with @mentions, reference images, and duration
changes works correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:03:59 -07:00
Tanish Manku 51e5db6b12 Text2Image carousel: cross-set navigation at arrow key boundaries (#1322)
Instead of looping within the image set, pressing Right on the last
image advances to the next set and Left on the first image goes to the
previous set, consistent with Up/Down behavior.
2026-04-08 23:46:19 -07:00
Brandon Thomas ff1ce82f66 transcode quicktime (#1318) 2026-04-08 10:27:14 -04:00
244 changed files with 18446 additions and 2312 deletions
Generated
+9
View File
@@ -3876,6 +3876,13 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error_utils"
version = "0.0.1"
dependencies = [
"regex",
]
[[package]]
name = "errors"
version = "0.0.1"
@@ -10172,6 +10179,7 @@ dependencies = [
"anyhow",
"bootstrap",
"bucket_paths",
"chrono",
"cloud_storage",
"concurrency",
"easyenv",
@@ -11233,6 +11241,7 @@ dependencies = [
"elasticsearch",
"elasticsearch_schema",
"enums",
"error_utils",
"errors",
"fal_client",
"ffmpeg_utils",
+2
View File
@@ -59,6 +59,7 @@ members = [
"crates/lib/data/primitives",
"crates/lib/data/web_base64",
"crates/lib/easyenv", # NB: Vendored version of https://github.com/echelon/easyenv.rs that we'll improve.
"crates/lib/error_utils",
"crates/lib/errors",
"crates/lib/ffmpeg_utils",
"crates/lib/files/file_to_base64_url",
@@ -236,6 +237,7 @@ cookie_store = { path = "crates/lib/cookie_store" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
error_utils = { path = "crates/lib/error_utils" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/api_clients/fal_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
@@ -40,11 +40,11 @@ pub struct OmniGenImageCostAndGenerateRequest {
pub image_batch_count: Option<u16>,
/// Only for angle manipulation models.
pub horizontal_angle: Option<f64>,
pub adjust_horizontal_angle: Option<f64>,
/// Only for angle manipulation models.
pub vertical_angle: Option<f64>,
pub adjust_vertical_angle: Option<f64>,
/// Only for angle manipulation models.
pub zoom: Option<f64>,
pub adjust_zoom: Option<f64>,
}
@@ -5,6 +5,7 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
/// Shared request body for both the video cost estimate and video generation endpoints.
@@ -39,6 +40,10 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Reference audio (optional).
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
/// Optional character tokens to reference in the prompt.
/// Characters are referenced in prompts as @CharacterName.
pub reference_character_tokens: Option<Vec<CharacterToken>>,
/// The resolution to use.
pub resolution: Option<CommonResolution>,
@@ -103,6 +103,7 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_default: Option<CommonResolution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1,10 +1,13 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
/// Video model to default to if none is specified
const DEFAULT_VIDEO_MODEL : CommonVideoModel = CommonVideoModel::Seedance2p0;
/// Query string parameters for the video models endpoint.
#[derive(Deserialize, IntoParams, ToSchema)]
@@ -110,6 +113,12 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_references_max_total_duration_seconds: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub character_references_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub character_references_max: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_generate_with_sound_toggle: Option<bool>,
@@ -159,7 +168,7 @@ pub struct OmniGenVideoModelDetails {
impl Default for OmniGenVideoModelDetails {
fn default() -> Self {
Self {
model: CommonVideoModel::Seedance2p0,
model: DEFAULT_VIDEO_MODEL,
full_name: None,
text_prompt_supported: None,
text_prompt_max_length: None,
@@ -176,6 +185,8 @@ impl Default for OmniGenVideoModelDetails {
audio_references_supported: None,
audio_references_max: None,
audio_references_max_total_duration_seconds: None,
character_references_supported: None,
character_references_max: None,
show_generate_with_sound_toggle: None,
aspect_ratio_options: None,
aspect_ratio_default: None,
@@ -36,7 +36,11 @@ pub enum CommonAspectRatio {
// Auto values that bake in resolution
// These are from the Seedream models
#[serde(rename = "auto_2k")]
Auto2k,
#[serde(rename = "auto_3k")]
Auto3k,
#[serde(rename = "auto_4k")]
Auto4k,
// Defined aspect ratios that bake in resolution
@@ -13,6 +13,8 @@ pub enum CommonImageModel {
FluxPro11,
#[serde(rename = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
@@ -0,0 +1,11 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CommonQuality {
High,
Medium,
Low,
}
@@ -2,6 +2,7 @@ pub mod audio_list_ref;
pub mod character_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_quality;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -31,6 +31,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
@@ -6,8 +6,11 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1(
plan: &PlanArtcraftFluxPro11<'_>,
) -> ImageGenerationCostEstimate {
// NB(bt): Slightly more than 3 cents, weird megapixel maths. We can eat that cost for now.
let cost_per_image: u64 = 3;
// Fal pricing: $0.04 per megapixel, billed by rounding up to the nearest
// megapixel. The default image_size values are ~1MP, so 4 cents per image.
// (The legacy `generate_flux_pro_11_text_to_image_handler` charges this same
// amount via `FluxPro11Args::calculate_cost_in_cents`.)
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
GenerateFluxPro11TextToImageNumImages::One => 1,
@@ -43,6 +46,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -60,10 +64,10 @@ mod tests {
#[test]
fn test_estimate_cost_usd_cents() {
// ~$0.03/image = 3 cents each (slightly more than 3¢ actual, rounded up)
assert_eq!(estimate_usd_cents(1), 3);
assert_eq!(estimate_usd_cents(2), 6);
assert_eq!(estimate_usd_cents(3), 9);
assert_eq!(estimate_usd_cents(4), 12);
// $0.04/image = 4 cents each (Fal's $0.04/MP at the default ~1MP).
assert_eq!(estimate_usd_cents(1), 4);
assert_eq!(estimate_usd_cents(2), 8);
assert_eq!(estimate_usd_cents(3), 12);
assert_eq!(estimate_usd_cents(4), 16);
}
}
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,239 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::{
ArtcraftGptImage1Quality, ArtcraftGptImage1Size, PlanArtcraftGptImage1,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Pricing per the fal-ai GPT Image 1 docs, adapted for our artcraft billing:
//
// Output image cost (per output image):
// Low – $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium – $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High – $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image cost (edit mode only, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(
plan.image_size,
None | Some(ArtcraftGptImage1Size::Square)
);
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(ArtcraftGptImage1Quality::Auto, _) => 17, // Auto treated as High square
(ArtcraftGptImage1Quality::Low, true) => 2,
(ArtcraftGptImage1Quality::Low, false) => 2,
(ArtcraftGptImage1Quality::Medium, true) => 5,
(ArtcraftGptImage1Quality::Medium, false) => 7,
(ArtcraftGptImage1Quality::High, true) => 17,
(ArtcraftGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = 2 * plan.num_input_images;
let cost_in_usd_cents = output_cost + input_image_cost;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn base() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn estimate_text(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
fn estimate_edit(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
num_input_images: usize,
) -> u64 {
let tokens: Vec<MediaFileToken> = (0..num_input_images)
.map(|i| MediaFileToken::new_from_str(&format!("mf_test{:028}", i)))
.collect();
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ───────────────────────────────────────
#[test]
fn default_quality_square_one_image_costs_17_cents() {
assert_eq!(estimate_text(None, None, 1), 17);
}
#[test]
fn default_quality_square_four_images_costs_68_cents() {
assert_eq!(estimate_text(None, None, 4), 68);
}
// ── Low quality (2¢/image regardless of size) ───────────────────────────
#[test]
fn low_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 2);
}
#[test]
fn low_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Low), None, 4), 8);
}
// ── Medium quality (5¢ square, 7¢ wide/tall) ───────────────────────────
#[test]
fn medium_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 5);
}
#[test]
fn medium_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 7);
}
#[test]
fn medium_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 20);
}
#[test]
fn medium_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 28);
}
// ── High quality (17¢ square, 25¢ wide/tall) ───────────────────────────
#[test]
fn high_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
#[test]
fn high_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 25);
}
#[test]
fn high_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 25);
}
#[test]
fn high_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 68);
}
#[test]
fn high_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 100);
}
// ── Edit mode: adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_one_input_adds_2_cents() {
// High square 1 output = 17¢, plus 1 input × 2¢ = 19¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 1), 19);
}
#[test]
fn edit_three_inputs_adds_6_cents() {
// High square 1 output = 17¢, plus 3 inputs × 2¢ = 23¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 3), 23);
}
#[test]
fn edit_five_inputs_adds_10_cents() {
// Medium wide 2 outputs = 7×2 = 14¢, plus 5 inputs × 2¢ = 24¢
assert_eq!(estimate_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 2, 5), 24);
}
#[test]
fn edit_low_quality_with_inputs() {
// Low square 1 output = 2¢, plus 2 inputs × 2¢ = 6¢
assert_eq!(estimate_edit(Some(CommonQuality::Low), None, 1, 2), 6);
}
// ── Text mode (no input images) has no input cost ──────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
}
@@ -9,42 +9,33 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Pricing varies by quality and image size. 1 credit = 1 USD cent.
// Low: $0.01/image (any size)
// Medium: $0.03/image (square/unset), $0.05/image (wide or tall)
// High: $0.13/image (square/unset), $0.20/image (wide or tall)
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// From Fal (text to image) -
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
// Your request will cost different amounts based on the number of images, quality, and size.
// Input text tokens: free (we don't charge for these).
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
// Default quality: High (when request.quality is None).
//
// From Fal (image to image) -
//
// Your request will cost different amounts based on the number of images, quality, and size.
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.008 per 1,000 input image tokens. One 1024x1024 image is roughly 135 tokens in low fidelity mode, or 3,050 tokens in high fidelity mode.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
//
let cost_per_image: u64 = match plan.quality {
GptImage1p5MultiFunctionImageGenQuality::Low => 1,
GptImage1p5MultiFunctionImageGenQuality::Medium => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 3,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 5,
},
GptImage1p5MultiFunctionImageGenQuality::High => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 13,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 20,
},
use GptImage1p5MultiFunctionImageGenQuality as Q;
use GptImage1p5MultiFunctionImageGenSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(Q::Low, None | Some(S::Square)) => 1,
(Q::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(Q::Medium, None | Some(S::Square)) => 4,
(Q::Medium, Some(S::Wide)) => 5,
(Q::Medium, Some(S::Tall)) => 6,
// High quality
(Q::High, None | Some(S::Square)) => 14,
(Q::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let num_images: u64 = match plan.num_images {
@@ -70,11 +61,16 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
fn estimate_usd_cents(image_batch_count: u16, aspect_ratio: Option<CommonAspectRatio>) -> u64 {
fn estimate(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::GptImage1p5,
provider: Provider::Artcraft,
@@ -82,7 +78,8 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio,
image_batch_count: Some(image_batch_count),
quality,
image_batch_count: Some(batch),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
@@ -90,34 +87,140 @@ mod tests {
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ─────────────────────────────────────
#[test]
fn default_quality_square_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn test_estimate_cost_medium_square() {
// Default quality (Medium) + square/unset size = 3 cents each
assert_eq!(estimate_usd_cents(1, None), 3);
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::Square)), 3);
assert_eq!(estimate_usd_cents(2, Some(CommonAspectRatio::Square)), 6);
assert_eq!(estimate_usd_cents(3, Some(CommonAspectRatio::Square)), 9);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::Square)), 12);
fn default_quality_unset_one_image() {
assert_eq!(estimate(None, None, 1), 14);
}
#[test]
fn test_estimate_cost_medium_wide() {
// Medium + wide = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::WideSixteenByNine)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::WideSixteenByNine)), 20);
fn default_quality_wide_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn test_estimate_cost_medium_tall() {
// Medium + tall = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::TallNineBySixteen)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::TallNineBySixteen)), 20);
fn default_quality_tall_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn default_quality_square_four_images() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 4), 56);
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 1);
}
#[test]
fn low_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 1), 1);
}
#[test]
fn low_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_square_four() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 4), 4);
}
#[test]
fn low_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 4), 8);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 4);
}
#[test]
fn medium_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), None, 1), 4);
}
#[test]
fn medium_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 5);
}
#[test]
fn medium_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 4), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn high_unset_one() {
assert_eq!(estimate(Some(CommonQuality::High), None, 1), 14);
}
#[test]
fn high_wide_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn high_tall_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn high_square_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 80);
}
#[test]
fn high_tall_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 4), 80);
}
}
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -8,12 +8,17 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_nano_banana_2(
plan: &PlanArtcraftNanaBanana2<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image at 0.5K, $0.15/image at 1K or 2K, $0.30/image at 4K.
// (Stored as USD cents.)
// Pricing (USD cents per image), scaled off the 1K base cost of $0.08:
// 0.5K → $0.06 (0.75×)
// 1K → $0.08 (1×, default)
// 2K → $0.12 (1.5×)
// 4K → $0.16 (2×)
let cost_per_image: u64 = match plan.resolution {
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 30,
_ => 15, // OneK, TwoK, or None (default 1K)
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 6,
Some(NanaBanana2MultiFunctionImageGenImageResolution::OneK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::TwoK) => 12,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 16,
None => 8, // default 1K
};
let num_images: u64 = match plan.num_images {
@@ -51,6 +56,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -66,17 +72,125 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// 1K/2K: $0.15/image = 15 cents
assert_eq!(estimate_usd_cents(None, 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 60);
// ── Default (None → 1K → 8¢/image) ─────────────────────────────────────────
// 4K: $0.30/image = 30 cents
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 60);
#[test]
fn default_resolution_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(None, 1), 8);
}
#[test]
fn default_resolution_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(None, 2), 16);
}
#[test]
fn default_resolution_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(None, 3), 24);
}
#[test]
fn default_resolution_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(None, 4), 32);
}
// ── 0.5K → 6¢/image (0.75× base) ───────────────────────────────────────────
#[test]
fn half_k_one_image_costs_6_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 1), 6);
}
#[test]
fn half_k_two_images_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 2), 12);
}
#[test]
fn half_k_three_images_costs_18_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 3), 18);
}
#[test]
fn half_k_four_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 4), 24);
}
// ── 1K → 8¢/image (base) ───────────────────────────────────────────────────
#[test]
fn one_k_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 8);
}
#[test]
fn one_k_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 16);
}
#[test]
fn one_k_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 3), 24);
}
#[test]
fn one_k_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 32);
}
// ── 2K → 12¢/image (1.5× base) ─────────────────────────────────────────────
#[test]
fn two_k_one_image_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 12);
}
#[test]
fn two_k_two_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 2), 24);
}
#[test]
fn two_k_three_images_costs_36_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 3), 36);
}
#[test]
fn two_k_four_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 4), 48);
}
// ── 4K → 16¢/image (2× base) ───────────────────────────────────────────────
#[test]
fn four_k_one_image_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 16);
}
#[test]
fn four_k_two_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 32);
}
#[test]
fn four_k_three_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 3), 48);
}
#[test]
fn four_k_four_images_costs_64_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 4), 64);
}
// ── 3K (unsupported) falls back to 2K pricing ──────────────────────────────
#[test]
fn three_k_one_image_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 1), 12);
}
#[test]
fn three_k_four_images_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 4), 48);
}
}
@@ -50,6 +50,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -2,6 +2,7 @@ pub mod estimate_image_cost_artcraft_flux_1_dev;
pub mod estimate_image_cost_artcraft_flux_1_schnell;
pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
@@ -0,0 +1,245 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::{
FalGptImage1ImageSize, FalGptImage1Quality, PlanFalGptImage1,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1/{text-to-image,edit-image}):
//
// Output image cost (per output image):
// Low: $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium: $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High: $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image tokens (edit mode, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(plan.image_size, None | Some(FalGptImage1ImageSize::Square));
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(FalGptImage1Quality::Low, true) => 2,
(FalGptImage1Quality::Low, false) => 2,
(FalGptImage1Quality::Medium, true) => 5,
(FalGptImage1Quality::Medium, false) => 7,
(FalGptImage1Quality::High, true) => 17,
(FalGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = if plan.image_urls.is_empty() {
0
} else {
2 * plan.image_urls.len() as u64
};
let cost_in_usd_cents = output_cost + input_image_cost;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::FalGptImage1NumImages;
fn text_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> PlanFalGptImage1<'static> {
PlanFalGptImage1 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn edit_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> PlanFalGptImage1<'static> {
let image_urls = (0..num_input_images)
.map(|i| format!("https://fake.example.com/img{}.png", i))
.collect();
PlanFalGptImage1 {
prompt: Some("p"),
image_urls,
image_size,
quality,
num_images,
}
}
fn cents_text(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&text_plan(quality, image_size, num_images))
.cost_in_usd_cents
.unwrap()
}
fn cents_edit(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&edit_plan(quality, image_size, num_images, num_input_images))
.cost_in_usd_cents
.unwrap()
}
// ── Low quality text-to-image (2¢/image, both sizes) ──────────────────
#[test]
fn low_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_unset_size_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::One), 2);
}
#[test]
fn low_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::Four), 8);
}
// ── Medium quality text-to-image (5¢ square, 7¢ wide/tall) ────────────
#[test]
fn medium_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 5);
}
#[test]
fn medium_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Four), 20);
}
#[test]
fn medium_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 28);
}
// ── High quality text-to-image (17¢ square, 25¢ wide/tall) ────────────
#[test]
fn high_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
#[test]
fn high_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_square_two_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Two), 34);
}
#[test]
fn high_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 100);
}
// ── Edit mode adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_high_square_one_output_one_input() {
// 17¢ output + 2¢ input = 19¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 1), 19);
}
#[test]
fn edit_high_square_one_output_three_inputs() {
// 17¢ output + 3×2¢ input = 23¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 3), 23);
}
#[test]
fn edit_medium_wide_two_outputs_five_inputs() {
// 2×7¢ output + 5×2¢ input = 24¢
assert_eq!(cents_edit(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Two, 5), 24);
}
#[test]
fn edit_low_square_one_output_two_inputs() {
// 2¢ output + 2×2¢ input = 6¢
assert_eq!(cents_edit(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 2), 6);
}
#[test]
fn edit_high_vertical_four_outputs_one_input() {
// 4×25¢ output + 1×2¢ input = 102¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::Four, 1), 102);
}
// ── Text mode has no input image cost ─────────────────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1(&text_plan(
FalGptImage1Quality::Medium,
None,
FalGptImage1NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -6,19 +6,28 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_imag
pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
plan: &PlanFalGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs:
// Low: $0.009 (1024x1024) / $0.013 (other)
// Medium: $0.034 (1024x1024) / $0.050–0.051 (other)
// High: $0.133 (1024x1024) / $0.199–0.200 (other)
// Round up to whole cents.
let is_square = matches!(plan.image_size, None | Some(FalGptImage1p5ImageSize::Square));
let cost_per_image: u64 = match (plan.quality, is_square) {
(FalGptImage1p5Quality::Low, true) => 1,
(FalGptImage1p5Quality::Low, false) => 2,
(FalGptImage1p5Quality::Medium, true) => 4,
(FalGptImage1p5Quality::Medium, false) => 6,
(FalGptImage1p5Quality::High, true) => 14,
(FalGptImage1p5Quality::High, false) => 20,
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
use FalGptImage1p5ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage1p5Quality::Low, None | Some(S::Square)) => 1,
(FalGptImage1p5Quality::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(FalGptImage1p5Quality::Medium, None | Some(S::Square)) => 4,
(FalGptImage1p5Quality::Medium, Some(S::Wide)) => 5,
(FalGptImage1p5Quality::Medium, Some(S::Tall)) => 6,
// High quality
(FalGptImage1p5Quality::High, None | Some(S::Square)) => 14,
(FalGptImage1p5Quality::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
@@ -31,3 +40,141 @@ pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::FalGptImage1p5NumImages;
fn plan(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> PlanFalGptImage1p5<'static> {
PlanFalGptImage1p5 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1p5(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::Four), 4);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 5);
}
#[test]
fn medium_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::Four), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage1p5Quality::High, None, FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_wide() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_tall() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 80);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1p5(&plan(
FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -48,6 +48,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -2,6 +2,7 @@ pub(crate) mod estimate_image_cost_fal_flux_1_dev;
pub(crate) mod estimate_image_cost_fal_flux_1_schnell;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_1_dev_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_dev_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -37,7 +37,6 @@ mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_flux_1_schnell_image_request, get_artcraft_client};
use strum::IntoEnumIterator;
@@ -47,6 +46,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a cyberpunk city at night"),
..base_flux_1_schnell_image_request()
@@ -69,6 +69,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_schnell_image_request()
@@ -94,6 +95,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a futuristic city at evening"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -51,6 +51,7 @@ mod tests {
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
horizontal_angle: Some(30.0),
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_ultra_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_ultra_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a t-rex walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -0,0 +1,147 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::GptImage1EditImageRequest;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use artcraft_client::endpoints::generate::image::text::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
/// GPT Image 1 has two distinct legacy storyteller-web endpoints (one for
/// text-to-image, one for image-edit), so we dispatch on whether image refs
/// are present rather than calling a single multi-function endpoint.
pub async fn execute_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let inference_job_token = match plan.image_inputs {
Some(image_inputs) => {
let request = GptImage1EditImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: Some(image_inputs.to_owned()),
image_size: plan.image_size.map(|s| s.to_edit()),
num_images: Some(plan.num_images.to_edit()),
image_quality: Some(plan.quality.to_edit()),
};
let response = gpt_image_1_edit_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_size: plan.image_size.map(|s| s.to_t2i()),
num_images: Some(plan.num_images.to_t2i()),
image_quality: Some(plan.quality.to_t2i()),
};
let response = generate_gpt_image_1_text_to_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_gpt_image_1_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
// Build-only smoke test that exercises the full text-to-image plan path
// (no network I/O — we never call generate_image).
#[test]
fn build_text_to_image_plan_smoke() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
assert!(matches!(plan, ImageGenerationPlan::ArtcraftGptImage1(_)));
}
// Build-only smoke test for the edit path. We can't actually fire the
// request without an authenticated client, but we want to make sure the
// dispatcher recognizes media-token inputs as edit-mode.
#[test]
fn build_edit_image_plan_smoke() {
let tokens = vec![MediaFileToken::new_from_str("mf_test00000000000000000000000000")];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::ArtcraftGptImage1(plan) = plan else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_some(), "edit mode must carry image_inputs");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_1() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
..base_gpt_image_1_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_1() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
}
}
@@ -50,6 +50,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_image_request()
@@ -79,6 +80,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
..base_nano_banana_image_request()
};
@@ -103,6 +105,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -138,6 +141,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()
@@ -49,6 +49,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_2_image_request()
@@ -51,6 +51,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_image_request()
@@ -74,6 +75,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_image_request()
@@ -106,6 +108,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
..base_image_request()
};
@@ -52,6 +52,7 @@ mod tests {
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
@@ -51,6 +51,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_seedream_4_image_request()
@@ -80,6 +81,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
..base_seedream_4_image_request()
};
@@ -104,6 +106,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -139,6 +142,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_seedream_4_image_request()
@@ -51,6 +51,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
..base_seedream_4p5_image_request()
@@ -80,6 +81,7 @@ mod tests {
prompt: Some("Change the background to a spooky haunted house"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto2k),
quality: None,
image_batch_count: Some(1),
..base_seedream_4p5_image_request()
};
@@ -104,6 +106,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -139,6 +142,7 @@ mod tests {
prompt: Some("Change the background to a spooky haunted house"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_seedream_4p5_image_request()
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
..base_seedream_5_lite_image_request()
@@ -2,6 +2,7 @@ pub mod generate_image_artcraft_flux_1_dev;
pub mod generate_image_artcraft_flux_1_schnell;
pub mod generate_image_artcraft_flux_pro_1p1;
pub mod generate_image_artcraft_flux_pro_1p1_ultra;
pub mod generate_image_artcraft_gpt_image_1;
pub mod generate_image_artcraft_gpt_image_1p5;
pub mod generate_image_artcraft_nano_banana;
pub mod generate_image_artcraft_nano_banana_2;
@@ -0,0 +1,160 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs,
};
pub async fn execute_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage1TextToImageArgs {
prompt: plan.prompt.unwrap_or(""),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
background: None,
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
let args = EnqueueGptImage1EditImageArgs {
prompt: plan.prompt.unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_image_url: None,
image_size: plan.image_size.map(|s| s.to_edit()),
quality: Some(plan.quality.to_edit()),
input_fidelity: None,
background: None,
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::get_fal_client;
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
// Build-only smoke test (no network I/O).
#[test]
fn build_text_to_image_plan_smoke() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_fal_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage1(plan) = plan else {
panic!("expected FalGptImage1")
};
assert!(plan.image_urls.is_empty(), "text mode must have no image_urls");
}
// Build-only smoke test for edit mode.
#[test]
fn build_edit_image_plan_smoke() {
let urls = vec!["https://example.com/img.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage1(plan) = plan else {
panic!("expected FalGptImage1")
};
assert_eq!(plan.image_urls, urls);
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn test_text_to_image_gpt_image_1_fal() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
..base_fal_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn test_edit_image_gpt_image_1_fal() {
let client = get_fal_client();
let urls = vec![
TREX_SKELETON_IMAGE_URL.to_string(),
];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
prompt: Some("change the background to a desert"),
..base_fal_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
}
}
@@ -105,6 +105,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::TwoK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cyberpunk city skyline at dusk, neon lights reflecting on rain-soaked streets"),
..base_fal_image_request()
@@ -129,6 +130,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(4),
prompt: Some("a golden retriever surfing a wave, cinematic, 4K"),
..base_fal_image_request()
@@ -161,6 +163,7 @@ mod tests {
image_inputs: Some(ImageListRef::Urls(&image_urls)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::TwoK),
quality: None,
image_batch_count: Some(1),
..base_fal_image_request()
};
@@ -2,6 +2,7 @@ pub mod generate_image_fal_flux_1_dev;
pub mod generate_image_fal_flux_1_schnell;
pub mod generate_image_fal_flux_pro_1p1;
pub mod generate_image_fal_flux_pro_1p1_ultra;
pub mod generate_image_fal_gpt_image_1;
pub mod generate_image_fal_gpt_image_1p5;
pub mod generate_image_fal_seedream_4;
pub mod generate_image_fal_seedream_4p5;
@@ -1,5 +1,6 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
@@ -13,6 +14,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::plan_generate_image_artcraft_flux_2_lora_angles;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::plan_generate_image_artcraft_flux_pro_1p1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::plan_generate_image_artcraft_gpt_image_1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2;
@@ -25,6 +27,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_d
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::plan_generate_image_fal_flux_1_schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::plan_generate_image_fal_flux_pro_1p1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::plan_generate_image_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::plan_generate_image_fal_gpt_image_1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::plan_generate_image_fal_gpt_image_1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::plan_generate_image_fal_seedream_4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::plan_generate_image_fal_seedream_4p5;
@@ -33,7 +36,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,
@@ -55,6 +58,9 @@ pub struct GenerateImageRequest<'a> {
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The quality level for generation. Not all models use this.
pub quality: Option<CommonQuality>,
/// How many images to generate.
pub image_batch_count: Option<u16>,
@@ -96,6 +102,7 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::Flux1Schnell => plan_generate_image_artcraft_flux_1_schnell(self),
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_artcraft_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
@@ -114,6 +121,7 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::Flux1Schnell => plan_generate_image_fal_flux_1_schnell(self),
CommonImageModel::FluxPro11 => plan_generate_image_fal_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_fal_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_fal_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_fal_gpt_image_1p5(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
@@ -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>,
@@ -4,6 +4,7 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_1_schnell::estimate_image_cost_artcraft_flux_1_schnell;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1::estimate_image_cost_artcraft_flux_pro_1p1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1_ultra::estimate_image_cost_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1::estimate_image_cost_artcraft_gpt_image_1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1p5::estimate_image_cost_artcraft_gpt_image_1p5;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana::estimate_image_cost_artcraft_nano_banana;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_2::estimate_image_cost_artcraft_nano_banana_2;
@@ -17,6 +18,7 @@ use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_d
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_schnell::estimate_image_cost_fal_flux_1_schnell;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1::estimate_image_cost_fal_flux_pro_1p1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1_ultra::estimate_image_cost_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1::estimate_image_cost_fal_gpt_image_1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1p5::estimate_image_cost_fal_gpt_image_1p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4::estimate_image_cost_fal_seedream_4;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4p5::estimate_image_cost_fal_seedream_4p5;
@@ -28,6 +30,7 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_schnell::execute_artcraft_flux_1_schnell;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1::execute_artcraft_flux_pro_1p1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1_ultra::execute_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1::execute_artcraft_gpt_image_1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1p5::execute_artcraft_gpt_image_1p5;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana::execute_artcraft_nano_banana;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_2::execute_artcraft_nano_banana_2;
@@ -41,6 +44,7 @@ use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_dev
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_schnell::execute_fal_flux_1_schnell;
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1::execute_fal_flux_pro_1p1;
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1_ultra::execute_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1::execute_fal_gpt_image_1;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1p5::execute_fal_gpt_image_1p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4::execute_fal_seedream_4;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4p5::execute_fal_seedream_4p5;
@@ -54,6 +58,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_schnell::PlanArtcraftFlux1Schnell;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::PlanArtcraftFluxPro11;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::PlanArtcraftFluxPro11Ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::PlanArtcraftNanaBanana2;
@@ -67,6 +72,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_d
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::PlanFalFluxPro11;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::PlanFalGptImage1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
@@ -81,6 +87,7 @@ pub enum ImageGenerationPlan<'a> {
ArtcraftFlux1Schnell(PlanArtcraftFlux1Schnell<'a>),
ArtcraftFluxPro11(PlanArtcraftFluxPro11<'a>),
ArtcraftFluxPro11Ultra(PlanArtcraftFluxPro11Ultra<'a>),
ArtcraftGptImage1(PlanArtcraftGptImage1<'a>),
ArtcraftGptImage1p5(PlanArtcraftGptImage1p5<'a>),
ArtcraftNanaBanana(PlanArtcraftNanaBanana<'a>),
ArtcraftNanaBanana2(PlanArtcraftNanaBanana2<'a>),
@@ -94,6 +101,7 @@ pub enum ImageGenerationPlan<'a> {
FalFlux1Schnell(PlanFalFlux1Schnell<'a>),
FalFluxPro11(PlanFalFluxPro11<'a>),
FalFluxPro11Ultra(PlanFalFluxPro11Ultra<'a>),
FalGptImage1(PlanFalGptImage1<'a>),
FalGptImage1p5(PlanFalGptImage1p5<'a>),
FalSeedream4(PlanFalSeedream4<'a>),
FalSeedream4p5(PlanFalSeedream4p5<'a>),
@@ -125,6 +133,10 @@ impl<'a> ImageGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_flux_pro_1p1_ultra(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage1(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1p5(plan, artcraft_client).await
@@ -177,6 +189,10 @@ impl<'a> ImageGenerationPlan<'a> {
let fal_client = client.get_fal_client_ref()?;
execute_fal_flux_pro_1p1_ultra(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage1(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage1p5(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1p5(plan, fal_client).await
@@ -222,6 +238,9 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::ArtcraftFluxPro11Ultra(plan) => {
estimate_image_cost_artcraft_flux_pro_1p1_ultra(plan)
}
ImageGenerationPlan::ArtcraftGptImage1(plan) => {
estimate_image_cost_artcraft_gpt_image_1(plan)
}
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
estimate_image_cost_artcraft_gpt_image_1p5(plan)
}
@@ -261,6 +280,9 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::FalFluxPro11Ultra(plan) => {
estimate_image_cost_fal_flux_pro_1p1_ultra(plan)
}
ImageGenerationPlan::FalGptImage1(plan) => {
estimate_image_cost_fal_gpt_image_1(plan)
}
ImageGenerationPlan::FalGptImage1p5(plan) => {
estimate_image_cost_fal_gpt_image_1p5(plan)
}
@@ -2,6 +2,7 @@ pub mod plan_generate_image_artcraft_flux_1_dev;
pub mod plan_generate_image_artcraft_flux_1_schnell;
pub mod plan_generate_image_artcraft_flux_pro_1p1;
pub mod plan_generate_image_artcraft_flux_pro_1p1_ultra;
pub mod plan_generate_image_artcraft_gpt_image_1;
pub mod plan_generate_image_artcraft_gpt_image_1p5;
pub mod plan_generate_image_artcraft_nano_banana;
pub mod plan_generate_image_artcraft_nano_banana_2;
@@ -268,6 +268,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_dev_image_request()
@@ -295,6 +296,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_1_dev_image_request()
@@ -312,6 +314,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_dev_image_request()
@@ -264,6 +264,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_schnell_image_request()
@@ -291,6 +292,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_1_schnell_image_request()
@@ -308,6 +310,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_schnell_image_request()
@@ -80,7 +80,7 @@ fn plan_image_size(
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
@@ -224,6 +224,7 @@ mod tests {
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
@@ -268,6 +268,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_image_request()
@@ -295,6 +296,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_pro_1p1_image_request()
@@ -312,6 +314,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_image_request()
@@ -271,6 +271,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_ultra_image_request()
@@ -298,6 +299,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_pro_1p1_ultra_image_request()
@@ -315,6 +317,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_ultra_image_request()
@@ -0,0 +1,537 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
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::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{
GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages,
};
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{
GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize,
GenerateGptImage1TextToImageNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
/// Quality is shared between t2i and edit (Auto / Low / Medium / High).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtcraftGptImage1Quality {
Auto,
Low,
Medium,
High,
}
/// Image size is shared between t2i and edit (Square / Horizontal / Vertical).
/// GPT Image 1 has no native auto size — None means "let the legacy handler
/// pick its default" (which is Square).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtcraftGptImage1Size {
Square,
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage1NumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanArtcraftGptImage1<'a> {
pub prompt: Option<&'a str>,
/// Input images for image editing. None means text-to-image mode.
pub image_inputs: Option<&'a Vec<MediaFileToken>>,
/// Number of input images (0 for text-to-image). This is counted from either
/// media tokens or hydrated URLs, so the cost estimator can use it without
/// needing to inspect image_inputs (which is None for URL-form inputs).
pub num_input_images: u64,
pub image_size: Option<ArtcraftGptImage1Size>,
/// Quality defaults to High when the request leaves it unspecified, matching
/// the legacy storyteller-web handler defaults.
pub quality: ArtcraftGptImage1Quality,
pub num_images: ArtcraftGptImage1NumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_gpt_image_1<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let num_input_images = count_input_images(request.image_inputs);
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage1(PlanArtcraftGptImage1 {
prompt: request.prompt,
image_inputs,
num_input_images,
image_size,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on quality + size + num_images +
// is_edit_mode (derived before this resolver runs), so URL-form inputs are
// accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn count_input_images(image_inputs: Option<ImageListRef<'_>>) -> u64 {
match image_inputs {
None => 0,
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len() as u64,
Some(ImageListRef::Urls(urls)) => urls.len() as u64,
}
}
fn plan_quality(quality: Option<CommonQuality>) -> ArtcraftGptImage1Quality {
match quality {
Some(CommonQuality::Low) => ArtcraftGptImage1Quality::Low,
Some(CommonQuality::Medium) => ArtcraftGptImage1Quality::Medium,
Some(CommonQuality::High) => ArtcraftGptImage1Quality::High,
None => ArtcraftGptImage1Quality::High,
}
}
// GPT Image 1 image sizes: Square (1024x1024), Horizontal (1536x1024),
// Vertical (1024x1536). All 17 CommonAspectRatio variants map to one of these
// or None (let the legacy handler default to Square).
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<ArtcraftGptImage1Size> {
use ArtcraftGptImage1Size as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Vertical),
}
}
// GPT Image 1 supports 1, 2, 3, and 4 images.
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<ArtcraftGptImage1NumImages, ArtcraftRouterError> {
use ArtcraftGptImage1NumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(N::One),
2 => Ok(N::Two),
3 => Ok(N::Three),
4 => Ok(N::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four),
},
}
}
// ── Conversions to legacy storyteller-web request enums ────────────────────
impl ArtcraftGptImage1NumImages {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageNumImages {
use GenerateGptImage1TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> GptImage1EditImageNumImages {
use GptImage1EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl ArtcraftGptImage1Quality {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageImageQuality {
use GenerateGptImage1TextToImageImageQuality as T;
match self {
Self::Auto => T::Auto,
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> GptImage1EditImageImageQuality {
use GptImage1EditImageImageQuality as E;
match self {
Self::Auto => E::Auto,
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl ArtcraftGptImage1Size {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageImageSize {
use GenerateGptImage1TextToImageImageSize as T;
match self {
Self::Square => T::Square,
Self::Horizontal => T::Horizontal,
Self::Vertical => T::Vertical,
}
}
pub fn to_edit(self) -> GptImage1EditImageImageSize {
use GptImage1EditImageImageSize as E;
match self {
Self::Square => E::Square,
Self::Horizontal => E::Horizontal,
Self::Vertical => E::Vertical,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::test_helpers::base_gpt_image_1_image_request;
// ── Image size (aspect ratio mapping) ────────────────────────────────────
#[test]
fn image_size_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_auto_variants_yield_none() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest { aspect_ratio: Some(auto_ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_size.is_none(), "expected None for {:?}", auto_ar);
}
}
#[test]
fn image_size_square_variants() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Square)), "expected Square for {:?}", ar);
}
}
#[test]
fn image_size_wide_variants_yield_horizontal() {
let wide_ars = [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
];
for ar in wide_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Horizontal)), "expected Horizontal for {:?}", ar);
}
}
#[test]
fn image_size_tall_variants_yield_vertical() {
let tall_ars = [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
];
for ar in tall_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Vertical)), "expected Vertical for {:?}", ar);
}
}
// ── Quality default ──────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_high() {
let request = base_gpt_image_1_image_request();
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.quality, ArtcraftGptImage1Quality::High));
}
// ── Num images ───────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_gpt_image_1_image_request()
};
let result = request.build();
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_default_is_one() {
let request = GenerateImageRequest { image_batch_count: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.num_images, ArtcraftGptImage1NumImages::One));
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, ArtcraftGptImage1NumImages::One),
(2, ArtcraftGptImage1NumImages::Two),
(3, ArtcraftGptImage1NumImages::Three),
(4, ArtcraftGptImage1NumImages::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_gpt_image_1_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(
matches!(plan.num_images, ArtcraftGptImage1NumImages::Four),
"expected Four for count 9 with {:?}", strategy,
);
}
}
// ── Image inputs (mode detection) ────────────────────────────────────────
#[test]
fn no_image_inputs_is_text_to_image_mode() {
let request = GenerateImageRequest { image_inputs: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_none());
}
#[test]
fn media_token_image_inputs_is_edit_mode() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_some());
}
#[test]
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on quality + size + num_images.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_none());
}
// ── Idempotency token ────────────────────────────────────────────────────
#[test]
fn idempotency_token_is_generated_when_unset() {
let request = base_gpt_image_1_image_request();
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(!plan.idempotency_token.is_empty());
}
#[test]
fn idempotency_token_passthrough() {
let token = "11111111-1111-1111-1111-111111111111";
let request = GenerateImageRequest { idempotency_token: Some(token), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert_eq!(plan.idempotency_token, token);
}
// ── Conversions to legacy storyteller-web request enums ─────────────────
#[test]
fn num_images_to_t2i_round_trip() {
use GenerateGptImage1TextToImageNumImages as T;
assert!(matches!(ArtcraftGptImage1NumImages::One.to_t2i(), T::One));
assert!(matches!(ArtcraftGptImage1NumImages::Two.to_t2i(), T::Two));
assert!(matches!(ArtcraftGptImage1NumImages::Three.to_t2i(), T::Three));
assert!(matches!(ArtcraftGptImage1NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit_round_trip() {
use GptImage1EditImageNumImages as E;
assert!(matches!(ArtcraftGptImage1NumImages::One.to_edit(), E::One));
assert!(matches!(ArtcraftGptImage1NumImages::Two.to_edit(), E::Two));
assert!(matches!(ArtcraftGptImage1NumImages::Three.to_edit(), E::Three));
assert!(matches!(ArtcraftGptImage1NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i_round_trip() {
use GenerateGptImage1TextToImageImageQuality as T;
assert!(matches!(ArtcraftGptImage1Quality::Auto.to_t2i(), T::Auto));
assert!(matches!(ArtcraftGptImage1Quality::Low.to_t2i(), T::Low));
assert!(matches!(ArtcraftGptImage1Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(ArtcraftGptImage1Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit_round_trip() {
use GptImage1EditImageImageQuality as E;
assert!(matches!(ArtcraftGptImage1Quality::Auto.to_edit(), E::Auto));
assert!(matches!(ArtcraftGptImage1Quality::Low.to_edit(), E::Low));
assert!(matches!(ArtcraftGptImage1Quality::Medium.to_edit(), E::Medium));
assert!(matches!(ArtcraftGptImage1Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i_round_trip() {
use GenerateGptImage1TextToImageImageSize as T;
assert!(matches!(ArtcraftGptImage1Size::Square.to_t2i(), T::Square));
assert!(matches!(ArtcraftGptImage1Size::Horizontal.to_t2i(), T::Horizontal));
assert!(matches!(ArtcraftGptImage1Size::Vertical.to_t2i(), T::Vertical));
}
#[test]
fn size_to_edit_round_trip() {
use GptImage1EditImageImageSize as E;
assert!(matches!(ArtcraftGptImage1Size::Square.to_edit(), E::Square));
assert!(matches!(ArtcraftGptImage1Size::Horizontal.to_edit(), E::Horizontal));
assert!(matches!(ArtcraftGptImage1Size::Vertical.to_edit(), E::Vertical));
}
}
@@ -1,4 +1,5 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -30,13 +31,14 @@ pub fn plan_generate_image_artcraft_gpt_image_1p5<'a>(
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage1p5(PlanArtcraftGptImage1p5 {
prompt: request.prompt,
image_inputs,
image_size,
quality: GptImage1p5MultiFunctionImageGenQuality::Medium,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
@@ -48,9 +50,19 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on quality + size + num_images, so
// URL-form inputs are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn plan_quality(quality: Option<CommonQuality>) -> GptImage1p5MultiFunctionImageGenQuality {
match quality {
Some(CommonQuality::Low) => GptImage1p5MultiFunctionImageGenQuality::Low,
Some(CommonQuality::Medium) => GptImage1p5MultiFunctionImageGenQuality::Medium,
Some(CommonQuality::High) => GptImage1p5MultiFunctionImageGenQuality::High,
None => GptImage1p5MultiFunctionImageGenQuality::High,
}
}
@@ -67,6 +79,7 @@ fn plan_image_size(
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Square
@@ -195,10 +208,10 @@ mod tests {
// ── Quality ──────────────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_medium() {
fn quality_defaults_to_high() {
let request = base_gpt_image_1p5_image_request();
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::Medium));
assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::High));
}
// ── Num images ──────────────────────────────────────────────────────────────
@@ -277,17 +290,16 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_gpt_image_1p5_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1p5")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -45,9 +45,13 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation path hydrates media tokens into URLs before
// running cost estimation against the Artcraft provider. The cost only
// depends on `num_images` and `is_edit_mode` (which is derived from
// `request.image_inputs.is_some()` before this resolver runs), so URL-form
// inputs are accepted here and dropped — there's no Artcraft execution
// path for Nano Banana that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -290,6 +294,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
@@ -323,6 +328,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_nano_banana_image_request()
@@ -341,6 +347,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
@@ -356,17 +363,21 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on num_images + is_edit_mode.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_nano_banana_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else {
panic!("expected ArtcraftNanaBanana")
};
// image_inputs is dropped (None), but edit-mode aspect-ratio handling
// still kicks in because is_edit_mode is derived from request.image_inputs.
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -50,9 +50,12 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against the Artcraft provider. Cost only depends on
// num_images + resolution + is_edit_mode (derived before this resolver
// runs), so URL-form inputs are accepted and dropped — there's no Artcraft
// execution path for Nano Banana 2 that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -135,8 +138,10 @@ fn plan_resolution(
// No 3K option; default to 2K
Ok(Some(Nb2Res::TwoK))
}
// HalfK, FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::HalfK | R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(Nb2Res::OneK)),
// HalfK is natively supported (and billed at the cheaper 0.5K rate).
Some(R::HalfK) => Ok(Some(Nb2Res::HalfK)),
// FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(Nb2Res::OneK)),
}
}
@@ -50,9 +50,12 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against the Artcraft provider. Cost only depends on
// num_images + resolution + is_edit_mode (derived before this resolver
// runs), so URL-form inputs are accepted and dropped — there's no Artcraft
// execution path for Nano Banana Pro that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -354,6 +357,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
@@ -387,6 +391,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_image_request()
@@ -405,6 +410,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
@@ -420,17 +426,19 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on num_images + resolution + is_edit_mode.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else {
panic!("expected ArtcraftNanaBananaPro")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -82,7 +82,7 @@ fn plan_image_size(
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
@@ -226,6 +226,7 @@ mod tests {
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -71,7 +72,7 @@ fn plan_image_size(
if is_edit_mode { Ok(Some(S::Auto)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
// Square
@@ -295,6 +296,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4_image_request()
@@ -322,6 +324,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedream_4_image_request()
@@ -336,6 +339,7 @@ mod tests {
fn num_images_out_of_range_clamps_to_four() {
for strategy in [RequestMismatchMitigationStrategy::PayMoreUpgrade, RequestMismatchMitigationStrategy::PayLessDowngrade] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4_image_request()
@@ -348,16 +352,16 @@ mod tests {
// ── Image inputs ──────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_seedream_4_image_request()
};
assert!(matches!(
request.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftSeedream4(plan) = request.build().unwrap() else {
panic!("expected ArtcraftSeedream4")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -71,7 +72,7 @@ fn plan_image_size(
if is_edit_mode { Ok(Some(S::Auto2k)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
// Square
@@ -296,6 +297,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4p5_image_request()
@@ -323,6 +325,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedream_4p5_image_request()
@@ -337,6 +340,7 @@ mod tests {
fn num_images_out_of_range_clamps_to_four() {
for strategy in [RequestMismatchMitigationStrategy::PayMoreUpgrade, RequestMismatchMitigationStrategy::PayLessDowngrade] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4p5_image_request()
@@ -349,16 +353,16 @@ mod tests {
// ── Image inputs ──────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_seedream_4p5_image_request()
};
assert!(matches!(
request.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftSeedream4p5(plan) = request.build().unwrap() else {
panic!("expected ArtcraftSeedream4p5")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -72,6 +73,7 @@ fn plan_image_size(
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto3k)),
// 5 Lite max is auto_3K; map Auto4k down to Auto3k
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto3k)),
@@ -147,8 +149,6 @@ mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::test_helpers::base_seedream_5_lite_image_request;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::{
BytedanceSeedream5LiteMultiFunctionImageGenImageSize as S,
@@ -2,6 +2,7 @@ pub mod plan_generate_image_fal_flux_1_dev;
pub mod plan_generate_image_fal_flux_1_schnell;
pub mod plan_generate_image_fal_flux_pro_1p1;
pub mod plan_generate_image_fal_flux_pro_1p1_ultra;
pub mod plan_generate_image_fal_gpt_image_1;
pub mod plan_generate_image_fal_gpt_image_1p5;
pub mod plan_generate_image_fal_seedream_4;
pub mod plan_generate_image_fal_seedream_4p5;
@@ -0,0 +1,498 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
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::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
EnqueueGptImage1EditImageNumImages, EnqueueGptImage1EditImageQuality,
EnqueueGptImage1EditImageSize,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
EnqueueGptImage1TextToImageNumImages, EnqueueGptImage1TextToImageQuality,
EnqueueGptImage1TextToImageSize,
};
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1NumImages {
One,
Two,
Three,
Four,
}
/// Quality is shared between t2i and edit (Low / Medium / High).
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1Quality {
Low,
Medium,
High,
}
/// GPT Image 1 supports three image sizes: 1024x1024, 1536x1024, 1024x1536.
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1ImageSize {
Square,
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub struct PlanFalGptImage1<'a> {
pub prompt: Option<&'a str>,
/// Image URLs for editing. Empty vec = text-to-image mode.
pub image_urls: Vec<String>,
pub image_size: Option<FalGptImage1ImageSize>,
/// Quality defaults to Medium when the request leaves it unspecified.
/// (Matches the fal cost calculator default.)
pub quality: FalGptImage1Quality,
pub num_images: FalGptImage1NumImages,
}
pub fn plan_generate_image_fal_gpt_image_1<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage1(PlanFalGptImage1 {
prompt: request.prompt,
image_urls,
image_size,
quality,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage1Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage1Quality::Low,
Some(CommonQuality::Medium) => FalGptImage1Quality::Medium,
Some(CommonQuality::High) => FalGptImage1Quality::High,
None => FalGptImage1Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage1ImageSize> {
use FalGptImage1ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Vertical),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalGptImage1NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalGptImage1NumImages::One),
2 => Ok(FalGptImage1NumImages::Two),
3 => Ok(FalGptImage1NumImages::Three),
4 => Ok(FalGptImage1NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalGptImage1NumImages::Four),
},
}
}
// ── Conversions to fal-client enqueue enums ────────────────────────────────
impl FalGptImage1NumImages {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageNumImages {
use EnqueueGptImage1TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageNumImages {
use EnqueueGptImage1EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl FalGptImage1Quality {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageQuality {
use EnqueueGptImage1TextToImageQuality as T;
match self {
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageQuality {
use EnqueueGptImage1EditImageQuality as E;
match self {
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl FalGptImage1ImageSize {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageSize {
use EnqueueGptImage1TextToImageSize as T;
match self {
Self::Square => T::Square,
Self::Horizontal => T::Horizontal,
Self::Vertical => T::Vertical,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageSize {
use EnqueueGptImage1EditImageSize as E;
match self {
Self::Square => E::Square,
Self::Horizontal => E::Horizontal,
Self::Vertical => E::Vertical,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan<'a>(request: &'a GenerateImageRequest<'a>) -> PlanFalGptImage1<'a> {
let ImageGenerationPlan::FalGptImage1(plan) =
plan_generate_image_fal_gpt_image_1(request).expect("plan should succeed")
else {
panic!("expected FalGptImage1 variant")
};
plan
}
// ── Mode detection ────────────────────────────────────────────────────────
#[test]
fn no_image_inputs_yields_empty_urls() {
let request = GenerateImageRequest { image_inputs: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_urls.is_empty());
}
#[test]
fn url_image_inputs_are_extracted() {
let urls = vec![
"https://example.com/a.jpg".to_string(),
"https://example.com/b.jpg".to_string(),
];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = build_plan(&request);
assert_eq!(plan.image_urls, urls);
}
#[test]
fn media_token_inputs_return_error() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
// ── Quality default ──────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_high() {
let request = base_fal_request();
let plan = build_plan(&request);
assert!(matches!(plan.quality, FalGptImage1Quality::High));
}
// ── Image size mapping ───────────────────────────────────────────────────
#[test]
fn image_size_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_auto_variants_yield_none() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest { aspect_ratio: Some(auto_ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_size.is_none(), "expected None for {:?}", auto_ar);
}
}
#[test]
fn image_size_square_variants() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Square)), "expected Square for {:?}", ar);
}
}
#[test]
fn image_size_wide_variants_yield_horizontal() {
let wide_ars = [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
];
for ar in wide_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Horizontal)), "expected Horizontal for {:?}", ar);
}
}
#[test]
fn image_size_tall_variants_yield_vertical() {
let tall_ars = [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
];
for ar in tall_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Vertical)), "expected Vertical for {:?}", ar);
}
}
// ── Num images ───────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_default_is_one() {
let request = GenerateImageRequest { image_batch_count: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.num_images, FalGptImage1NumImages::One));
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, FalGptImage1NumImages::One),
(2, FalGptImage1NumImages::Two),
(3, FalGptImage1NumImages::Three),
(4, FalGptImage1NumImages::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_fal_request() };
let plan = build_plan(&request);
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.num_images, FalGptImage1NumImages::Four),
"expected Four for count 9 with {:?}", strategy,
);
}
}
// ── as_u64 ───────────────────────────────────────────────────────────────
#[test]
fn num_images_as_u64() {
assert_eq!(FalGptImage1NumImages::One.as_u64(), 1);
assert_eq!(FalGptImage1NumImages::Two.as_u64(), 2);
assert_eq!(FalGptImage1NumImages::Three.as_u64(), 3);
assert_eq!(FalGptImage1NumImages::Four.as_u64(), 4);
}
// ── Conversions to fal-client enqueue enums ─────────────────────────────
#[test]
fn num_images_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageNumImages as T;
assert!(matches!(FalGptImage1NumImages::One.to_t2i(), T::One));
assert!(matches!(FalGptImage1NumImages::Two.to_t2i(), T::Two));
assert!(matches!(FalGptImage1NumImages::Three.to_t2i(), T::Three));
assert!(matches!(FalGptImage1NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit_round_trip() {
use EnqueueGptImage1EditImageNumImages as E;
assert!(matches!(FalGptImage1NumImages::One.to_edit(), E::One));
assert!(matches!(FalGptImage1NumImages::Two.to_edit(), E::Two));
assert!(matches!(FalGptImage1NumImages::Three.to_edit(), E::Three));
assert!(matches!(FalGptImage1NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageQuality as T;
assert!(matches!(FalGptImage1Quality::Low.to_t2i(), T::Low));
assert!(matches!(FalGptImage1Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(FalGptImage1Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit_round_trip() {
use EnqueueGptImage1EditImageQuality as E;
assert!(matches!(FalGptImage1Quality::Low.to_edit(), E::Low));
assert!(matches!(FalGptImage1Quality::Medium.to_edit(), E::Medium));
assert!(matches!(FalGptImage1Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageSize as T;
assert!(matches!(FalGptImage1ImageSize::Square.to_t2i(), T::Square));
assert!(matches!(FalGptImage1ImageSize::Horizontal.to_t2i(), T::Horizontal));
assert!(matches!(FalGptImage1ImageSize::Vertical.to_t2i(), T::Vertical));
}
#[test]
fn size_to_edit_round_trip() {
use EnqueueGptImage1EditImageSize as E;
assert!(matches!(FalGptImage1ImageSize::Square.to_edit(), E::Square));
assert!(matches!(FalGptImage1ImageSize::Horizontal.to_edit(), E::Horizontal));
assert!(matches!(FalGptImage1ImageSize::Vertical.to_edit(), E::Vertical));
}
}
@@ -1,4 +1,5 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -53,13 +54,14 @@ pub fn plan_generate_image_fal_gpt_image_1p5<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage1p5(PlanFalGptImage1p5 {
prompt: request.prompt,
image_urls,
image_size,
quality: FalGptImage1p5Quality::Medium,
quality,
num_images,
}))
}
@@ -76,12 +78,22 @@ fn resolve_image_list_ref(
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage1p5Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage1p5Quality::Low,
Some(CommonQuality::Medium) => FalGptImage1p5Quality::Medium,
Some(CommonQuality::High) => FalGptImage1p5Quality::High,
None => FalGptImage1p5Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage1p5ImageSize> {
use FalGptImage1p5ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
@@ -256,6 +256,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -478,7 +479,8 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
@@ -511,6 +513,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
@@ -529,7 +532,8 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(5),
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
@@ -84,7 +84,7 @@ fn plan_image_size(
Some(CommonAspectRatio::Auto) => {
if is_edit_mode { Ok(Some(S::Auto)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
@@ -78,7 +78,7 @@ fn plan_image_size(
None => Ok(None),
// No bare Auto for v4.5 — fall back to Auto2k.
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
@@ -87,6 +87,7 @@ fn plan_image_size(
},
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto3k)),
// No 4K — fall back to 3K.
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto3k)),
@@ -54,9 +54,10 @@ fn resolve_image_ref<'a>(
match image_ref {
None => Ok(None),
Some(ImageRef::MediaFileToken(t)) => Ok(Some(t)),
Some(ImageRef::Url(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost doesn't depend on image content, so URL-form
// refs are accepted and dropped.
Some(ImageRef::Url(_)) => Ok(None),
}
}
@@ -279,15 +280,15 @@ mod tests {
}
#[test]
fn url_image_ref_returns_error() {
fn url_image_ref_accepted_for_cost_path() {
let request = GenerateVideoRequest {
start_frame: Some(ImageRef::Url("https://example.com/image.jpg")),
..base_seedance_1p5_pro_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let VideoGenerationPlan::ArtcraftSeedance1p5Pro(plan) = request.build().unwrap() else {
panic!("expected ArtcraftSeedance1p5Pro")
};
// URL is dropped; start_frame resolves to None on the artcraft plan.
assert!(plan.start_frame.is_none());
}
}
@@ -118,11 +118,27 @@ fn plan_aspect_ratio(
value: format!("{:?}", unsupported),
}))
}
_ => Ok(Some(Ar::Auto)),
_ => Ok(Some(nearest_aspect_ratio(unsupported))),
},
}
}
/// Pick the nearest supported aspect ratio for unsupported inputs.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> FalSeedance1p5ProAspectRatio {
use FalSeedance1p5ProAspectRatio as Ar;
match aspect_ratio {
// Wide unsupported → nearest wide
CommonAspectRatio::WideFiveByFour => Ar::FourByThree, // 1.25 → 1.33
CommonAspectRatio::WideThreeByTwo => Ar::FourByThree, // 1.50 → 1.33
// Tall unsupported → nearest tall
CommonAspectRatio::TallFourByFive => Ar::ThreeByFour, // 0.80 → 0.75
CommonAspectRatio::TallTwoByThree => Ar::ThreeByFour, // 0.67 → 0.75
CommonAspectRatio::TallNineByTwentyOne => Ar::NineBySixteen, // 0.43 → 0.56
// Anything else → Square
_ => Ar::Square,
}
}
fn plan_resolution(
resolution: Option<CommonResolution>,
strategy: RequestMismatchMitigationStrategy,
@@ -1,4 +1,5 @@
/// The result of estimating the cost of a video generation plan.
#[derive(Debug)]
pub struct VideoGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
@@ -29,6 +29,7 @@ pub fn base_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -55,6 +56,7 @@ pub fn base_fal_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -73,6 +75,7 @@ pub fn base_seedream_4_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -91,6 +94,7 @@ pub fn base_seedream_4p5_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -109,6 +113,7 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -127,6 +132,7 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -145,6 +151,26 @@ pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
pub fn base_gpt_image_1_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -163,6 +189,7 @@ pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -181,6 +208,7 @@ pub fn base_flux_1_dev_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -199,6 +227,7 @@ pub fn base_flux_1_schnell_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -217,6 +246,7 @@ pub fn base_flux_pro_1p1_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -235,6 +265,7 @@ pub fn base_qwen_edit_2511_angles_image_request() -> GenerateImageRequest<'stati
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -253,6 +284,7 @@ pub fn base_flux_2_lora_angles_image_request() -> GenerateImageRequest<'static>
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -271,6 +303,7 @@ pub fn base_flux_pro_1p1_ultra_image_request() -> GenerateImageRequest<'static>
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,57 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
/// Non-BYOK edit-image (image-to-image) binding for
/// `fal-ai/gpt-image-1/edit-image`. Fal hosts this model directly; pricing is
/// billed by Fal at their published rates.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GptImage1NonByokEditImageInput {
pub prompt: String,
pub image_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mask_image_url: Option<String>,
/// "auto", "1024x1024", "1536x1024", "1024x1536"
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
/// "low", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub input_fidelity: Option<String>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// "auto", "transparent", "opaque"
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// "jpeg", "png", "webp"
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokEditImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokEditImageOutput {
pub images: Vec<GptImage1NonByokEditImageFile>,
}
pub fn gpt_image_1_non_byok_edit_image(
params: GptImage1NonByokEditImageInput,
) -> FalRequest<GptImage1NonByokEditImageInput, GptImage1NonByokEditImageOutput> {
FalRequest::new("fal-ai/gpt-image-1/edit-image", params)
}
@@ -3,6 +3,7 @@ pub mod http_flux_1_schnell_edit_image;
pub mod http_flux_pro_kontext_max_edit;
pub mod http_gemini_25_flash_edit;
pub mod http_gpt_image_1_edit_image;
pub mod http_gpt_image_1_non_byok_edit_image;
pub mod http_gpt_image_1p5_edit_image;
pub mod http_nano_banana_2_edit_image;
pub mod http_nano_banana_pro_edit_image;
@@ -0,0 +1,49 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
/// Non-BYOK text-to-image binding for `fal-ai/gpt-image-1/text-to-image`.
///
/// Fal hosts this model directly (no caller-supplied OpenAI API key); pricing
/// is billed by Fal at their published rates.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GptImage1NonByokTextToImageInput {
pub prompt: String,
/// "auto", "1024x1024", "1536x1024", "1024x1536"
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// "auto", "transparent", "opaque"
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// "jpeg", "png", "webp"
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokTextToImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokTextToImageOutput {
pub images: Vec<GptImage1NonByokTextToImageFile>,
}
pub fn gpt_image_1_non_byok_text_to_image(
params: GptImage1NonByokTextToImageInput,
) -> FalRequest<GptImage1NonByokTextToImageInput, GptImage1NonByokTextToImageOutput> {
FalRequest::new("fal-ai/gpt-image-1/text-to-image", params)
}
@@ -3,6 +3,7 @@ pub mod http_flux_1_schnell_text_to_image;
pub mod http_flux_pro_11_text_to_image;
pub mod http_flux_pro_11_ultra_text_to_image;
pub mod http_gemini_25_flash_text_to_image;
pub mod http_gpt_image_1_non_byok_text_to_image;
pub mod http_gpt_image_1_text_to_image;
pub mod http_gpt_image_1p5_text_to_image;
pub mod http_nano_banana_2_text_to_image;
@@ -0,0 +1,159 @@
//! Deprecated BYOK (bring-your-own-OpenAI-key) edit-image binding for
//! `fal-ai/gpt-image-1/edit-image/byok`. The non-BYOK replacement lives in
//! `enqueue_gpt_image_1_edit_image_webhook.rs`. Kept for the legacy
//! `gpt_image_1_edit_image_handler` storyteller-web endpoint.
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::edit::http_gpt_image_1_edit_image::{gpt_image_1_edit_image, GptImage1EditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptEditImageByokArgs<'a, V: IntoUrl> {
// Request
pub image_urls: Vec<String>,
pub prompt: &'a str,
pub image_size: GptEditImageSize,
pub num_images: GptEditImageNumImages,
pub quality: GptEditImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageSize {
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageNumImages{
One,
Two,
Three,
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_byok_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let image_size = match args.image_size {
GptEditImageSize::Auto => "auto",
GptEditImageSize::Square => "1024x1024",
GptEditImageSize::Horizontal => "1536x1024",
GptEditImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptEditImageQuality::Auto => "auto",
GptEditImageQuality::Low => "low",
GptEditImageQuality::Medium => "medium",
GptEditImageQuality::High => "high",
};
let num_images = match args.num_images {
GptEditImageNumImages::One => 1,
GptEditImageNumImages::Two => 2,
GptEditImageNumImages::Three => 3,
GptEditImageNumImages::Four => 4,
};
let request = GptImage1EditImageInput {
image_urls: args.image_urls,
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_image_1_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::{
enqueue_gpt_image_1_byok_edit_image_webhook, GptEditImageByokArgs, GptEditImageNumImages,
GptEditImageQuality, GptEditImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{
ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL,
};
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&fal_secret);
let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let openai_api_key = OpenAiApiKey::from_str(&openai_secret);
let args = GptEditImageByokArgs {
image_urls: vec![
GHOST_IMAGE_URL.to_string(),
TREX_SKELETON_IMAGE_URL.to_string(),
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
],
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly",
image_size: GptEditImageSize::Horizontal,
num_images: GptEditImageNumImages::One,
quality: GptEditImageQuality::High,
api_key: &fal_api_key,
openai_api_key: &openai_api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_gpt_image_1_byok_edit_image_webhook(args).await?;
Ok(())
}
}
@@ -1,158 +1,232 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::edit::http_gpt_image_1_edit_image::{gpt_image_1_edit_image, GptImage1EditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::image::edit::http_gpt_image_1_non_byok_edit_image::{
gpt_image_1_non_byok_edit_image, GptImage1NonByokEditImageInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct GptEditImageByokArgs<'a, V: IntoUrl> {
// Request
pub image_urls: Vec<String>,
/// Typed args for the non-BYOK `fal-ai/gpt-image-1/edit-image` endpoint.
///
/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_edit_image_webhook.rs`
/// and is deprecated.)
pub struct EnqueueGptImage1EditImageArgs<'a, R: IntoUrl> {
// Required
pub prompt: &'a str,
pub image_size: GptEditImageSize,
pub num_images: GptEditImageNumImages,
pub quality: GptEditImageQuality,
pub image_urls: Vec<String>,
pub num_images: EnqueueGptImage1EditImageNumImages,
// Optional
pub mask_image_url: Option<String>,
pub image_size: Option<EnqueueGptImage1EditImageSize>,
pub quality: Option<EnqueueGptImage1EditImageQuality>,
pub input_fidelity: Option<EnqueueGptImage1EditImageInputFidelity>,
pub background: Option<EnqueueGptImage1EditImageBackground>,
pub output_format: Option<EnqueueGptImage1EditImageOutputFormat>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageSize {
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageNumImages{
pub enum EnqueueGptImage1EditImageNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageSize {
Auto,
/// 1024x1024
Square,
/// 1536x1024
Horizontal,
/// 1024x1536
Vertical,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageInputFidelity {
Low,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageBackground {
Auto,
Transparent,
Opaque,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageOutputFormat {
Jpeg,
Png,
Webp,
}
impl<U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
// Per fal docs (fal-ai/gpt-image-1/edit-image), pricing matches
// text-to-image (input image tokens are billed separately and not included
// in this base estimate):
// Low: $0.011 (1024x1024) / $0.016 (other) per image
// Medium: $0.042 (1024x1024) / $0.063 (other) per image
// High: $0.167 (1024x1024) / $0.25 (other) per image
let use_quality = self
.quality
.unwrap_or(EnqueueGptImage1EditImageQuality::Medium);
let is_square = matches!(
self.image_size,
None | Some(EnqueueGptImage1EditImageSize::Square) | Some(EnqueueGptImage1EditImageSize::Auto)
);
let base_cost: u64 = match (use_quality, is_square) {
(EnqueueGptImage1EditImageQuality::Low, true) => 2,
(EnqueueGptImage1EditImageQuality::Low, false) => 2,
(EnqueueGptImage1EditImageQuality::Medium, true) => 5,
(EnqueueGptImage1EditImageQuality::Medium, false) => 7,
(EnqueueGptImage1EditImageQuality::High, true) => 17,
(EnqueueGptImage1EditImageQuality::High, false) => 25,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
let n: u64 = match self.num_images {
EnqueueGptImage1EditImageNumImages::One => 1,
EnqueueGptImage1EditImageNumImages::Two => 2,
EnqueueGptImage1EditImageNumImages::Three => 3,
EnqueueGptImage1EditImageNumImages::Four => 4,
};
cost as UsdCents
base_cost * n
}
}
pub async fn enqueue_gpt_image_1_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
pub async fn enqueue_gpt_image_1_edit_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1EditImageArgs<'_, R>,
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptEditImageSize::Auto => "auto",
GptEditImageSize::Square => "1024x1024",
GptEditImageSize::Horizontal => "1536x1024",
GptEditImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptEditImageQuality::Auto => "auto",
GptEditImageQuality::Low => "low",
GptEditImageQuality::Medium => "medium",
GptEditImageQuality::High => "high",
let num_images: u8 = match args.num_images {
EnqueueGptImage1EditImageNumImages::One => 1,
EnqueueGptImage1EditImageNumImages::Two => 2,
EnqueueGptImage1EditImageNumImages::Three => 3,
EnqueueGptImage1EditImageNumImages::Four => 4,
};
let num_images = match args.num_images {
GptEditImageNumImages::One => 1,
GptEditImageNumImages::Two => 2,
GptEditImageNumImages::Three => 3,
GptEditImageNumImages::Four => 4,
};
let image_size = args.image_size.map(|s| {
match s {
EnqueueGptImage1EditImageSize::Auto => "auto",
EnqueueGptImage1EditImageSize::Square => "1024x1024",
EnqueueGptImage1EditImageSize::Horizontal => "1536x1024",
EnqueueGptImage1EditImageSize::Vertical => "1024x1536",
}
.to_string()
});
let request = GptImage1EditImageInput {
image_urls: args.image_urls,
let quality = args.quality.map(|q| {
match q {
EnqueueGptImage1EditImageQuality::Low => "low",
EnqueueGptImage1EditImageQuality::Medium => "medium",
EnqueueGptImage1EditImageQuality::High => "high",
}
.to_string()
});
let input_fidelity = args.input_fidelity.map(|f| {
match f {
EnqueueGptImage1EditImageInputFidelity::Low => "low",
EnqueueGptImage1EditImageInputFidelity::High => "high",
}
.to_string()
});
let background = args.background.map(|b| {
match b {
EnqueueGptImage1EditImageBackground::Auto => "auto",
EnqueueGptImage1EditImageBackground::Transparent => "transparent",
EnqueueGptImage1EditImageBackground::Opaque => "opaque",
}
.to_string()
});
let output_format = args.output_format.map(|f| {
match f {
EnqueueGptImage1EditImageOutputFormat::Jpeg => "jpeg",
EnqueueGptImage1EditImageOutputFormat::Png => "png",
EnqueueGptImage1EditImageOutputFormat::Webp => "webp",
}
.to_string()
});
let request = GptImage1NonByokEditImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
image_urls: args.image_urls,
mask_image_url: args.mask_image_url,
num_images: Some(num_images),
image_size,
quality,
input_fidelity,
background,
output_format,
};
let result = gpt_image_1_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
let result = gpt_image_1_non_byok_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{enqueue_gpt_image_1_edit_image_webhook, GptEditImageByokArgs, GptEditImageNumImages, GptEditImageQuality, GptEditImageSize};
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs,
EnqueueGptImage1EditImageNumImages, EnqueueGptImage1EditImageQuality,
EnqueueGptImage1EditImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, GRASSY_HILL_TRANSPARENT_IMAGE_URL};
use test_data::web::image_urls::{
ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL,
};
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptEditImageByokArgs {
let args = EnqueueGptImage1EditImageArgs {
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly",
image_urls: vec![
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
GRASSY_HILL_TRANSPARENT_IMAGE_URL.to_string(),
TREX_SKELETON_IMAGE_URL.to_string(),
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
],
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
num_images: EnqueueGptImage1EditImageNumImages::One,
mask_image_url: None,
image_size: Some(EnqueueGptImage1EditImageSize::Horizontal),
quality: Some(EnqueueGptImage1EditImageQuality::Medium),
input_fidelity: None,
background: None,
output_format: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
image_size: GptEditImageSize::Horizontal,
num_images: GptEditImageNumImages::One,
quality: GptEditImageQuality::High,
};
let result = enqueue_gpt_image_1_edit_image_webhook(args).await?;
let _result = enqueue_gpt_image_1_edit_image_webhook(args).await?;
Ok(())
}
@@ -6,6 +6,7 @@ pub mod enqueue_bytedance_seedream_v5_lite_edit_image_webhook;
pub mod enqueue_nano_banana_2_edit_image_webhook;
pub mod enqueue_flux_pro_kontext_max_edit_webhook;
pub mod enqueue_gemini_25_flash_edit_webhook;
pub mod enqueue_gpt_image_1_byok_edit_image_webhook;
pub mod enqueue_gpt_image_1_edit_image_webhook;
pub mod enqueue_gpt_image_1p5_edit_image_webhook;
pub mod enqueue_nano_banana_pro_edit_image_webhook;
@@ -0,0 +1,154 @@
//! Deprecated BYOK (bring-your-own-OpenAI-key) text-to-image binding for
//! `fal-ai/gpt-image-1/text-to-image/byok`. The non-BYOK replacement lives in
//! `enqueue_gpt_image_1_text_to_image_webhook.rs`. Kept for the legacy
//! `generate_gpt_image_1_text_to_image_handler` storyteller-web endpoint.
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::text::http_gpt_image_1_text_to_image::{gpt_image_1_text_to_image, GptImage1TextToImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
One,
Two,
Three,
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_byok_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let request = GptImage1TextToImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_image_1_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::text::enqueue_gpt_image_1_byok_text_to_image_webhook::{
enqueue_gpt_image_1_byok_text_to_image_webhook, GptTextToImageByokArgs,
GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&fal_secret);
let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let openai_api_key = OpenAiApiKey::from_str(&openai_secret);
let args = GptTextToImageByokArgs {
prompt: "an anime girl riding on the back of a t-rex",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
api_key: &fal_api_key,
openai_api_key: &openai_api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_gpt_image_1_byok_text_to_image_webhook(args).await?;
Ok(())
}
}
@@ -1,122 +1,179 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::text::http_gpt_image_1_text_to_image::{gpt_image_1_text_to_image, GptImage1TextToImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::image::text::http_gpt_image_1_non_byok_text_to_image::{
gpt_image_1_non_byok_text_to_image, GptImage1NonByokTextToImageInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
/// Typed args for the non-BYOK `fal-ai/gpt-image-1/text-to-image` endpoint.
///
/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_text_to_image_webhook.rs`
/// and is deprecated.)
pub struct EnqueueGptImage1TextToImageArgs<'a, R: IntoUrl> {
// Required
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
pub num_images: EnqueueGptImage1TextToImageNumImages,
// Optional
pub image_size: Option<EnqueueGptImage1TextToImageSize>,
pub quality: Option<EnqueueGptImage1TextToImageQuality>,
pub background: Option<EnqueueGptImage1TextToImageBackground>,
pub output_format: Option<EnqueueGptImage1TextToImageOutputFormat>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
pub enum EnqueueGptImage1TextToImageNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageSize {
Auto,
/// 1024x1024
Square,
/// 1536x1024
Horizontal,
/// 1024x1536
Vertical,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageBackground {
Auto,
Transparent,
Opaque,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageOutputFormat {
Jpeg,
Png,
Webp,
}
impl<U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
// Per fal docs (fal-ai/gpt-image-1/text-to-image):
// Low: $0.011 (1024x1024) / $0.016 (other) per image
// Medium: $0.042 (1024x1024) / $0.063 (other) per image
// High: $0.167 (1024x1024) / $0.25 (other) per image
// Default quality is Medium when unspecified, square when size unspecified.
let use_quality = self
.quality
.unwrap_or(EnqueueGptImage1TextToImageQuality::Medium);
let is_square = matches!(
self.image_size,
None | Some(EnqueueGptImage1TextToImageSize::Square) | Some(EnqueueGptImage1TextToImageSize::Auto)
);
let base_cost: u64 = match (use_quality, is_square) {
(EnqueueGptImage1TextToImageQuality::Low, true) => 2,
(EnqueueGptImage1TextToImageQuality::Low, false) => 2,
(EnqueueGptImage1TextToImageQuality::Medium, true) => 5,
(EnqueueGptImage1TextToImageQuality::Medium, false) => 7,
(EnqueueGptImage1TextToImageQuality::High, true) => 17,
(EnqueueGptImage1TextToImageQuality::High, false) => 25,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
let n: u64 = match self.num_images {
EnqueueGptImage1TextToImageNumImages::One => 1,
EnqueueGptImage1TextToImageNumImages::Two => 2,
EnqueueGptImage1TextToImageNumImages::Three => 3,
EnqueueGptImage1TextToImageNumImages::Four => 4,
};
cost as UsdCents
base_cost * n
}
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
pub async fn enqueue_gpt_image_1_text_to_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1TextToImageArgs<'_, R>,
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
let num_images: u8 = match args.num_images {
EnqueueGptImage1TextToImageNumImages::One => 1,
EnqueueGptImage1TextToImageNumImages::Two => 2,
EnqueueGptImage1TextToImageNumImages::Three => 3,
EnqueueGptImage1TextToImageNumImages::Four => 4,
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let image_size = args.image_size.map(|s| {
match s {
EnqueueGptImage1TextToImageSize::Auto => "auto",
EnqueueGptImage1TextToImageSize::Square => "1024x1024",
EnqueueGptImage1TextToImageSize::Horizontal => "1536x1024",
EnqueueGptImage1TextToImageSize::Vertical => "1024x1536",
}
.to_string()
});
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let quality = args.quality.map(|q| {
match q {
EnqueueGptImage1TextToImageQuality::Low => "low",
EnqueueGptImage1TextToImageQuality::Medium => "medium",
EnqueueGptImage1TextToImageQuality::High => "high",
}
.to_string()
});
let request = GptImage1TextToImageInput {
let background = args.background.map(|b| {
match b {
EnqueueGptImage1TextToImageBackground::Auto => "auto",
EnqueueGptImage1TextToImageBackground::Transparent => "transparent",
EnqueueGptImage1TextToImageBackground::Opaque => "opaque",
}
.to_string()
});
let output_format = args.output_format.map(|f| {
match f {
EnqueueGptImage1TextToImageOutputFormat::Jpeg => "jpeg",
EnqueueGptImage1TextToImageOutputFormat::Png => "png",
EnqueueGptImage1TextToImageOutputFormat::Webp => "webp",
}
.to_string()
});
let request = GptImage1NonByokTextToImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
num_images: Some(num_images),
image_size,
quality,
background,
output_format,
};
let result = gpt_image_1_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
let result = gpt_image_1_non_byok_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use crate::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs,
EnqueueGptImage1TextToImageNumImages, EnqueueGptImage1TextToImageQuality,
EnqueueGptImage1TextToImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
@@ -125,24 +182,20 @@ mod tests {
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptTextToImageByokArgs {
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
let args = EnqueueGptImage1TextToImageArgs {
prompt: "an anime girl riding on the back of a t-rex",
num_images: EnqueueGptImage1TextToImageNumImages::One,
image_size: Some(EnqueueGptImage1TextToImageSize::Horizontal),
quality: Some(EnqueueGptImage1TextToImageQuality::Medium),
background: None,
output_format: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
};
let result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
let _result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
Ok(())
}
@@ -7,6 +7,7 @@ pub mod enqueue_flux_1_schnell_text_to_image_webhook;
pub mod enqueue_flux_pro_11_text_to_image_webhook;
pub mod enqueue_flux_pro_11_ultra_text_to_image_webhook;
pub mod enqueue_gemini_25_flash_text_to_image_webhook;
pub mod enqueue_gpt_image_1_byok_text_to_image_webhook;
pub mod enqueue_gpt_image_1_text_to_image_webhook;
pub mod enqueue_gpt_image_1p5_text_to_image_webhook;
pub mod enqueue_nano_banana_pro_text_to_image_webhook;
@@ -1,36 +1,44 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
use crate::webhook_api::raw::webhook_error_type::WebhookErrorType;
use crate::webhook_api::hydrated::hydrated_webhook_contents::{ErrorData, PayloadErrorData, WebhookSuccessData, HydratedWebhookContents};
use crate::webhook_api::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus};
use crate::webhook_api::hydrated::success_case_extractors::extract_contents_from_payload;
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
pub fn hydrate_webhook_contents(webhook: &RawWebhookPayload) -> HydratedWebhookContents {
match webhook.status {
WebhookStatus::Error => {
RawWebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
HydratedWebhookContents::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
RawWebhookStatus::Ok => {
// Check for `payload_error` case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
return HydratedWebhookContents::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
let value = webhook.payload
.clone()
.unwrap_or(Value::Null);
let extracted_contents = extract_contents_from_payload(&value);
HydratedWebhookContents::Success(WebhookSuccessData {
payload: value,
extracted_contents,
})
}
}
@@ -67,7 +75,7 @@ fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Optio
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
@@ -77,11 +85,11 @@ mod tests {
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
let webhook = load_test_webhook("failure/gpt_image_1p5_fail_content_policy.json");
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
@@ -91,17 +99,17 @@ mod tests {
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
let webhook = load_test_webhook("failure/gpt_image_fail_invalid_api_key.json");
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
@@ -111,17 +119,17 @@ mod tests {
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
let webhook = load_test_webhook("failure/kling_1p6_pro_file_too_large_error.json");
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
@@ -131,17 +139,17 @@ mod tests {
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
let webhook = load_test_webhook("failure/kling_3p0_pro_fail_content_policy.json");
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
@@ -151,17 +159,17 @@ mod tests {
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
let webhook = load_test_webhook("failure/nano_banana_pro_fail_no_media_generated.json");
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
@@ -170,71 +178,91 @@ mod tests {
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
status: RawWebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
HydratedWebhookContents::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
status: RawWebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
HydratedWebhookContents::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Success, got {:?}", other),
}
}
#[test]
fn seedream_partner_validation_failed() {
let webhook = load_test_webhook("failure/seedream_partner_validation_failed.json");
let result = hydrate_webhook_contents(&webhook);
match result {
HydratedWebhookContents::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
let webhook = RawWebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
status: RawWebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
let result = hydrate_webhook_contents(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
HydratedWebhookContents::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
other => panic!("Expected HydratedWebhookContents::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,127 @@
use serde::Deserialize;
use serde_json::Value;
use crate::webhook_api::raw::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum HydratedWebhookContents {
/// The webhook reported success and has a payload.
Success(WebhookSuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported as "success" but (1) had no payload and (2) had a payload_error.
/// In rare instances of an "OK" response, there may be an error on Fal's end with encoding
/// the payload. If that happens, the "payload_error" field may be set, and this enum variant
/// represents that failure case.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct WebhookSuccessData {
/// The success data is polymorphic, so we're returning a JSON `Value` for now.
/// This will allow for downstream handlers to parse the payload as needed.
/// This is the entire raw success payload.
pub payload: Value,
/// If there are any extracted sub-payload contents, such as "images" or "video",
/// then they are included here. This may not be fully inclusive of future
/// payload types.
pub extracted_contents: Option<ExtractedContents>,
}
#[derive(Debug)]
pub struct ExtractedContents {
/// Parsed from `payload.image` (single image result).
pub image: Option<ImageData>,
/// Parsed from `payload.images` (batch image results).
pub images: Option<Vec<ImagesData>>,
/// Parsed from `payload.video`.
pub video: Option<VideoData>,
/// Parsed from `payload.model_glb`.
pub model_glb: Option<ModelGlbData>,
/// Parsed from `payload.model_mesh`.
pub model_mesh: Option<ModelMeshData>,
/// Parsed from `payload.thumbnail`.
pub thumbnail: Option<ThumbnailData>,
}
/// Data under `payload.image`:
#[derive(Debug, Deserialize)]
pub struct ImageData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub height: Option<u64>,
pub width: Option<u64>,
}
/// Data under `payload.images` (a list of these):
#[derive(Debug, Deserialize)]
pub struct ImagesData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub height: Option<u64>,
pub width: Option<u64>,
}
/// Data under `payload.video`:
#[derive(Debug, Deserialize)]
pub struct VideoData {
pub url: Option<String>,
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
}
/// Data under `payload.model_glb` (there may be other sibling keys too)
#[derive(Debug, Deserialize)]
pub struct ModelGlbData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
/// Data under `payload.model_mesh` (there may be other sibling keys too)
#[derive(Debug, Deserialize)]
pub struct ModelMeshData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
/// Data under `payload.thumbnail` (there may be other sibling keys too)
/// Frequently seen together with `model_glb`.
#[derive(Debug, Deserialize)]
pub struct ThumbnailData {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub file_size: Option<usize>,
pub url: Option<String>,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,2 @@
pub mod hydrated_webhook_contents;
pub mod success_case_extractors;
@@ -0,0 +1,100 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ImageData;
/// Extract and deserialize the `image` key from a webhook success payload.
pub (crate) fn extract_image(obj: &Map<String, Value>) -> Option<ImageData> {
let value = obj.get("image")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::{RawWebhookPayload, RawWebhookStatus};
#[test]
fn payload_without_known_keys_has_none_extracted_contents() {
let webhook = RawWebhookPayload {
request_id: "test-no-keys".to_string(),
gateway_request_id: "test-no-keys".to_string(),
status: RawWebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"some_other_key": "value"})),
payload_error: None,
};
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
assert!(data.extracted_contents.is_none());
}
#[test]
fn null_payload_has_none_extracted_contents() {
let webhook = RawWebhookPayload {
request_id: "test-null".to_string(),
gateway_request_id: "test-null".to_string(),
status: RawWebhookStatus::Ok,
error: None,
payload: None,
payload_error: None,
};
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
assert!(data.extracted_contents.is_none());
}
#[test]
fn synthetic_image_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {
"url": "https://cdn.example.com/single.png",
"content_type": "image/png",
"file_name": "single.png",
"file_size": 98765,
"width": 512,
"height": 512
}
}"#).unwrap();
let image = extract_image(&obj).expect("should extract image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/single.png"));
assert_eq!(image.content_type.as_deref(), Some("image/png"));
assert_eq!(image.file_name.as_deref(), Some("single.png"));
assert_eq!(image.file_size, Some(98765));
assert_eq!(image.width, Some(512));
assert_eq!(image.height, Some(512));
}
#[test]
fn synthetic_image_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {"url": "https://cdn.example.com/img.jpg"}
}"#).unwrap();
let image = extract_image(&obj).expect("should extract image");
assert_eq!(image.url.as_deref(), Some("https://cdn.example.com/img.jpg"));
assert!(image.content_type.is_none());
assert!(image.file_size.is_none());
}
#[test]
fn missing_image_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [{"url": "https://example.com/img.png"}]
}"#).unwrap();
assert!(extract_image(&obj).is_none());
}
}
@@ -0,0 +1,96 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ImagesData;
/// Extract and deserialize the `images` key from a webhook success payload.
pub (crate) fn extract_images(obj: &Map<String, Value>) -> Option<Vec<ImagesData>> {
let value = obj.get("images")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn images_payload_from_test_file() {
let webhook = load_test_webhook("success/images_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let images = contents.images.expect("images should be Some");
assert_eq!(images.len(), 2);
assert_eq!(images[0].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name1.png"));
assert_eq!(images[0].content_type.as_deref(), Some("image/png"));
assert_eq!(images[0].file_name.as_deref(), Some("name1.png"));
assert_eq!(images[0].file_size, Some(11500002));
assert_eq!(images[1].url.as_deref(), Some("https://v3b.fal.media/files/b/01234567/name2.png"));
assert_eq!(images[1].file_size, Some(13499999));
assert!(contents.image.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_images_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [
{"url": "https://example.com/a.png", "content_type": "image/png", "width": 1024, "height": 768},
{"url": "https://example.com/b.jpg", "content_type": "image/jpeg", "file_size": 54321}
]
}"#).unwrap();
let images = extract_images(&obj).expect("should extract images");
assert_eq!(images.len(), 2);
assert_eq!(images[0].url.as_deref(), Some("https://example.com/a.png"));
assert_eq!(images[0].width, Some(1024));
assert_eq!(images[0].height, Some(768));
assert!(images[0].file_size.is_none());
assert_eq!(images[1].url.as_deref(), Some("https://example.com/b.jpg"));
assert_eq!(images[1].content_type.as_deref(), Some("image/jpeg"));
assert_eq!(images[1].file_size, Some(54321));
assert!(images[1].width.is_none());
}
#[test]
fn empty_images_array_returns_empty_vec() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": []
}"#).unwrap();
let images = extract_images(&obj).expect("should extract empty images");
assert!(images.is_empty());
}
#[test]
fn missing_images_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/v.mp4"}
}"#).unwrap();
assert!(extract_images(&obj).is_none());
}
}
@@ -0,0 +1,91 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ModelGlbData;
/// Extract and deserialize the `model_glb` key from a webhook success payload.
pub (crate) fn extract_model_glb(obj: &Map<String, Value>) -> Option<ModelGlbData> {
let value = obj.get("model_glb")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_glb_from_test_file() {
let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let glb = contents.model_glb.expect("model_glb should be Some");
assert_eq!(glb.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb37/f7UaAhyfcx0BAnYy0GpFM_model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("model.glb"));
assert_eq!(glb.file_size, Some(66284788));
// This payload also has a thumbnail.
assert!(contents.thumbnail.is_some());
// No image/images/video/mesh in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_model_glb_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {
"url": "https://cdn.example.com/model.glb",
"content_type": "model/gltf-binary",
"file_name": "output.glb",
"file_size": 5432100
}
}"#).unwrap();
let glb = extract_model_glb(&obj).expect("should extract model_glb");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/model.glb"));
assert_eq!(glb.content_type.as_deref(), Some("model/gltf-binary"));
assert_eq!(glb.file_name.as_deref(), Some("output.glb"));
assert_eq!(glb.file_size, Some(5432100));
}
#[test]
fn model_glb_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://cdn.example.com/m.glb"}
}"#).unwrap();
let glb = extract_model_glb(&obj).expect("should extract model_glb");
assert_eq!(glb.url.as_deref(), Some("https://cdn.example.com/m.glb"));
assert!(glb.content_type.is_none());
}
#[test]
fn missing_model_glb_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://example.com/v.mp4"}
}"#).unwrap();
assert!(extract_model_glb(&obj).is_none());
}
}
@@ -0,0 +1,89 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ModelMeshData;
/// Extract and deserialize the `model_mesh` key from a webhook success payload.
pub (crate) fn extract_model_mesh(obj: &Map<String, Value>) -> Option<ModelMeshData> {
let value = obj.get("model_mesh")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn model_mesh_from_test_file() {
let webhook = load_test_webhook("success/model_mesh_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let mesh = contents.model_mesh.expect("model_mesh should be Some");
assert_eq!(mesh.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb2d/XCpH7fk-tZVXr1XMUr7DY_mesh-1775972273-765284.glb"));
assert_eq!(mesh.content_type.as_deref(), Some("application/octet-stream"));
assert_eq!(mesh.file_name.as_deref(), Some("mesh-1775972273-765284.glb"));
assert_eq!(mesh.file_size, Some(3886928));
// No other content types in this payload.
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.video.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.thumbnail.is_none());
}
#[test]
fn synthetic_model_mesh_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {
"url": "https://cdn.example.com/mesh.obj",
"content_type": "model/obj",
"file_name": "output.obj",
"file_size": 1234567
}
}"#).unwrap();
let mesh = extract_model_mesh(&obj).expect("should extract model_mesh");
assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/mesh.obj"));
assert_eq!(mesh.content_type.as_deref(), Some("model/obj"));
assert_eq!(mesh.file_name.as_deref(), Some("output.obj"));
assert_eq!(mesh.file_size, Some(1234567));
}
#[test]
fn model_mesh_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_mesh": {"url": "https://cdn.example.com/m.obj"}
}"#).unwrap();
let mesh = extract_model_mesh(&obj).expect("should extract model_mesh");
assert_eq!(mesh.url.as_deref(), Some("https://cdn.example.com/m.obj"));
assert!(mesh.content_type.is_none());
}
#[test]
fn missing_model_mesh_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"image": {"url": "https://example.com/img.png"}
}"#).unwrap();
assert!(extract_model_mesh(&obj).is_none());
}
}
@@ -0,0 +1,86 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::ThumbnailData;
/// Extract and deserialize the `thumbnail` key from a webhook success payload.
pub(crate) fn extract_thumbnail(obj: &Map<String, Value>) -> Option<ThumbnailData> {
let value = obj.get("thumbnail")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn thumbnail_from_model_glb_test_file() {
let webhook = load_test_webhook("success/model_glb_urls_and_thumbnail_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let thumbnail = contents.thumbnail.expect("thumbnail should be Some");
assert_eq!(thumbnail.url.as_deref(), Some("https://v3b.fal.media/files/b/0a95eb38/yvwGJWZV9TkWZuZvjxZNj_preview.png"));
assert_eq!(thumbnail.content_type.as_deref(), Some("image/png"));
assert_eq!(thumbnail.file_name.as_deref(), Some("preview.png"));
assert_eq!(thumbnail.file_size, Some(93436));
}
#[test]
fn synthetic_thumbnail_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"thumbnail": {
"url": "https://cdn.example.com/thumb.png",
"content_type": "image/png",
"file_name": "thumb.png",
"file_size": 12345
}
}"#).unwrap();
let thumb = extract_thumbnail(&obj).expect("should extract thumbnail");
assert_eq!(thumb.url.as_deref(), Some("https://cdn.example.com/thumb.png"));
assert_eq!(thumb.content_type.as_deref(), Some("image/png"));
assert_eq!(thumb.file_name.as_deref(), Some("thumb.png"));
assert_eq!(thumb.file_size, Some(12345));
}
#[test]
fn missing_thumbnail_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"model_glb": {"url": "https://example.com/model.glb"}
}"#).unwrap();
assert!(extract_thumbnail(&obj).is_none());
}
#[test]
fn model_mesh_payload_has_no_thumbnail() {
let webhook = load_test_webhook("success/model_mesh_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
assert!(contents.thumbnail.is_none());
}
}
@@ -0,0 +1,90 @@
use serde_json::{Map, Value};
use crate::webhook_api::hydrated::hydrated_webhook_contents::VideoData;
/// Extract and deserialize the `video` key from a webhook success payload.
pub (crate) fn extract_video(obj: &Map<String, Value>) -> Option<VideoData> {
let value = obj.get("video")?;
serde_json::from_value(value.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::webhook_api::hydrate_webhook_contents::hydrate_webhook_contents;
use crate::webhook_api::hydrated::hydrated_webhook_contents::HydratedWebhookContents;
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
fn load_test_webhook(filename: &str) -> RawWebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn video_payload_from_test_file() {
let webhook = load_test_webhook("success/video_payload_1.json");
let result = hydrate_webhook_contents(&webhook);
let HydratedWebhookContents::Success(data) = result else {
panic!("Expected Success, got {:?}", result);
};
let contents = data.extracted_contents
.expect("extracted_contents should be Some");
let video = contents.video.expect("video should be Some");
assert_eq!(video.url.as_deref(), Some("https://v3b.fal.media/files/b/0abcdef0/AB-CDE_123456789abcde_output.mp4"));
assert_eq!(video.content_type.as_deref(), Some("video/mp4"));
assert_eq!(video.file_name.as_deref(), Some("output.mp4"));
assert_eq!(video.file_size, Some(6226845));
assert!(contents.image.is_none());
assert!(contents.images.is_none());
assert!(contents.model_glb.is_none());
assert!(contents.model_mesh.is_none());
}
#[test]
fn synthetic_video_payload() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {
"url": "https://cdn.example.com/output.mp4",
"content_type": "video/mp4",
"file_name": "clip.mp4",
"file_size": 12345678
}
}"#).unwrap();
let video = extract_video(&obj).expect("should extract video");
assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/output.mp4"));
assert_eq!(video.content_type.as_deref(), Some("video/mp4"));
assert_eq!(video.file_name.as_deref(), Some("clip.mp4"));
assert_eq!(video.file_size, Some(12345678));
}
#[test]
fn synthetic_video_url_only() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"video": {"url": "https://cdn.example.com/v.mp4"}
}"#).unwrap();
let video = extract_video(&obj).expect("should extract video");
assert_eq!(video.url.as_deref(), Some("https://cdn.example.com/v.mp4"));
assert!(video.content_type.is_none());
assert!(video.file_name.is_none());
assert!(video.file_size.is_none());
}
#[test]
fn missing_video_key_returns_none() {
let obj: serde_json::Map<String, serde_json::Value> = serde_json::from_str(r#"{
"images": [{"url": "https://example.com/img.png"}]
}"#).unwrap();
assert!(extract_video(&obj).is_none());
}
}
@@ -0,0 +1,43 @@
pub mod extract_image_payload;
pub mod extract_images_payload;
pub mod extract_video_payload;
pub mod extract_model_glb_payload;
pub mod extract_model_mesh_payload;
pub mod extract_thumbnail_payload;
use serde_json::Value;
use crate::webhook_api::hydrated::hydrated_webhook_contents::ExtractedContents;
/// Try to extract known content keys from a success payload.
///
/// Checks the payload (as a JSON object) for any of: `image`, `images`,
/// `video`, `model_glb`, `model_mesh`. If at least one is found, returns
/// `Some(ExtractedContents)` with copies of the relevant values. Multiple
/// keys can be populated simultaneously. Returns `None` if the payload is
/// not an object or none of the known keys are present.
pub fn extract_contents_from_payload(payload: &Value) -> Option<ExtractedContents> {
let obj = payload.as_object()?;
let image = extract_image_payload::extract_image(obj);
let images = extract_images_payload::extract_images(obj);
let video = extract_video_payload::extract_video(obj);
let model_glb = extract_model_glb_payload::extract_model_glb(obj);
let model_mesh = extract_model_mesh_payload::extract_model_mesh(obj);
let thumbnail = extract_thumbnail_payload::extract_thumbnail(obj);
if image.is_none() && images.is_none() && video.is_none()
&& model_glb.is_none() && model_mesh.is_none() && thumbnail.is_none()
{
return None;
}
Some(ExtractedContents {
image,
images,
video,
model_glb,
model_mesh,
thumbnail,
})
}
@@ -1,3 +1,4 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
pub mod raw;
pub mod hydrated;
pub mod hydrate_webhook_contents;
pub mod parse_raw_webhook_payload;
@@ -0,0 +1,5 @@
use crate::webhook_api::raw::raw_webhook_payload::RawWebhookPayload;
pub fn parse_raw_webhook_payload(json: &str) -> Result<RawWebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -1,5 +0,0 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -1,3 +0,0 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -1,32 +0,0 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}

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