Compare commits

...
Author SHA1 Message Date
Brandon Thomas bb278e980e more tests 2026-05-14 20:45:37 -04:00
Brandon Thomas 3f09515c84 add pricing tests 2026-05-14 20:40:51 -04:00
Brandon Thomas b100f33548 add resolution support to gpt-image-2 at fal 2026-05-14 19:46:59 -04:00
9 changed files with 1000 additions and 359 deletions
@@ -0,0 +1,206 @@
use serde::{Deserialize, Serialize};
/// Our synthetic resolution tiers for GPT Image 2. When combined with an
/// aspect ratio (image_size), we compute a concrete width x height that
/// satisfies all GPT Image 2 constraints:
///
/// - Maximum edge length <= 3840px
/// - Both edges must be multiples of 16px
/// - Long-to-short ratio must not exceed 3:1
/// - Total pixels: 655,360 <= w*h <= 8,294,400
#[derive(Copy, Clone, Debug)]
pub enum GptImage2Resolution {
/// ~1K on the long edge (1024px baseline)
OneK,
/// ~2K on the long edge (2048px baseline)
TwoK,
/// ~3K on the long edge (3072px baseline)
ThreeK,
/// ~4K on the long edge (max edge, up to 3840px)
FourK,
}
/// The aspect ratios supported by GPT Image 2 (the standard image_size enum
/// values that carry aspect-ratio semantics).
#[derive(Copy, Clone, Debug)]
pub enum GptImage2AspectRatio {
/// 1:1
Square,
/// 1:1 (HD variant — same ratio, higher default res)
SquareHd,
/// 4:3
Landscape4x3,
/// 16:9
Landscape16x9,
/// 3:4
Portrait4x3,
/// 9:16
Portrait16x9,
}
/// A custom image size with explicit width and height, serialisable as the
/// JSON object `{"width": N, "height": N}` that Fal accepts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomImageSize {
pub width: u32,
pub height: u32,
}
const MIN_PIXELS: u64 = 655_360;
const MAX_PIXELS: u64 = 8_294_400;
const MAX_EDGE: u32 = 3840;
/// Given an aspect ratio and a resolution tier, compute a concrete
/// `CustomImageSize`. The returned dimensions are guaranteed to satisfy all
/// GPT Image 2 constraints.
pub fn compute_custom_image_size(aspect: GptImage2AspectRatio, resolution: GptImage2Resolution) -> CustomImageSize {
// Aspect ratio as (w_ratio, h_ratio)
let (w_ratio, h_ratio): (u32, u32) = match aspect {
GptImage2AspectRatio::Square | GptImage2AspectRatio::SquareHd => (1, 1),
GptImage2AspectRatio::Landscape4x3 => (4, 3),
GptImage2AspectRatio::Landscape16x9 => (16, 9),
GptImage2AspectRatio::Portrait4x3 => (3, 4),
GptImage2AspectRatio::Portrait16x9 => (9, 16),
};
// Target long-edge pixels for each tier
let target_long_edge: u32 = match resolution {
GptImage2Resolution::OneK => 1024,
GptImage2Resolution::TwoK => 2048,
GptImage2Resolution::ThreeK => 3072,
GptImage2Resolution::FourK => 3840,
};
// Compute raw dimensions from the target long edge and the aspect ratio.
// The long edge gets `target_long_edge`; the short edge is derived.
let (raw_w, raw_h) = if w_ratio >= h_ratio {
// Landscape or square: width is the long edge
let w = target_long_edge;
let h = (w as u64 * h_ratio as u64 / w_ratio as u64) as u32;
(w, h)
} else {
// Portrait: height is the long edge
let h = target_long_edge;
let w = (h as u64 * w_ratio as u64 / h_ratio as u64) as u32;
(w, h)
};
// Round both dimensions down to nearest multiple of 16
let mut w = (raw_w / 16) * 16;
let mut h = (raw_h / 16) * 16;
// Clamp each edge to MAX_EDGE
w = w.min(MAX_EDGE);
h = h.min(MAX_EDGE);
// Ensure minimum pixels by scaling up if needed
let pixels = w as u64 * h as u64;
if pixels < MIN_PIXELS {
// Scale up uniformly
let scale = ((MIN_PIXELS as f64) / (pixels as f64)).sqrt();
w = (((w as f64 * scale).ceil() as u32 + 15) / 16) * 16;
h = (((h as f64 * scale).ceil() as u32 + 15) / 16) * 16;
}
// Ensure maximum pixels by scaling down if needed
let pixels = w as u64 * h as u64;
if pixels > MAX_PIXELS {
let scale = ((MAX_PIXELS as f64) / (pixels as f64)).sqrt();
w = ((w as f64 * scale).floor() as u32 / 16) * 16;
h = ((h as f64 * scale).floor() as u32 / 16) * 16;
}
// Final clamp
w = w.min(MAX_EDGE);
h = h.min(MAX_EDGE);
CustomImageSize { width: w, height: h }
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_ASPECTS: &[GptImage2AspectRatio] = &[
GptImage2AspectRatio::Square,
GptImage2AspectRatio::SquareHd,
GptImage2AspectRatio::Landscape4x3,
GptImage2AspectRatio::Landscape16x9,
GptImage2AspectRatio::Portrait4x3,
GptImage2AspectRatio::Portrait16x9,
];
const ALL_RESOLUTIONS: &[GptImage2Resolution] = &[
GptImage2Resolution::OneK,
GptImage2Resolution::TwoK,
GptImage2Resolution::ThreeK,
GptImage2Resolution::FourK,
];
#[test]
fn all_combinations_satisfy_constraints() {
for &aspect in ALL_ASPECTS {
for &resolution in ALL_RESOLUTIONS {
let size = compute_custom_image_size(aspect, resolution);
let pixels = size.width as u64 * size.height as u64;
let max_edge = size.width.max(size.height);
let min_edge = size.width.min(size.height);
assert!(size.width % 16 == 0, "width {} not multiple of 16 for {:?} {:?}", size.width, aspect, resolution);
assert!(size.height % 16 == 0, "height {} not multiple of 16 for {:?} {:?}", size.height, aspect, resolution);
assert!(max_edge <= MAX_EDGE, "max edge {} exceeds 3840 for {:?} {:?}", max_edge, aspect, resolution);
assert!(max_edge as f64 / min_edge as f64 <= 3.0, "ratio {:.2} exceeds 3:1 for {:?} {:?} ({}x{})", max_edge as f64 / min_edge as f64, aspect, resolution, size.width, size.height);
assert!(pixels >= MIN_PIXELS, "pixels {} < 655360 for {:?} {:?} ({}x{})", pixels, aspect, resolution, size.width, size.height);
assert!(pixels <= MAX_PIXELS, "pixels {} > 8294400 for {:?} {:?} ({}x{})", pixels, aspect, resolution, size.width, size.height);
}
}
}
#[test]
fn all_combinations_dimensions_snapshot() {
// Print all dimensions for manual review / documentation
for &aspect in ALL_ASPECTS {
for &resolution in ALL_RESOLUTIONS {
let size = compute_custom_image_size(aspect, resolution);
let pixels = size.width as u64 * size.height as u64;
println!("{:?} + {:?} => {}x{} ({} pixels)", aspect, resolution, size.width, size.height, pixels);
}
}
}
mod specific_dimensions {
use super::*;
#[test]
fn square_1k() {
let s = compute_custom_image_size(GptImage2AspectRatio::Square, GptImage2Resolution::OneK);
assert_eq!(s.width, 1024);
assert_eq!(s.height, 1024);
}
#[test]
fn square_4k() {
let s = compute_custom_image_size(GptImage2AspectRatio::Square, GptImage2Resolution::FourK);
// sqrt(8294400) ~= 2880, but 3840 * 3840 = 14745600 > MAX, so it gets clamped
assert!(s.width <= MAX_EDGE);
assert!(s.height <= MAX_EDGE);
assert!(s.width as u64 * s.height as u64 <= MAX_PIXELS);
}
#[test]
fn landscape_16x9_1k() {
// 1024x576 = 589,824 < MIN_PIXELS, so gets scaled up to 1088x608
let s = compute_custom_image_size(GptImage2AspectRatio::Landscape16x9, GptImage2Resolution::OneK);
assert_eq!(s.width, 1088);
assert_eq!(s.height, 608);
}
#[test]
fn portrait_16x9_4k() {
let s = compute_custom_image_size(GptImage2AspectRatio::Portrait16x9, GptImage2Resolution::FourK);
assert!(s.width <= MAX_EDGE);
assert!(s.height <= MAX_EDGE);
assert!(s.width as u64 * s.height as u64 <= MAX_PIXELS);
}
}
}
@@ -0,0 +1 @@
pub mod gpt_image_2_resolution;
@@ -1,6 +1,9 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::image::common::gpt_image_2_resolution::{
compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution,
};
use crate::requests::api::image::edit::gpt_image_2_edit_image::raw_request::{
GptImage2EditImageInput, GptImage2EditImageOutput,
GptImage2EditImageInput, GptImage2EditImageOutput, ImageSizeParam,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
@@ -18,9 +21,14 @@ pub struct GptImage2EditImageRequest {
/// Optional mask URL indicating what part of the image to edit.
pub mask_url: Option<String>,
/// Output image size.
/// Output image size (aspect ratio preset).
pub image_size: Option<GptImage2EditImageSize>,
/// Optional resolution tier. When present, a custom width x height is
/// computed from the aspect ratio and this resolution, overriding the
/// standard preset dimensions. Not applicable when image_size is Auto.
pub resolution: Option<GptImage2Resolution>,
/// Quality level.
pub quality: Option<GptImage2EditImageQuality>,
@@ -61,6 +69,32 @@ pub enum GptImage2EditImageOutputFormat {
Webp,
}
/// Map image_size to GptImage2AspectRatio. Returns None for Auto (no
/// meaningful aspect ratio to compute custom dimensions from).
fn size_to_aspect(size: GptImage2EditImageSize) -> Option<GptImage2AspectRatio> {
match size {
GptImage2EditImageSize::Square => Some(GptImage2AspectRatio::Square),
GptImage2EditImageSize::SquareHd => Some(GptImage2AspectRatio::SquareHd),
GptImage2EditImageSize::Landscape4x3 => Some(GptImage2AspectRatio::Landscape4x3),
GptImage2EditImageSize::Landscape16x9 => Some(GptImage2AspectRatio::Landscape16x9),
GptImage2EditImageSize::Portrait4x3 => Some(GptImage2AspectRatio::Portrait4x3),
GptImage2EditImageSize::Portrait16x9 => Some(GptImage2AspectRatio::Portrait16x9),
GptImage2EditImageSize::Auto => None,
}
}
fn size_to_preset_str(size: GptImage2EditImageSize) -> &'static str {
match size {
GptImage2EditImageSize::SquareHd => "square_hd",
GptImage2EditImageSize::Square => "square",
GptImage2EditImageSize::Portrait4x3 => "portrait_4_3",
GptImage2EditImageSize::Portrait16x9 => "portrait_16_9",
GptImage2EditImageSize::Landscape4x3 => "landscape_4_3",
GptImage2EditImageSize::Landscape16x9 => "landscape_16_9",
GptImage2EditImageSize::Auto => "auto",
}
}
impl FalEndpoint for GptImage2EditImageRequest {
const ENDPOINT: &str = "openai/gpt-image-2/edit";
@@ -75,15 +109,30 @@ impl FalEndpoint for GptImage2EditImageRequest {
GptImage2EditImageNumImages::Four => 4,
};
let image_size = self.image_size.map(|s| match s {
GptImage2EditImageSize::SquareHd => "square_hd",
GptImage2EditImageSize::Square => "square",
GptImage2EditImageSize::Portrait4x3 => "portrait_4_3",
GptImage2EditImageSize::Portrait16x9 => "portrait_16_9",
GptImage2EditImageSize::Landscape4x3 => "landscape_4_3",
GptImage2EditImageSize::Landscape16x9 => "landscape_16_9",
GptImage2EditImageSize::Auto => "auto",
}.to_string());
let image_size = match (self.image_size, self.resolution) {
// Resolution present with a non-Auto size: compute custom dimensions
(Some(size), Some(resolution)) => {
match size_to_aspect(size) {
Some(aspect) => {
let custom = compute_custom_image_size(aspect, resolution);
Some(ImageSizeParam::Custom(custom))
}
// Auto + resolution: can't compute dimensions, fall back to preset
None => Some(ImageSizeParam::Preset(size_to_preset_str(size).to_string())),
}
}
// Resolution present but no image_size: default to Square
(None, Some(resolution)) => {
let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution);
Some(ImageSizeParam::Custom(custom))
}
// No resolution: use the standard preset string
(Some(size), None) => {
Some(ImageSizeParam::Preset(size_to_preset_str(size).to_string()))
}
// Neither: let the API default
(None, None) => None,
};
let quality = self.quality.map(|q| match q {
GptImage2EditImageQuality::Low => "low",
@@ -114,6 +163,7 @@ impl FalEndpoint for GptImage2EditImageRequest {
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
@@ -135,6 +185,7 @@ mod tests {
num_images: GptImage2EditImageNumImages::Two,
mask_url: None,
image_size: None,
resolution: None,
quality: None,
output_format: None,
};
@@ -157,6 +208,7 @@ mod tests {
num_images: GptImage2EditImageNumImages::One,
mask_url: None,
image_size: Some(GptImage2EditImageSize::Square),
resolution: None,
quality: Some(GptImage2EditImageQuality::High),
output_format: Some(GptImage2EditImageOutputFormat::Png),
};
@@ -170,5 +222,150 @@ mod tests {
Ok(())
}
/// Combinatorial test: every (image_size, resolution) pair for edit-image.
/// Uses a test_data source image and prompts to include the test parameters
/// as text in the output.
#[tokio::test]
#[ignore] // manually test — requires real API key, incurs costs
async fn test_combinatorial_size_and_resolution() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let sizes = [
("square", GptImage2EditImageSize::Square),
("square_hd", GptImage2EditImageSize::SquareHd),
("landscape_4x3", GptImage2EditImageSize::Landscape4x3),
("landscape_16x9", GptImage2EditImageSize::Landscape16x9),
("portrait_4x3", GptImage2EditImageSize::Portrait4x3),
("portrait_16x9", GptImage2EditImageSize::Portrait16x9),
];
let resolutions = [
("1K", GptImage2Resolution::OneK),
("2K", GptImage2Resolution::TwoK),
("3K", GptImage2Resolution::ThreeK),
("4K", GptImage2Resolution::FourK),
];
let surfaces = ["t-shirt", "sign", "monitor", "billboard", "coffee mug", "poster"];
let mut surface_idx = 0;
for (size_name, size) in &sizes {
for (res_name, resolution) in &resolutions {
let surface = surfaces[surface_idx % surfaces.len()];
surface_idx += 1;
let prompt = format!(
"Edit this image: add a {} with the text \"{} {}\" prominently displayed, studio lighting",
surface, size_name, res_name,
);
let request = GptImage2EditImageRequest {
prompt,
image_urls: vec![GHOST_IMAGE_URL.to_string()],
num_images: GptImage2EditImageNumImages::One,
mask_url: None,
image_size: Some(*size),
resolution: Some(*resolution),
quality: Some(GptImage2EditImageQuality::Medium),
output_format: Some(GptImage2EditImageOutputFormat::Png),
};
let result = request.send_queue_request(&api_key).await?;
println!("[{} + {}] Request ID: {}", size_name, res_name, result.request_id);
assert!(!result.request_id.is_empty());
}
}
Ok(())
}
mod to_raw_request_tests {
use super::*;
use crate::requests::api::image::edit::gpt_image_2_edit_image::raw_request::ImageSizeParam;
fn make_request(
image_size: Option<GptImage2EditImageSize>,
resolution: Option<GptImage2Resolution>,
) -> GptImage2EditImageRequest {
GptImage2EditImageRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/image.png".to_string()],
num_images: GptImage2EditImageNumImages::One,
mask_url: None,
image_size,
resolution,
quality: None,
output_format: None,
}
}
#[test]
fn preset_without_resolution() {
let raw = make_request(Some(GptImage2EditImageSize::Square), None)
.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Preset(s) => assert_eq!(s, "square"),
ImageSizeParam::Custom(_) => panic!("expected preset"),
}
}
#[test]
fn auto_without_resolution() {
let raw = make_request(Some(GptImage2EditImageSize::Auto), None)
.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Preset(s) => assert_eq!(s, "auto"),
ImageSizeParam::Custom(_) => panic!("expected preset"),
}
}
#[test]
fn custom_with_resolution() {
let raw = make_request(
Some(GptImage2EditImageSize::Landscape16x9),
Some(GptImage2Resolution::TwoK),
).to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Custom(c) => {
assert_eq!(c.width, 2048);
assert_eq!(c.height, 1152);
}
ImageSizeParam::Preset(_) => panic!("expected custom"),
}
}
#[test]
fn auto_with_resolution_falls_back_to_preset() {
let raw = make_request(
Some(GptImage2EditImageSize::Auto),
Some(GptImage2Resolution::TwoK),
).to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Preset(s) => assert_eq!(s, "auto"),
ImageSizeParam::Custom(_) => panic!("expected preset for Auto + resolution"),
}
}
#[test]
fn resolution_without_image_size_defaults_to_square() {
let raw = make_request(None, Some(GptImage2Resolution::TwoK))
.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Custom(c) => {
assert_eq!(c.width, 2048);
assert_eq!(c.height, 2048);
}
ImageSizeParam::Preset(_) => panic!("expected custom"),
}
}
#[test]
fn neither_size_nor_resolution() {
let raw = make_request(None, None).to_raw_request().unwrap();
assert!(raw.image_size.is_none());
}
}
// NB: Pricing tests are in cost.rs
}
@@ -1,46 +1,104 @@
use crate::requests::api::image::edit::gpt_image_2_edit_image::api::{
GptImage2EditImageNumImages, GptImage2EditImageQuality,
GptImage2EditImageRequest, GptImage2EditImageSize,
};
use crate::requests::api::image::common::gpt_image_2_resolution::{compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution};
use crate::requests::api::image::edit::gpt_image_2_edit_image::api::{GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest, GptImage2EditImageSize};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
const PRICING_TABLE: &[GptImage2PriceRow] = &[GptImage2PriceRow { width: 1024, height: 768, low: 11, medium: 43, high: 151 }, GptImage2PriceRow { width: 1024, height: 1024, low: 15, medium: 61, high: 219 }, GptImage2PriceRow { width: 1024, height: 1536, low: 18, medium: 54, high: 178 }, GptImage2PriceRow { width: 1920, height: 1080, low: 17, medium: 53, high: 158 }, GptImage2PriceRow { width: 2560, height: 1440, low: 19, medium: 68, high: 234 }, GptImage2PriceRow { width: 3840, height: 2160, low: 24, medium: 113, high: 413 }];
#[derive(Copy, Clone)]
struct GptImage2PriceRow {
width: u32,
height: u32,
low: u64,
medium: u64,
high: u64,
}
impl GptImage2PriceRow {
fn price_in_tenths_of_a_cent(self, quality: GptImage2EditImageQuality) -> u64 {
match quality {
GptImage2EditImageQuality::Low => self.low,
GptImage2EditImageQuality::Medium => self.medium,
GptImage2EditImageQuality::High => self.high,
}
}
}
impl FalRequestCostCalculator for GptImage2EditImageRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Cost table (per image) by approximate pixel dimensions:
//
// landscape_4_3 (~1024x768): low=$0.01, medium=$0.04, high=$0.15
// square (~1024x1024): low=$0.01, medium=$0.06, high=$0.22
// portrait_4_3 (~768x1024): low=$0.01, medium=$0.04, high=$0.15
// landscape_16_9(~1920x1080): low=$0.01, medium=$0.04, high=$0.16
// portrait_16_9 (~1080x1920): low=$0.01, medium=$0.04, high=$0.16
// square_hd (~2048x2048): low=$0.01, medium=$0.06, high=$0.23
// auto (varies): estimated as square_hd (worst case)
let use_quality = self.quality.unwrap_or(GptImage2EditImageQuality::High);
let use_size = self.image_size.unwrap_or(GptImage2EditImageSize::Square);
use GptImage2EditImageQuality::*;
use GptImage2EditImageSize::*;
let base_cost = match (use_quality, use_size) {
(Low, _) => 1,
(Medium, Landscape4x3 | Portrait4x3 | Landscape16x9 | Portrait16x9) => 4,
(Medium, Square) => 6,
(Medium, SquareHd) => 6,
(Medium, Auto) => 6,
(High, Landscape4x3 | Portrait4x3) => 15,
(High, Landscape16x9 | Portrait16x9) => 16,
(High, Square) => 22,
(High, SquareHd) => 23,
(High, Auto) => 23,
let quality = self.quality.unwrap_or(GptImage2EditImageQuality::High);
let price = match dimensions_for_request(self) {
Some((width, height)) => closest_price_row(width, height).price_in_tenths_of_a_cent(quality),
None => max_price_for_quality(quality),
};
let total_tenths_of_a_cent = price * num_images(self.num_images);
let cost = match self.num_images {
GptImage2EditImageNumImages::One => base_cost,
GptImage2EditImageNumImages::Two => base_cost * 2,
GptImage2EditImageNumImages::Three => base_cost * 3,
GptImage2EditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
total_tenths_of_a_cent.div_ceil(10) as UsdCents
}
}
fn dimensions_for_request(request: &GptImage2EditImageRequest) -> Option<(u32, u32)> {
match (request.image_size, request.resolution) {
(Some(GptImage2EditImageSize::Auto), _) => None,
(Some(size), Some(resolution)) => {
let aspect = size_to_aspect(size);
let custom = compute_custom_image_size(aspect, resolution);
Some((custom.width, custom.height))
},
(None, Some(resolution)) => {
let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution);
Some((custom.width, custom.height))
},
(Some(size), None) => Some(preset_dimensions(size)),
(None, None) => Some(preset_dimensions(GptImage2EditImageSize::Square)),
}
}
fn preset_dimensions(size: GptImage2EditImageSize) -> (u32, u32) {
match size {
GptImage2EditImageSize::SquareHd => (2048, 2048),
GptImage2EditImageSize::Square => (1024, 1024),
GptImage2EditImageSize::Portrait4x3 => (768, 1024),
GptImage2EditImageSize::Portrait16x9 => (1080, 1920),
GptImage2EditImageSize::Landscape4x3 => (1024, 768),
GptImage2EditImageSize::Landscape16x9 => (1920, 1080),
GptImage2EditImageSize::Auto => (3840, 2160),
}
}
fn size_to_aspect(size: GptImage2EditImageSize) -> GptImage2AspectRatio {
match size {
GptImage2EditImageSize::Square => GptImage2AspectRatio::Square,
GptImage2EditImageSize::SquareHd => GptImage2AspectRatio::SquareHd,
GptImage2EditImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3,
GptImage2EditImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9,
GptImage2EditImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3,
GptImage2EditImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9,
GptImage2EditImageSize::Auto => GptImage2AspectRatio::Square,
}
}
fn closest_price_row(width: u32, height: u32) -> GptImage2PriceRow {
let pixels = width as u64 * height as u64;
PRICING_TABLE
.iter()
.copied()
.min_by_key(|row| {
let row_pixels = row.width as u64 * row.height as u64;
row_pixels.abs_diff(pixels)
})
.expect("GPT Image 2 pricing table cannot be empty")
}
fn max_price_for_quality(quality: GptImage2EditImageQuality) -> u64 {
PRICING_TABLE.iter().copied().map(|row| row.price_in_tenths_of_a_cent(quality)).max().expect("GPT Image 2 pricing table cannot be empty")
}
fn num_images(num_images: GptImage2EditImageNumImages) -> u64 {
match num_images {
GptImage2EditImageNumImages::One => 1,
GptImage2EditImageNumImages::Two => 2,
GptImage2EditImageNumImages::Three => 3,
GptImage2EditImageNumImages::Four => 4,
}
}
@@ -48,162 +106,113 @@ impl FalRequestCostCalculator for GptImage2EditImageRequest {
mod tests {
use super::*;
fn make_request(
num_images: GptImage2EditImageNumImages,
quality: Option<GptImage2EditImageQuality>,
image_size: Option<GptImage2EditImageSize>,
) -> GptImage2EditImageRequest {
GptImage2EditImageRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/image.png".to_string()],
num_images,
mask_url: None,
image_size,
quality,
output_format: None,
const QUALITIES: &[(GptImage2EditImageQuality, u64, u64, u64, u64, u64, u64)] = &[(GptImage2EditImageQuality::Low, 11, 15, 18, 17, 19, 24), (GptImage2EditImageQuality::Medium, 43, 61, 54, 53, 68, 113), (GptImage2EditImageQuality::High, 151, 219, 178, 158, 234, 413)];
const PRESET_CASES: &[(GptImage2EditImageSize, u64)] = &[(GptImage2EditImageSize::Landscape4x3, 0), (GptImage2EditImageSize::Portrait4x3, 0), (GptImage2EditImageSize::Square, 1), (GptImage2EditImageSize::Landscape16x9, 3), (GptImage2EditImageSize::Portrait16x9, 3), (GptImage2EditImageSize::SquareHd, 4)];
const RESOLUTION_CASES: &[(GptImage2EditImageSize, GptImage2Resolution, u64)] = &[
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::OneK, 0),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::TwoK, 4),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::ThreeK, 5),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::FourK, 5),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::OneK, 0),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::TwoK, 3),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::ThreeK, 4),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::FourK, 5),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::OneK, 0),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::TwoK, 4),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::ThreeK, 5),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::FourK, 5),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::OneK, 0),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::TwoK, 3),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::ThreeK, 4),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::FourK, 5),
(GptImage2EditImageSize::Square, GptImage2Resolution::OneK, 1),
(GptImage2EditImageSize::Square, GptImage2Resolution::TwoK, 4),
(GptImage2EditImageSize::Square, GptImage2Resolution::ThreeK, 5),
(GptImage2EditImageSize::Square, GptImage2Resolution::FourK, 5),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::OneK, 1),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::TwoK, 4),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::ThreeK, 5),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::FourK, 5),
];
const SPOT_PRICE_CASES: &[(GptImage2EditImageSize, GptImage2Resolution, GptImage2EditImageNumImages, u64)] = &[
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::OneK, GptImage2EditImageNumImages::One, 16),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::TwoK, GptImage2EditImageNumImages::Two, 47),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::ThreeK, GptImage2EditImageNumImages::Three, 124),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::TwoK, GptImage2EditImageNumImages::Four, 64),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::FourK, GptImage2EditImageNumImages::Two, 83),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::TwoK, GptImage2EditImageNumImages::One, 24),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::ThreeK, GptImage2EditImageNumImages::Three, 71),
(GptImage2EditImageSize::Square, GptImage2Resolution::OneK, GptImage2EditImageNumImages::Four, 88),
(GptImage2EditImageSize::Square, GptImage2Resolution::FourK, GptImage2EditImageNumImages::Four, 166),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::TwoK, GptImage2EditImageNumImages::Three, 71),
];
fn make_request(num_images: GptImage2EditImageNumImages, quality: Option<GptImage2EditImageQuality>, image_size: Option<GptImage2EditImageSize>, resolution: Option<GptImage2Resolution>) -> GptImage2EditImageRequest {
GptImage2EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_url: None, image_size, quality, resolution, output_format: None }
}
#[test]
fn cost_defaults_to_high_square() {
assert_eq!(make_request(GptImage2EditImageNumImages::One, None, None, None).calculate_cost_in_cents(), 22);
}
#[test]
fn cost_rounds_final_request_total_up_to_whole_cents() {
assert_eq!(make_request(GptImage2EditImageNumImages::Four, Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), None,).calculate_cost_in_cents(), 5,);
}
#[test]
fn preset_pricing_matches_published_table() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let expected_by_row = [landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k];
for &(size, row_index) in PRESET_CASES {
let expected = expected_by_row[row_index as usize].div_ceil(10);
assert_eq!(make_request(GptImage2EditImageNumImages::One, Some(quality), Some(size), None).calculate_cost_in_cents(), expected, "quality={quality:?} size={size:?}",);
}
}
}
mod default_tests {
use super::*;
#[test]
fn exact_published_rows_match_all_quality_prices() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let cases = [(1024, 768, landscape_4x3), (1024, 1024, square), (1024, 1536, portrait), (1920, 1080, landscape_16x9), (2560, 1440, square_hd), (3840, 2160, four_k)];
#[test]
fn cost_defaults_one_image() {
// Default quality=High, size=Square => 22 cents
assert_eq!(
make_request(GptImage2EditImageNumImages::One, None, None)
.calculate_cost_in_cents(), 22);
}
#[test]
fn cost_defaults_four_images() {
assert_eq!(
make_request(GptImage2EditImageNumImages::Four, None, None)
.calculate_cost_in_cents(), 88);
for (width, height, expected) in cases {
assert_eq!(closest_price_row(width, height).price_in_tenths_of_a_cent(quality), expected, "quality={quality:?} dimensions={width}x{height}",);
}
}
}
mod low_quality_tests {
use super::*;
#[test]
fn resolution_pricing_covers_aspect_size_resolution_combinations() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let expected_by_row = [landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k];
#[test]
fn cost_low_any_size_one_image() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd))
.calculate_cost_in_cents(), 1);
}
#[test]
fn cost_low_any_size_four_images() {
assert_eq!(
make_request(GptImage2EditImageNumImages::Four, Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto))
.calculate_cost_in_cents(), 4);
for &(size, resolution, row_index) in RESOLUTION_CASES {
let expected = expected_by_row[row_index as usize].div_ceil(10);
assert_eq!(make_request(GptImage2EditImageNumImages::One, Some(quality), Some(size), Some(resolution)).calculate_cost_in_cents(), expected, "quality={quality:?} size={size:?} resolution={resolution:?}",);
}
}
}
mod medium_quality_tests {
use super::*;
#[test]
fn cost_medium_landscape_4x3() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3))
.calculate_cost_in_cents(), 4);
}
#[test]
fn cost_medium_portrait_16x9() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9))
.calculate_cost_in_cents(), 4);
}
#[test]
fn cost_medium_square() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square))
.calculate_cost_in_cents(), 6);
}
#[test]
fn cost_medium_square_hd() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd))
.calculate_cost_in_cents(), 6);
}
#[test]
fn cost_medium_auto() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto))
.calculate_cost_in_cents(), 6);
}
#[test]
fn cost_medium_landscape_4x3_three_images() {
assert_eq!(
make_request(GptImage2EditImageNumImages::Three, Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3))
.calculate_cost_in_cents(), 12);
#[test]
fn high_quality_spot_prices_match_expected_cents() {
for &(size, resolution, num_images, expected) in SPOT_PRICE_CASES {
assert_eq!(make_request(num_images, Some(GptImage2EditImageQuality::High), Some(size), Some(resolution)).calculate_cost_in_cents(), expected, "size={size:?} resolution={resolution:?} num_images={num_images:?}",);
}
}
mod high_quality_tests {
use super::*;
#[test]
fn auto_uses_highest_published_price_for_quality() {
assert_eq!(make_request(GptImage2EditImageNumImages::Two, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK),).calculate_cost_in_cents(), 83,);
}
#[test]
fn cost_high_landscape_4x3() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3))
.calculate_cost_in_cents(), 15);
}
#[test]
fn cost_high_portrait_4x3() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3))
.calculate_cost_in_cents(), 15);
}
#[test]
fn cost_high_landscape_16x9() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9))
.calculate_cost_in_cents(), 16);
}
#[test]
fn cost_high_portrait_16x9() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9))
.calculate_cost_in_cents(), 16);
}
#[test]
fn cost_high_square() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square))
.calculate_cost_in_cents(), 22);
}
#[test]
fn cost_high_square_hd() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd))
.calculate_cost_in_cents(), 23);
}
#[test]
fn cost_high_auto() {
assert_eq!(
make_request(GptImage2EditImageNumImages::One, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto))
.calculate_cost_in_cents(), 23);
}
#[test]
fn cost_high_square_hd_two_images() {
assert_eq!(
make_request(GptImage2EditImageNumImages::Two, Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd))
.calculate_cost_in_cents(), 46);
}
#[test]
fn resolution_without_image_size_defaults_to_square_aspect() {
assert_eq!(make_request(GptImage2EditImageNumImages::Two, Some(GptImage2EditImageQuality::High), None, Some(GptImage2Resolution::FourK),).calculate_cost_in_cents(), 83,);
}
}
@@ -1,6 +1,16 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize;
#[derive(Debug, Serialize, Deserialize, Default)]
/// The `image_size` field can be either a string enum value (e.g. "square")
/// or a custom object with explicit width/height.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum ImageSizeParam {
Preset(String),
Custom(CustomImageSize),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage2EditImageInput {
pub prompt: String,
@@ -12,8 +22,9 @@ pub struct GptImage2EditImageInput {
pub mask_url: Option<String>,
/// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto
/// OR a custom { width, height } object.
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
pub image_size: Option<ImageSizeParam>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1,4 +1,5 @@
pub mod angle;
pub mod background;
pub mod common;
pub mod edit;
pub mod text;
@@ -1,6 +1,9 @@
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::api::image::common::gpt_image_2_resolution::{
compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution,
};
use crate::requests::api::image::text::gpt_image_2_text_to_image::raw_request::{
GptImage2TextToImageInput, GptImage2TextToImageOutput,
GptImage2TextToImageInput, GptImage2TextToImageOutput, ImageSizeParam,
};
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
@@ -12,9 +15,14 @@ pub struct GptImage2TextToImageRequest {
/// Number of images to generate.
pub num_images: GptImage2TextToImageNumImages,
/// Output image size.
/// Output image size (aspect ratio preset).
pub image_size: Option<GptImage2TextToImageSize>,
/// Optional resolution tier. When present, a custom width x height is
/// computed from the aspect ratio and this resolution, overriding the
/// standard preset dimensions.
pub resolution: Option<GptImage2Resolution>,
/// Quality level.
pub quality: Option<GptImage2TextToImageQuality>,
@@ -54,6 +62,19 @@ pub enum GptImage2TextToImageOutputFormat {
Webp,
}
/// Map our image_size enum to the GptImage2AspectRatio used by the resolution
/// calculator.
fn size_to_aspect(size: GptImage2TextToImageSize) -> GptImage2AspectRatio {
match size {
GptImage2TextToImageSize::Square => GptImage2AspectRatio::Square,
GptImage2TextToImageSize::SquareHd => GptImage2AspectRatio::SquareHd,
GptImage2TextToImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3,
GptImage2TextToImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9,
GptImage2TextToImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3,
GptImage2TextToImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9,
}
}
impl FalEndpoint for GptImage2TextToImageRequest {
const ENDPOINT: &str = "openai/gpt-image-2";
@@ -68,14 +89,33 @@ impl FalEndpoint for GptImage2TextToImageRequest {
GptImage2TextToImageNumImages::Four => 4,
};
let image_size = self.image_size.map(|s| match s {
GptImage2TextToImageSize::SquareHd => "square_hd",
GptImage2TextToImageSize::Square => "square",
GptImage2TextToImageSize::Portrait4x3 => "portrait_4_3",
GptImage2TextToImageSize::Portrait16x9 => "portrait_16_9",
GptImage2TextToImageSize::Landscape4x3 => "landscape_4_3",
GptImage2TextToImageSize::Landscape16x9 => "landscape_16_9",
}.to_string());
let image_size = match (self.image_size, self.resolution) {
// Resolution present: compute custom dimensions from aspect + resolution
(Some(size), Some(resolution)) => {
let aspect = size_to_aspect(size);
let custom = compute_custom_image_size(aspect, resolution);
Some(ImageSizeParam::Custom(custom))
}
// Resolution present but no image_size: default to Square
(None, Some(resolution)) => {
let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution);
Some(ImageSizeParam::Custom(custom))
}
// No resolution: use the standard preset string
(Some(size), None) => {
let preset = match size {
GptImage2TextToImageSize::SquareHd => "square_hd",
GptImage2TextToImageSize::Square => "square",
GptImage2TextToImageSize::Portrait4x3 => "portrait_4_3",
GptImage2TextToImageSize::Portrait16x9 => "portrait_16_9",
GptImage2TextToImageSize::Landscape4x3 => "landscape_4_3",
GptImage2TextToImageSize::Landscape16x9 => "landscape_16_9",
};
Some(ImageSizeParam::Preset(preset.to_string()))
}
// Neither: let the API default
(None, None) => None,
};
let quality = self.quality.map(|q| match q {
GptImage2TextToImageQuality::Low => "low",
@@ -104,6 +144,7 @@ impl FalEndpoint for GptImage2TextToImageRequest {
mod tests {
use super::*;
use crate::creds::fal_api_key::FalApiKey;
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
use errors::AnyhowResult;
use std::fs::read_to_string;
@@ -118,6 +159,7 @@ mod tests {
prompt: "an anime girl riding on the back of a t-rex".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: None,
resolution: None,
quality: None,
output_format: None,
};
@@ -138,6 +180,7 @@ mod tests {
prompt: "a corgi wearing sunglasses at the beach".to_string(),
num_images: GptImage2TextToImageNumImages::Two,
image_size: Some(GptImage2TextToImageSize::Landscape16x9),
resolution: None,
quality: Some(GptImage2TextToImageQuality::High),
output_format: Some(GptImage2TextToImageOutputFormat::Png),
};
@@ -151,5 +194,137 @@ mod tests {
Ok(())
}
/// Combinatorial test: every (image_size, resolution) pair.
/// The prompt embeds the test parameters so the output image documents
/// which combination was used.
#[tokio::test]
#[ignore] // manually test — requires real API key, incurs costs
async fn test_combinatorial_size_and_resolution() -> AnyhowResult<()> {
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let sizes = [
("square", GptImage2TextToImageSize::Square),
("square_hd", GptImage2TextToImageSize::SquareHd),
("landscape_4x3", GptImage2TextToImageSize::Landscape4x3),
("landscape_16x9", GptImage2TextToImageSize::Landscape16x9),
("portrait_4x3", GptImage2TextToImageSize::Portrait4x3),
("portrait_16x9", GptImage2TextToImageSize::Portrait16x9),
];
let resolutions = [
("1K", GptImage2Resolution::OneK),
("2K", GptImage2Resolution::TwoK),
("3K", GptImage2Resolution::ThreeK),
("4K", GptImage2Resolution::FourK),
];
let surfaces = ["t-shirt", "sign", "monitor", "billboard", "coffee mug", "poster"];
let mut surface_idx = 0;
for (size_name, size) in &sizes {
for (res_name, resolution) in &resolutions {
let surface = surfaces[surface_idx % surfaces.len()];
surface_idx += 1;
let prompt = format!(
"A photorealistic {} with the text \"{} {}\" printed on it, studio lighting, white background",
surface, size_name, res_name,
);
let request = GptImage2TextToImageRequest {
prompt,
num_images: GptImage2TextToImageNumImages::One,
image_size: Some(*size),
resolution: Some(*resolution),
quality: Some(GptImage2TextToImageQuality::Medium),
output_format: Some(GptImage2TextToImageOutputFormat::Png),
};
let result = request.send_queue_request(&api_key).await?;
println!("[{} + {}] Request ID: {}", size_name, res_name, result.request_id);
assert!(!result.request_id.is_empty());
}
}
Ok(())
}
mod to_raw_request_tests {
use super::*;
use crate::requests::api::image::text::gpt_image_2_text_to_image::raw_request::ImageSizeParam;
#[test]
fn preset_without_resolution() {
let request = GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: Some(GptImage2TextToImageSize::Square),
resolution: None,
quality: None,
output_format: None,
};
let raw = request.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Preset(s) => assert_eq!(s, "square"),
ImageSizeParam::Custom(_) => panic!("expected preset"),
}
}
#[test]
fn custom_with_resolution() {
let request = GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: Some(GptImage2TextToImageSize::Landscape16x9),
resolution: Some(GptImage2Resolution::TwoK),
quality: None,
output_format: None,
};
let raw = request.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Custom(c) => {
assert_eq!(c.width, 2048);
assert_eq!(c.height, 1152);
}
ImageSizeParam::Preset(_) => panic!("expected custom"),
}
}
#[test]
fn resolution_without_image_size_defaults_to_square() {
let request = GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: None,
resolution: Some(GptImage2Resolution::TwoK),
quality: None,
output_format: None,
};
let raw = request.to_raw_request().unwrap();
match raw.image_size.unwrap() {
ImageSizeParam::Custom(c) => {
assert_eq!(c.width, 2048);
assert_eq!(c.height, 2048);
}
ImageSizeParam::Preset(_) => panic!("expected custom"),
}
}
#[test]
fn neither_size_nor_resolution() {
let request = GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: None,
resolution: None,
quality: None,
output_format: None,
};
let raw = request.to_raw_request().unwrap();
assert!(raw.image_size.is_none());
}
}
// NB: Pricing tests are in cost.rs
}
@@ -1,44 +1,95 @@
use crate::requests::api::image::text::gpt_image_2_text_to_image::api::{
GptImage2TextToImageNumImages, GptImage2TextToImageQuality,
GptImage2TextToImageRequest, GptImage2TextToImageSize,
};
use crate::requests::api::image::common::gpt_image_2_resolution::{compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution};
use crate::requests::api::image::text::gpt_image_2_text_to_image::api::{GptImage2TextToImageNumImages, GptImage2TextToImageQuality, GptImage2TextToImageRequest, GptImage2TextToImageSize};
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
const PRICING_TABLE: &[GptImage2PriceRow] = &[GptImage2PriceRow { width: 1024, height: 768, low: 11, medium: 43, high: 151 }, GptImage2PriceRow { width: 1024, height: 1024, low: 15, medium: 61, high: 219 }, GptImage2PriceRow { width: 1024, height: 1536, low: 18, medium: 54, high: 178 }, GptImage2PriceRow { width: 1920, height: 1080, low: 17, medium: 53, high: 158 }, GptImage2PriceRow { width: 2560, height: 1440, low: 19, medium: 68, high: 234 }, GptImage2PriceRow { width: 3840, height: 2160, low: 24, medium: 113, high: 413 }];
#[derive(Copy, Clone)]
struct GptImage2PriceRow {
width: u32,
height: u32,
low: u64,
medium: u64,
high: u64,
}
impl GptImage2PriceRow {
fn price_in_tenths_of_a_cent(self, quality: GptImage2TextToImageQuality) -> u64 {
match quality {
GptImage2TextToImageQuality::Low => self.low,
GptImage2TextToImageQuality::Medium => self.medium,
GptImage2TextToImageQuality::High => self.high,
}
}
}
impl FalRequestCostCalculator for GptImage2TextToImageRequest {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Cost table (per image) by approximate pixel dimensions:
//
// landscape_4_3 (~1024x768): low=$0.01, medium=$0.04, high=$0.15
// square (~1024x1024): low=$0.01, medium=$0.06, high=$0.22
// portrait_4_3 (~768x1024): low=$0.01, medium=$0.04, high=$0.15
// landscape_16_9(~1920x1080): low=$0.01, medium=$0.04, high=$0.16
// portrait_16_9 (~1080x1920): low=$0.01, medium=$0.04, high=$0.16
// square_hd (~2048x2048): low=$0.01, medium=$0.06, high=$0.23
// auto (varies): estimated as square
let use_quality = self.quality.unwrap_or(GptImage2TextToImageQuality::High);
let use_size = self.image_size.unwrap_or(GptImage2TextToImageSize::Square);
let quality = self.quality.unwrap_or(GptImage2TextToImageQuality::High);
let (width, height) = dimensions_for_request(self);
let price = closest_price_row(width, height).price_in_tenths_of_a_cent(quality);
let total_tenths_of_a_cent = price * num_images(self.num_images);
use GptImage2TextToImageQuality::*;
use GptImage2TextToImageSize::*;
total_tenths_of_a_cent.div_ceil(10) as UsdCents
}
}
let base_cost = match (use_quality, use_size) {
(Low, _) => 1,
(Medium, Landscape4x3 | Portrait4x3 | Landscape16x9 | Portrait16x9) => 4,
(Medium, Square) => 6,
(Medium, SquareHd) => 6,
(High, Landscape4x3 | Portrait4x3) => 15,
(High, Landscape16x9 | Portrait16x9) => 16,
(High, Square) => 22,
(High, SquareHd) => 23,
};
fn dimensions_for_request(request: &GptImage2TextToImageRequest) -> (u32, u32) {
match (request.image_size, request.resolution) {
(Some(size), Some(resolution)) => {
let aspect = size_to_aspect(size);
let custom = compute_custom_image_size(aspect, resolution);
(custom.width, custom.height)
},
(None, Some(resolution)) => {
let custom = compute_custom_image_size(GptImage2AspectRatio::Square, resolution);
(custom.width, custom.height)
},
(Some(size), None) => preset_dimensions(size),
(None, None) => preset_dimensions(GptImage2TextToImageSize::Square),
}
}
let cost = match self.num_images {
GptImage2TextToImageNumImages::One => base_cost,
GptImage2TextToImageNumImages::Two => base_cost * 2,
GptImage2TextToImageNumImages::Three => base_cost * 3,
GptImage2TextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
fn preset_dimensions(size: GptImage2TextToImageSize) -> (u32, u32) {
match size {
GptImage2TextToImageSize::SquareHd => (2048, 2048),
GptImage2TextToImageSize::Square => (1024, 1024),
GptImage2TextToImageSize::Portrait4x3 => (768, 1024),
GptImage2TextToImageSize::Portrait16x9 => (1080, 1920),
GptImage2TextToImageSize::Landscape4x3 => (1024, 768),
GptImage2TextToImageSize::Landscape16x9 => (1920, 1080),
}
}
fn size_to_aspect(size: GptImage2TextToImageSize) -> GptImage2AspectRatio {
match size {
GptImage2TextToImageSize::Square => GptImage2AspectRatio::Square,
GptImage2TextToImageSize::SquareHd => GptImage2AspectRatio::SquareHd,
GptImage2TextToImageSize::Landscape4x3 => GptImage2AspectRatio::Landscape4x3,
GptImage2TextToImageSize::Landscape16x9 => GptImage2AspectRatio::Landscape16x9,
GptImage2TextToImageSize::Portrait4x3 => GptImage2AspectRatio::Portrait4x3,
GptImage2TextToImageSize::Portrait16x9 => GptImage2AspectRatio::Portrait16x9,
}
}
fn closest_price_row(width: u32, height: u32) -> GptImage2PriceRow {
let pixels = width as u64 * height as u64;
PRICING_TABLE
.iter()
.copied()
.min_by_key(|row| {
let row_pixels = row.width as u64 * row.height as u64;
row_pixels.abs_diff(pixels)
})
.expect("GPT Image 2 pricing table cannot be empty")
}
fn num_images(num_images: GptImage2TextToImageNumImages) -> u64 {
match num_images {
GptImage2TextToImageNumImages::One => 1,
GptImage2TextToImageNumImages::Two => 2,
GptImage2TextToImageNumImages::Three => 3,
GptImage2TextToImageNumImages::Four => 4,
}
}
@@ -46,140 +97,119 @@ impl FalRequestCostCalculator for GptImage2TextToImageRequest {
mod tests {
use super::*;
fn make_request(
num_images: GptImage2TextToImageNumImages,
quality: Option<GptImage2TextToImageQuality>,
image_size: Option<GptImage2TextToImageSize>,
) -> GptImage2TextToImageRequest {
GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images,
image_size,
quality,
output_format: None,
const QUALITIES: &[(GptImage2TextToImageQuality, u64, u64, u64, u64, u64, u64)] = &[
(GptImage2TextToImageQuality::Low, 11, 15, 18, 17, 19, 24),
(GptImage2TextToImageQuality::Medium, 43, 61, 54, 53, 68, 113),
(GptImage2TextToImageQuality::High, 151, 219, 178, 158, 234, 413)
];
const PRESET_CASES: &[(GptImage2TextToImageSize, u64)] = &[
(GptImage2TextToImageSize::Landscape4x3, 0),
(GptImage2TextToImageSize::Portrait4x3, 0),
(GptImage2TextToImageSize::Square, 1),
(GptImage2TextToImageSize::Landscape16x9, 3),
(GptImage2TextToImageSize::Portrait16x9, 3),
(GptImage2TextToImageSize::SquareHd, 4),
];
const RESOLUTION_CASES: &[(GptImage2TextToImageSize, GptImage2Resolution, u64)] = &[
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::OneK, 0),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::TwoK, 4),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::ThreeK, 5),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::FourK, 5),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::OneK, 0),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::TwoK, 3),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::ThreeK, 4),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::FourK, 5),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::OneK, 0),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::TwoK, 4),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::ThreeK, 5),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::FourK, 5),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::OneK, 0),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::TwoK, 3),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::ThreeK, 4),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::FourK, 5),
(GptImage2TextToImageSize::Square, GptImage2Resolution::OneK, 1),
(GptImage2TextToImageSize::Square, GptImage2Resolution::TwoK, 4),
(GptImage2TextToImageSize::Square, GptImage2Resolution::ThreeK, 5),
(GptImage2TextToImageSize::Square, GptImage2Resolution::FourK, 5),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::OneK, 1),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::TwoK, 4),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::ThreeK, 5),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::FourK, 5),
];
const SPOT_PRICE_CASES: &[(GptImage2TextToImageSize, GptImage2Resolution, GptImage2TextToImageNumImages, u64)] = &[
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::OneK, GptImage2TextToImageNumImages::One, 16),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::TwoK, GptImage2TextToImageNumImages::Two, 47),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::ThreeK, GptImage2TextToImageNumImages::Three, 124),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::TwoK, GptImage2TextToImageNumImages::Four, 64),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::FourK, GptImage2TextToImageNumImages::Two, 83),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::TwoK, GptImage2TextToImageNumImages::One, 24),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::ThreeK, GptImage2TextToImageNumImages::Three, 71),
(GptImage2TextToImageSize::Square, GptImage2Resolution::OneK, GptImage2TextToImageNumImages::Four, 88),
(GptImage2TextToImageSize::Square, GptImage2Resolution::FourK, GptImage2TextToImageNumImages::Four, 166),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::TwoK, GptImage2TextToImageNumImages::Three, 71),
];
fn make_request(num_images: GptImage2TextToImageNumImages, quality: Option<GptImage2TextToImageQuality>, image_size: Option<GptImage2TextToImageSize>, resolution: Option<GptImage2Resolution>) -> GptImage2TextToImageRequest {
GptImage2TextToImageRequest { prompt: "test".to_string(), num_images, image_size, quality, resolution, output_format: None }
}
#[test]
fn cost_defaults_to_high_square() {
assert_eq!(make_request(GptImage2TextToImageNumImages::One, None, None, None).calculate_cost_in_cents(), 22);
}
#[test]
fn cost_rounds_final_request_total_up_to_whole_cents() {
assert_eq!(make_request(GptImage2TextToImageNumImages::Four, Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), None,).calculate_cost_in_cents(), 5,);
}
#[test]
fn preset_pricing_matches_published_table() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let expected_by_row = [landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k];
for &(size, row_index) in PRESET_CASES {
let expected = expected_by_row[row_index as usize].div_ceil(10);
assert_eq!(make_request(GptImage2TextToImageNumImages::One, Some(quality), Some(size), None).calculate_cost_in_cents(), expected, "quality={quality:?} size={size:?}",);
}
}
}
mod default_tests {
use super::*;
#[test]
fn exact_published_rows_match_all_quality_prices() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let cases = [(1024, 768, landscape_4x3), (1024, 1024, square), (1024, 1536, portrait), (1920, 1080, landscape_16x9), (2560, 1440, square_hd), (3840, 2160, four_k)];
#[test]
fn cost_defaults_one_image() {
// Default quality=High, size=Square => 22 cents
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, None, None)
.calculate_cost_in_cents(), 22);
}
#[test]
fn cost_defaults_four_images() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::Four, None, None)
.calculate_cost_in_cents(), 88);
for (width, height, expected) in cases {
assert_eq!(closest_price_row(width, height).price_in_tenths_of_a_cent(quality), expected, "quality={quality:?} dimensions={width}x{height}",);
}
}
}
mod low_quality_tests {
use super::*;
#[test]
fn resolution_pricing_covers_aspect_size_resolution_combinations() {
for &(quality, landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k) in QUALITIES {
let expected_by_row = [landscape_4x3, square, portrait, landscape_16x9, square_hd, four_k];
#[test]
fn cost_low_any_size_one_image() {
// Low quality is always 1 cent per image regardless of size
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd))
.calculate_cost_in_cents(), 1);
}
#[test]
fn cost_low_any_size_four_images() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::Four, Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square))
.calculate_cost_in_cents(), 4);
for &(size, resolution, row_index) in RESOLUTION_CASES {
let expected = expected_by_row[row_index as usize].div_ceil(10);
assert_eq!(make_request(GptImage2TextToImageNumImages::One, Some(quality), Some(size), Some(resolution)).calculate_cost_in_cents(), expected, "quality={quality:?} size={size:?} resolution={resolution:?}",);
}
}
}
mod medium_quality_tests {
use super::*;
#[test]
fn cost_medium_landscape_4x3() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3))
.calculate_cost_in_cents(), 4);
}
#[test]
fn cost_medium_square() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square))
.calculate_cost_in_cents(), 6);
}
#[test]
fn cost_medium_square_hd() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd))
.calculate_cost_in_cents(), 6);
}
#[test]
fn cost_medium_portrait_16x9_two_images() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::Two, Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9))
.calculate_cost_in_cents(), 8);
#[test]
fn high_quality_spot_prices_match_expected_cents() {
for &(size, resolution, num_images, expected) in SPOT_PRICE_CASES {
assert_eq!(make_request(num_images, Some(GptImage2TextToImageQuality::High), Some(size), Some(resolution)).calculate_cost_in_cents(), expected, "size={size:?} resolution={resolution:?} num_images={num_images:?}",);
}
}
mod high_quality_tests {
use super::*;
#[test]
fn cost_high_landscape_4x3() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3))
.calculate_cost_in_cents(), 15);
}
#[test]
fn cost_high_portrait_4x3() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3))
.calculate_cost_in_cents(), 15);
}
#[test]
fn cost_high_landscape_16x9() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9))
.calculate_cost_in_cents(), 16);
}
#[test]
fn cost_high_portrait_16x9() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9))
.calculate_cost_in_cents(), 16);
}
#[test]
fn cost_high_square() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square))
.calculate_cost_in_cents(), 22);
}
#[test]
fn cost_high_square_hd() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::One, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd))
.calculate_cost_in_cents(), 23);
}
#[test]
fn cost_high_square_hd_three_images() {
assert_eq!(
make_request(GptImage2TextToImageNumImages::Three, Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd))
.calculate_cost_in_cents(), 69);
}
#[test]
fn resolution_without_image_size_defaults_to_square_aspect() {
assert_eq!(make_request(GptImage2TextToImageNumImages::Two, Some(GptImage2TextToImageQuality::High), None, Some(GptImage2Resolution::FourK),).calculate_cost_in_cents(), 83,);
}
}
@@ -1,12 +1,23 @@
use serde::{Deserialize, Serialize};
use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize;
#[derive(Debug, Serialize, Deserialize, Default)]
/// The `image_size` field can be either a string enum value (e.g. "square")
/// or a custom object with explicit width/height.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum ImageSizeParam {
Preset(String),
Custom(CustomImageSize),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage2TextToImageInput {
pub prompt: String,
/// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9
/// OR a custom { width, height } object.
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
pub image_size: Option<ImageSizeParam>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]