mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb278e980e | ||
|
|
3f09515c84 | ||
|
|
b100f33548 |
+206
@@ -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;
|
||||
+208
-11
@@ -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
|
||||
}
|
||||
|
||||
+188
-179
@@ -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,);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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;
|
||||
+185
-10
@@ -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
|
||||
}
|
||||
|
||||
+185
-155
@@ -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,);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user