mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 21:32:18 +00:00
gpt image 2.5
This commit is contained in:
1 parent
60be62cb71
commit
a3edb6b78f
29 files changed
+2305
No files matched your search
+29
@@ -0,0 +1,29 @@
|
||||
/// Background treatment for the generated image. Fal's default is `Auto`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GptImage2p5Background {
|
||||
Auto,
|
||||
Transparent,
|
||||
Opaque,
|
||||
}
|
||||
|
||||
impl GptImage2p5Background {
|
||||
pub fn to_str(self) -> &'static str {
|
||||
match self {
|
||||
GptImage2p5Background::Auto => "auto",
|
||||
GptImage2p5Background::Transparent => "transparent",
|
||||
GptImage2p5Background::Opaque => "opaque",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_strings_match_fal_schema() {
|
||||
assert_eq!(GptImage2p5Background::Auto.to_str(), "auto");
|
||||
assert_eq!(GptImage2p5Background::Transparent.to_str(), "transparent");
|
||||
assert_eq!(GptImage2p5Background::Opaque.to_str(), "opaque");
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_background::GptImage2p5Background;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_pricing::{
|
||||
estimate_output_image_cost, estimate_reference_images_cost, hundredths_of_cent_to_cents,
|
||||
};
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents;
|
||||
|
||||
/// Fal rejects more than this many reference images.
|
||||
pub const MAX_REFERENCE_IMAGES: usize = 16;
|
||||
|
||||
/// Parameters for a GPT Image 2.5 edit request. Identical for Flare and Sunburst; the
|
||||
/// per-endpoint request types wrap this.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5EditImageParams {
|
||||
/// Text prompt describing the edit. 1 to 32,000 characters.
|
||||
pub prompt: String,
|
||||
|
||||
/// One to sixteen reference image URLs.
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Optional mask URL indicating which part of the image to edit.
|
||||
pub mask_url: Option<String>,
|
||||
|
||||
/// Number of images to generate.
|
||||
pub num_images: GptImage2p5NumImages,
|
||||
|
||||
/// Output image size preset. `None` lets Fal default to `auto` (inferred from the input
|
||||
/// images).
|
||||
pub image_size: Option<GptImage2p5ImageSize>,
|
||||
|
||||
/// Optional resolution tier. When present (and the size is not `Auto`), a custom
|
||||
/// width x height is computed from the preset's aspect ratio and sent instead of the
|
||||
/// preset name.
|
||||
pub resolution: Option<GptImage2Resolution>,
|
||||
|
||||
/// Quality tier. `None` lets Fal default to `high`.
|
||||
pub quality: Option<GptImage2p5Quality>,
|
||||
|
||||
/// Background treatment. `None` lets Fal default to `auto`.
|
||||
pub background: Option<GptImage2p5Background>,
|
||||
|
||||
/// Output format. `None` sends `png`.
|
||||
pub output_format: Option<GptImage2p5OutputFormat>,
|
||||
|
||||
/// Compression level 0 to 100. Only sent for `jpeg` and `webp`.
|
||||
pub output_compression: Option<u8>,
|
||||
}
|
||||
|
||||
impl GptImage2p5EditImageParams {
|
||||
/// Fal's default when `image_size` is omitted.
|
||||
pub const DEFAULT_IMAGE_SIZE: GptImage2p5ImageSize = GptImage2p5ImageSize::Auto;
|
||||
|
||||
/// Fal's default when `quality` is omitted.
|
||||
pub const DEFAULT_QUALITY: GptImage2p5Quality = GptImage2p5Quality::High;
|
||||
|
||||
pub fn to_raw_input(&self) -> GptImage2p5EditImageInput {
|
||||
let output_format = self.output_format.unwrap_or(GptImage2p5OutputFormat::Png);
|
||||
let output_compression = if output_format.supports_compression() {
|
||||
self.output_compression.map(|c| c.min(100))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
GptImage2p5EditImageInput {
|
||||
prompt: self.prompt.clone(),
|
||||
image_urls: self.image_urls.clone(),
|
||||
mask_url: self.mask_url.clone(),
|
||||
image_size: self.image_size.map(|size| size.to_raw_param(self.resolution)),
|
||||
background: self.background.map(|b| b.to_str().to_string()),
|
||||
quality: self.quality.map(|q| q.to_str().to_string()),
|
||||
num_images: Some(self.num_images.to_u8()),
|
||||
output_format: Some(output_format.to_str().to_string()),
|
||||
output_compression,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated cost in whole cents, rounded up per image.
|
||||
///
|
||||
/// Each output image is charged for rendering plus an allowance for reading the reference
|
||||
/// images (the mask is not counted). With no explicit size the estimate uses the
|
||||
/// conservative `Auto` dimensions.
|
||||
pub fn estimate_cost_in_cents(&self) -> UsdCents {
|
||||
let quality = self.quality.unwrap_or(Self::DEFAULT_QUALITY);
|
||||
let (width, height) = self.image_size
|
||||
.unwrap_or(Self::DEFAULT_IMAGE_SIZE)
|
||||
.estimated_output_dimensions(self.resolution);
|
||||
let hundredths_per_image = estimate_output_image_cost(quality, width, height)
|
||||
+ estimate_reference_images_cost(self.image_urls.len());
|
||||
hundredths_of_cent_to_cents(hundredths_per_image) * self.num_images.to_u64()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
const IMAGE_URL: &str = "https://example.com/image.png";
|
||||
|
||||
mod to_raw_input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_request_serializes_to_expected_json() {
|
||||
let raw = make_params(1, One, None, None, None).to_raw_input();
|
||||
let json = serde_json::to_value(&raw).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "test",
|
||||
"image_urls": [IMAGE_URL],
|
||||
"num_images": 1,
|
||||
"output_format": "png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_request_serializes_to_expected_json() {
|
||||
let params = GptImage2p5EditImageParams {
|
||||
prompt: "add a hat".to_string(),
|
||||
image_urls: vec!["https://example.com/a.png".to_string(), "https://example.com/b.png".to_string()],
|
||||
mask_url: Some("https://example.com/mask.png".to_string()),
|
||||
num_images: Two,
|
||||
image_size: Some(Portrait4x3),
|
||||
resolution: Some(OneK),
|
||||
quality: Some(Max),
|
||||
background: Some(GptImage2p5Background::Opaque),
|
||||
output_format: Some(GptImage2p5OutputFormat::Jpeg),
|
||||
output_compression: Some(90),
|
||||
};
|
||||
let json = serde_json::to_value(params.to_raw_input()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "add a hat",
|
||||
"image_urls": ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
"mask_url": "https://example.com/mask.png",
|
||||
"image_size": { "width": 768, "height": 1024 },
|
||||
"background": "opaque",
|
||||
"quality": "max",
|
||||
"num_images": 2,
|
||||
"output_format": "jpeg",
|
||||
"output_compression": 90,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_size_sends_auto_preset_even_with_resolution() {
|
||||
let raw = make_params(1, One, None, Some(Auto), Some(TwoK)).to_raw_input();
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"),
|
||||
GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_without_size_is_not_sent() {
|
||||
let raw = make_params(1, One, None, None, Some(TwoK)).to_raw_input();
|
||||
assert!(raw.image_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_dropped_for_png() {
|
||||
let mut params = make_params(1, One, None, None, None);
|
||||
params.output_compression = Some(50);
|
||||
assert!(params.to_raw_input().output_compression.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
|
||||
// (size, resolution, [low, medium, high, xhigh, max]) — expected cents per image with
|
||||
// one reference image. Pinned from the model in `gpt_image_2p5_pricing`.
|
||||
const GRID_ONE_REFERENCE: &[(GptImage2p5ImageSize, Option<GptImage2Resolution>, [u64; 5])] = &[
|
||||
// 1:1
|
||||
(Square, None, [2, 3, 7, 11, 22]), // 1024x1024
|
||||
(Square, Some(OneK), [2, 3, 7, 11, 22]), // 1024x1024
|
||||
(Square, Some(TwoK), [2, 3, 10, 16, 35]), // 2048x2048
|
||||
(Square, Some(ThreeK), [3, 4, 14, 23, 51]), // 2880x2880
|
||||
(Square, Some(FourK), [3, 4, 14, 23, 51]), // 2880x2880 (pixel cap)
|
||||
(SquareHd, None, [2, 3, 10, 16, 35]), // 2048x2048
|
||||
(SquareHd, Some(OneK), [2, 3, 7, 11, 22]), // 1024x1024
|
||||
(SquareHd, Some(TwoK), [2, 3, 10, 16, 35]), // 2048x2048
|
||||
(SquareHd, Some(ThreeK), [3, 4, 14, 23, 51]), // 2880x2880
|
||||
(SquareHd, Some(FourK), [3, 4, 14, 23, 51]), // 2880x2880 (pixel cap)
|
||||
// 4:3
|
||||
(Landscape4x3, None, [2, 2, 5, 8, 16]), // 1024x768
|
||||
(Landscape4x3, Some(OneK), [2, 2, 5, 8, 16]), // 1024x768
|
||||
(Landscape4x3, Some(TwoK), [2, 3, 7, 12, 25]), // 2048x1536
|
||||
(Landscape4x3, Some(ThreeK), [2, 4, 11, 19, 40]), // 3072x2304
|
||||
(Landscape4x3, Some(FourK), [3, 4, 12, 21, 45]), // 3312x2480 (pixel cap)
|
||||
(Portrait4x3, None, [2, 2, 5, 8, 16]), // 768x1024
|
||||
(Portrait4x3, Some(OneK), [2, 2, 5, 8, 16]), // 768x1024
|
||||
(Portrait4x3, Some(TwoK), [2, 3, 7, 12, 25]), // 1536x2048
|
||||
(Portrait4x3, Some(ThreeK), [2, 4, 11, 19, 40]), // 2304x3072
|
||||
(Portrait4x3, Some(FourK), [3, 4, 12, 21, 45]), // 2480x3312 (pixel cap)
|
||||
// 16:9
|
||||
(Landscape16x9, None, [2, 2, 5, 8, 17]), // 1920x1080
|
||||
(Landscape16x9, Some(OneK), [2, 2, 4, 6, 12]), // 1088x608
|
||||
(Landscape16x9, Some(TwoK), [2, 2, 6, 9, 18]), // 2048x1152
|
||||
(Landscape16x9, Some(ThreeK), [2, 3, 8, 14, 30]), // 3072x1728
|
||||
(Landscape16x9, Some(FourK), [2, 4, 11, 19, 41]), // 3840x2160
|
||||
(Portrait16x9, None, [2, 2, 5, 8, 17]), // 1080x1920
|
||||
(Portrait16x9, Some(OneK), [2, 2, 4, 6, 12]), // 608x1088
|
||||
(Portrait16x9, Some(TwoK), [2, 2, 6, 9, 18]), // 1152x2048
|
||||
(Portrait16x9, Some(ThreeK), [2, 3, 8, 14, 30]), // 1728x3072
|
||||
(Portrait16x9, Some(FourK), [2, 4, 11, 19, 41]), // 2160x3840
|
||||
// Auto always prices at the conservative 3840x2160
|
||||
(Auto, None, [2, 4, 11, 19, 41]),
|
||||
(Auto, Some(OneK), [2, 4, 11, 19, 41]),
|
||||
(Auto, Some(FourK), [2, 4, 11, 19, 41]),
|
||||
];
|
||||
|
||||
const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max];
|
||||
|
||||
#[test]
|
||||
fn exhaustive_grid_with_one_reference_image() {
|
||||
for &(size, resolution, expected) in GRID_ONE_REFERENCE {
|
||||
for (quality, expected) in ALL_QUALITIES.iter().zip(expected) {
|
||||
let actual = make_params(1, One, Some(*quality), Some(size), resolution).estimate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {resolution:?} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_scales_linearly_across_grid() {
|
||||
for &(size, resolution, _) in GRID_ONE_REFERENCE {
|
||||
let per_image = make_params(1, One, Some(High), Some(size), resolution).estimate_cost_in_cents();
|
||||
for (num, n) in [(Two, 2), (Three, 3), (Four, 4)] {
|
||||
let actual = make_params(1, num, Some(High), Some(size), resolution).estimate_cost_in_cents();
|
||||
assert_eq!(actual, per_image * n, "{size:?} {resolution:?} x{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_reference_images_never_cost_less() {
|
||||
for &(size, resolution, _) in GRID_ONE_REFERENCE {
|
||||
let mut previous = 0;
|
||||
for refs in 1..=16 {
|
||||
let cost = make_params(refs, One, Some(High), Some(size), resolution).estimate_cost_in_cents();
|
||||
assert!(cost >= previous, "{size:?} {resolution:?} {refs} refs: {cost} < {previous}");
|
||||
previous = cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_auto_size() {
|
||||
// 3840x2160 high = 1001 + one reference image 80 = 1081 hundredths => 11 cents
|
||||
assert_eq!(make_params(1, One, None, None, None).estimate_cost_in_cents(), 11);
|
||||
assert_eq!(make_params(1, One, Some(High), Some(Auto), None).estimate_cost_in_cents(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_images_add_to_cost() {
|
||||
// Square high = 529 hundredths; each reference image adds 80.
|
||||
assert_eq!(make_params(1, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 7); // 609
|
||||
assert_eq!(make_params(3, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 8); // 769
|
||||
assert_eq!(make_params(16, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 19); // 1809
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_per_image_cents() {
|
||||
assert_eq!(make_params(1, One, Some(High), Some(Square), None).estimate_cost_in_cents(), 7);
|
||||
assert_eq!(make_params(1, Two, Some(High), Some(Square), None).estimate_cost_in_cents(), 14);
|
||||
assert_eq!(make_params(1, Three, Some(High), Some(Square), None).estimate_cost_in_cents(), 21);
|
||||
assert_eq!(make_params(1, Four, Some(High), Some(Square), None).estimate_cost_in_cents(), 28);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_params(
|
||||
num_reference_images: usize,
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5EditImageParams {
|
||||
GptImage2p5EditImageParams {
|
||||
prompt: "test".to_string(),
|
||||
image_urls: vec![IMAGE_URL.to_string(); num_reference_images],
|
||||
mask_url: None,
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::{
|
||||
compute_custom_image_size, GptImage2AspectRatio, GptImage2Resolution,
|
||||
};
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
|
||||
/// Output image size preset. Both text-to-image and edit accept the same set, including
|
||||
/// `Auto` (text-to-image: model picks; edit: inferred from the input images).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GptImage2p5ImageSize {
|
||||
SquareHd,
|
||||
Square,
|
||||
Portrait4x3,
|
||||
Portrait16x9,
|
||||
Landscape4x3,
|
||||
Landscape16x9,
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Conservative dimensions assumed for `Auto` when estimating cost: the largest canonical
|
||||
/// size Fal publishes pricing for.
|
||||
pub const AUTO_SIZE_ESTIMATED_DIMENSIONS: (u32, u32) = (3840, 2160);
|
||||
|
||||
impl GptImage2p5ImageSize {
|
||||
/// Build the wire `image_size` value.
|
||||
///
|
||||
/// With a resolution tier, a concrete `{width, height}` is computed from the preset's
|
||||
/// aspect ratio. `Auto` has no aspect ratio, so it is always sent as the `"auto"` preset
|
||||
/// and any resolution tier is ignored.
|
||||
pub fn to_raw_param(self, resolution: Option<GptImage2Resolution>) -> GptImage2p5ImageSizeParam {
|
||||
match (self.to_aspect_ratio(), resolution) {
|
||||
(Some(aspect), Some(resolution)) => {
|
||||
GptImage2p5ImageSizeParam::Custom(compute_custom_image_size(aspect, resolution))
|
||||
}
|
||||
_ => GptImage2p5ImageSizeParam::Preset(self.to_preset_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The output dimensions we expect Fal to render, used for cost estimation.
|
||||
pub fn estimated_output_dimensions(self, resolution: Option<GptImage2Resolution>) -> (u32, u32) {
|
||||
match (self.to_aspect_ratio(), resolution) {
|
||||
(Some(aspect), Some(resolution)) => {
|
||||
let custom = compute_custom_image_size(aspect, resolution);
|
||||
(custom.width, custom.height)
|
||||
}
|
||||
_ => self.preset_dimensions(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_preset_str(self) -> &'static str {
|
||||
match self {
|
||||
GptImage2p5ImageSize::SquareHd => "square_hd",
|
||||
GptImage2p5ImageSize::Square => "square",
|
||||
GptImage2p5ImageSize::Portrait4x3 => "portrait_4_3",
|
||||
GptImage2p5ImageSize::Portrait16x9 => "portrait_16_9",
|
||||
GptImage2p5ImageSize::Landscape4x3 => "landscape_4_3",
|
||||
GptImage2p5ImageSize::Landscape16x9 => "landscape_16_9",
|
||||
GptImage2p5ImageSize::Auto => "auto",
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` for `Auto`, which carries no aspect ratio.
|
||||
pub fn to_aspect_ratio(self) -> Option<GptImage2AspectRatio> {
|
||||
match self {
|
||||
GptImage2p5ImageSize::SquareHd => Some(GptImage2AspectRatio::SquareHd),
|
||||
GptImage2p5ImageSize::Square => Some(GptImage2AspectRatio::Square),
|
||||
GptImage2p5ImageSize::Portrait4x3 => Some(GptImage2AspectRatio::Portrait4x3),
|
||||
GptImage2p5ImageSize::Portrait16x9 => Some(GptImage2AspectRatio::Portrait16x9),
|
||||
GptImage2p5ImageSize::Landscape4x3 => Some(GptImage2AspectRatio::Landscape4x3),
|
||||
GptImage2p5ImageSize::Landscape16x9 => Some(GptImage2AspectRatio::Landscape16x9),
|
||||
GptImage2p5ImageSize::Auto => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimensions assumed for a preset sent without a resolution tier. These match the
|
||||
/// assumptions used by the GPT Image 2 bindings; `Auto` uses the conservative maximum.
|
||||
fn preset_dimensions(self) -> (u32, u32) {
|
||||
match self {
|
||||
GptImage2p5ImageSize::SquareHd => (2048, 2048),
|
||||
GptImage2p5ImageSize::Square => (1024, 1024),
|
||||
GptImage2p5ImageSize::Portrait4x3 => (768, 1024),
|
||||
GptImage2p5ImageSize::Portrait16x9 => (1080, 1920),
|
||||
GptImage2p5ImageSize::Landscape4x3 => (1024, 768),
|
||||
GptImage2p5ImageSize::Landscape16x9 => (1920, 1080),
|
||||
GptImage2p5ImageSize::Auto => AUTO_SIZE_ESTIMATED_DIMENSIONS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
|
||||
const ALL_SIZES: &[GptImage2p5ImageSize] = &[
|
||||
SquareHd, Square, Portrait4x3, Portrait16x9, Landscape4x3, Landscape16x9, Auto,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn wire_strings_match_fal_schema() {
|
||||
assert_eq!(SquareHd.to_preset_str(), "square_hd");
|
||||
assert_eq!(Square.to_preset_str(), "square");
|
||||
assert_eq!(Portrait4x3.to_preset_str(), "portrait_4_3");
|
||||
assert_eq!(Portrait16x9.to_preset_str(), "portrait_16_9");
|
||||
assert_eq!(Landscape4x3.to_preset_str(), "landscape_4_3");
|
||||
assert_eq!(Landscape16x9.to_preset_str(), "landscape_16_9");
|
||||
assert_eq!(Auto.to_preset_str(), "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_without_resolution_sends_preset_string() {
|
||||
for &size in ALL_SIZES {
|
||||
match size.to_raw_param(None) {
|
||||
GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, size.to_preset_str()),
|
||||
GptImage2p5ImageSizeParam::Custom(_) => panic!("{size:?} should be a preset"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_with_resolution_sends_custom_dimensions() {
|
||||
match Landscape16x9.to_raw_param(Some(GptImage2Resolution::TwoK)) {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => {
|
||||
assert_eq!(c.width, 2048);
|
||||
assert_eq!(c.height, 1152);
|
||||
}
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_with_resolution_falls_back_to_auto_preset() {
|
||||
match Auto.to_raw_param(Some(GptImage2Resolution::TwoK)) {
|
||||
GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"),
|
||||
GptImage2p5ImageSizeParam::Custom(_) => panic!("Auto must never send custom dimensions"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_dimensions_without_resolution() {
|
||||
assert_eq!(Square.estimated_output_dimensions(None), (1024, 1024));
|
||||
assert_eq!(SquareHd.estimated_output_dimensions(None), (2048, 2048));
|
||||
assert_eq!(Landscape4x3.estimated_output_dimensions(None), (1024, 768));
|
||||
assert_eq!(Portrait4x3.estimated_output_dimensions(None), (768, 1024));
|
||||
assert_eq!(Landscape16x9.estimated_output_dimensions(None), (1920, 1080));
|
||||
assert_eq!(Portrait16x9.estimated_output_dimensions(None), (1080, 1920));
|
||||
assert_eq!(Auto.estimated_output_dimensions(None), (3840, 2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimated_dimensions_with_resolution_match_wire_dimensions() {
|
||||
for &size in ALL_SIZES {
|
||||
if size == Auto {
|
||||
continue;
|
||||
}
|
||||
for res in [GptImage2Resolution::OneK, GptImage2Resolution::TwoK, GptImage2Resolution::ThreeK, GptImage2Resolution::FourK] {
|
||||
let (w, h) = size.estimated_output_dimensions(Some(res));
|
||||
match size.to_raw_param(Some(res)) {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (w, h), "{size:?} {res:?}"),
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("{size:?} {res:?} should be custom"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_ignores_resolution_for_estimation() {
|
||||
assert_eq!(Auto.estimated_output_dimensions(Some(GptImage2Resolution::OneK)), (3840, 2160));
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/// Number of images to generate per request.
|
||||
///
|
||||
/// Fal accepts 1 to 10; we expose the same 1 to 4 range as the other image bindings, which
|
||||
/// is what the ArtCraft batch UI offers. Add variants here if a larger batch is ever needed.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GptImage2p5NumImages {
|
||||
One,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
}
|
||||
|
||||
impl GptImage2p5NumImages {
|
||||
pub fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GptImage2p5NumImages::One => 1,
|
||||
GptImage2p5NumImages::Two => 2,
|
||||
GptImage2p5NumImages::Three => 3,
|
||||
GptImage2p5NumImages::Four => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_u64(self) -> u64 {
|
||||
self.to_u8() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts() {
|
||||
assert_eq!(GptImage2p5NumImages::One.to_u8(), 1);
|
||||
assert_eq!(GptImage2p5NumImages::Two.to_u8(), 2);
|
||||
assert_eq!(GptImage2p5NumImages::Three.to_u8(), 3);
|
||||
assert_eq!(GptImage2p5NumImages::Four.to_u8(), 4);
|
||||
assert_eq!(GptImage2p5NumImages::Four.to_u64(), 4);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/// Output file format. Fal's default is `Png`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GptImage2p5OutputFormat {
|
||||
Jpeg,
|
||||
Png,
|
||||
Webp,
|
||||
}
|
||||
|
||||
impl GptImage2p5OutputFormat {
|
||||
pub fn to_str(self) -> &'static str {
|
||||
match self {
|
||||
GptImage2p5OutputFormat::Jpeg => "jpeg",
|
||||
GptImage2p5OutputFormat::Png => "png",
|
||||
GptImage2p5OutputFormat::Webp => "webp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Fal only honours `output_compression` for lossy formats.
|
||||
pub fn supports_compression(self) -> bool {
|
||||
match self {
|
||||
GptImage2p5OutputFormat::Jpeg | GptImage2p5OutputFormat::Webp => true,
|
||||
GptImage2p5OutputFormat::Png => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_strings_match_fal_schema() {
|
||||
assert_eq!(GptImage2p5OutputFormat::Jpeg.to_str(), "jpeg");
|
||||
assert_eq!(GptImage2p5OutputFormat::Png.to_str(), "png");
|
||||
assert_eq!(GptImage2p5OutputFormat::Webp.to_str(), "webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_lossy_formats_support_compression() {
|
||||
assert!(GptImage2p5OutputFormat::Jpeg.supports_compression());
|
||||
assert!(GptImage2p5OutputFormat::Webp.supports_compression());
|
||||
assert!(!GptImage2p5OutputFormat::Png.supports_compression());
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
//! Cost model for GPT Image 2.5 (Flare and Sunburst share it).
|
||||
//!
|
||||
//! Fal bills by token: output image tokens at $30.00 / 1M, input image tokens at $8.00 / 1M,
|
||||
//! text tokens at $5.00 / 1M input, rounded up to $0.0001. Rather than count tokens, we fit
|
||||
//! the canonical per-image price table Fal publishes:
|
||||
//!
|
||||
//! | Size | low | medium | high | xhigh | max |
|
||||
//! |-----------|----------|----------|----------|----------|----------|
|
||||
//! | 1024x768 | $0.00402 | $0.00903 | $0.03612 | $0.06420 | $0.14445 |
|
||||
//! | 1024x1024 | $0.00588 | $0.01317 | $0.05268 | $0.09366 | $0.21072 |
|
||||
//! | 1024x1536 | $0.00474 | $0.01029 | $0.04116 | $0.07377 | $0.16464 |
|
||||
//! | 1920x1080 | $0.00441 | $0.01029 | $0.03960 | $0.07041 | $0.15840 |
|
||||
//! | 2560x1440 | $0.00615 | $0.01434 | $0.05529 | $0.09828 | $0.22110 |
|
||||
//! | 3840x2160 | $0.01113 | $0.02595 | $0.10008 | $0.17790 | $0.40026 |
|
||||
//!
|
||||
//! Dividing by the output token price shows the structure: at `low` quality an image costs a
|
||||
//! per-aspect-ratio base (~158 tokens for 1:1, ~106 for 4:3, ~101 for 3:2, ~72 for 16:9) plus
|
||||
//! 36 tokens per megapixel, and each higher quality tier multiplies the token count by a
|
||||
//! constant (medium 7/3, high 9, xhigh 16, max 36). The three 16:9 rows pin the slope and the
|
||||
//! multipliers exactly; the other rows pin their bases.
|
||||
//!
|
||||
//! The estimate never undershoots the published table and is within 8% above it at every
|
||||
//! canonical size (see tests). Prompt text tokens are not modelled: a few hundred tokens at
|
||||
//! $5 / 1M is well under $0.002.
|
||||
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents;
|
||||
|
||||
/// Cost in hundredths of a US cent ($0.0001), the granularity Fal rounds GPT Image 2.5 to.
|
||||
pub type UsdHundredthsOfCent = u64;
|
||||
|
||||
/// $30.00 per 1M output image tokens.
|
||||
const OUTPUT_IMAGE_TOKEN_USD: f64 = 30.0 / 1_000_000.0;
|
||||
|
||||
/// $8.00 per 1M input image tokens.
|
||||
const INPUT_IMAGE_TOKEN_USD: f64 = 8.0 / 1_000_000.0;
|
||||
|
||||
/// Output tokens per megapixel at `low` quality, fitted from the 16:9 rows of the table.
|
||||
const LOW_QUALITY_OUTPUT_TOKENS_PER_MEGAPIXEL: f64 = 36.0;
|
||||
|
||||
/// Fal does not publish input image token counts. This is a conservative allowance per
|
||||
/// reference image on edit requests, comparable to a high-detail 1024x1024 vision input.
|
||||
const ESTIMATED_INPUT_TOKENS_PER_REFERENCE_IMAGE: f64 = 1_000.0;
|
||||
|
||||
/// Estimated cost of rendering one output image of the given size at the given quality.
|
||||
pub fn estimate_output_image_cost(quality: GptImage2p5Quality, width: u32, height: u32) -> UsdHundredthsOfCent {
|
||||
let megapixels = width as f64 * height as f64 / 1_000_000.0;
|
||||
let low_quality_tokens = low_quality_base_tokens(width, height)
|
||||
+ LOW_QUALITY_OUTPUT_TOKENS_PER_MEGAPIXEL * megapixels;
|
||||
let tokens = low_quality_tokens * quality_token_multiplier(quality);
|
||||
usd_to_hundredths_of_cent(tokens * OUTPUT_IMAGE_TOKEN_USD)
|
||||
}
|
||||
|
||||
/// Estimated cost of the reference (input) images attached to an edit request.
|
||||
pub fn estimate_reference_images_cost(num_reference_images: usize) -> UsdHundredthsOfCent {
|
||||
let tokens = num_reference_images as f64 * ESTIMATED_INPUT_TOKENS_PER_REFERENCE_IMAGE;
|
||||
usd_to_hundredths_of_cent(tokens * INPUT_IMAGE_TOKEN_USD)
|
||||
}
|
||||
|
||||
/// Round up to whole cents.
|
||||
pub fn hundredths_of_cent_to_cents(hundredths: UsdHundredthsOfCent) -> UsdCents {
|
||||
hundredths.div_ceil(100)
|
||||
}
|
||||
|
||||
/// Token count multiplier for each quality tier relative to `low`.
|
||||
fn quality_token_multiplier(quality: GptImage2p5Quality) -> f64 {
|
||||
match quality {
|
||||
GptImage2p5Quality::Low => 1.0,
|
||||
GptImage2p5Quality::Medium => 7.0 / 3.0,
|
||||
// `auto` lets the model choose; `high` is the documented default so we price it there.
|
||||
GptImage2p5Quality::High | GptImage2p5Quality::Auto => 9.0,
|
||||
GptImage2p5Quality::XHigh => 16.0,
|
||||
GptImage2p5Quality::Max => 36.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-image base token count at `low` quality, which depends on the aspect ratio family.
|
||||
/// Fitted from the canonical table: 1:1 => 158, 4:3 => 106, 3:2 => 101, 16:9 => 72.
|
||||
fn low_quality_base_tokens(width: u32, height: u32) -> f64 {
|
||||
let long_edge = width.max(height) as f64;
|
||||
let short_edge = width.min(height).max(1) as f64;
|
||||
let ratio = long_edge / short_edge;
|
||||
if ratio < 1.25 {
|
||||
158.0 // ~1:1
|
||||
} else if ratio < 1.42 {
|
||||
106.0 // ~4:3 (1.333)
|
||||
} else if ratio < 1.60 {
|
||||
101.0 // ~3:2 (1.5)
|
||||
} else {
|
||||
72.0 // ~16:9 (1.778) and wider
|
||||
}
|
||||
}
|
||||
|
||||
fn usd_to_hundredths_of_cent(usd: f64) -> UsdHundredthsOfCent {
|
||||
// Small epsilon so values that are exactly on a boundary in decimal don't round up
|
||||
// because of binary float noise.
|
||||
(usd * 10_000.0 - 1e-6).ceil().max(0.0) as u64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GptImage2p5Quality::*;
|
||||
|
||||
const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max];
|
||||
|
||||
/// Fal's published canonical table, in hundredths of a cent: (width, height, [low, medium, high, xhigh, max]).
|
||||
const FAL_PUBLISHED_TABLE: &[(u32, u32, [u64; 5])] = &[
|
||||
(1024, 768, [ 40, 90, 361, 642, 1444]),
|
||||
(1024, 1024, [ 59, 132, 527, 937, 2107]),
|
||||
(1024, 1536, [ 47, 103, 412, 738, 1646]),
|
||||
(1920, 1080, [ 44, 103, 396, 704, 1584]),
|
||||
(2560, 1440, [ 62, 143, 553, 983, 2211]),
|
||||
(3840, 2160, [111, 260, 1001, 1779, 4003]),
|
||||
];
|
||||
|
||||
/// What our model produces at the canonical sizes, in hundredths of a cent. Pinned so a
|
||||
/// change to the fit is a deliberate, visible diff.
|
||||
const MODEL_TABLE: &[(u32, u32, [u64; 5])] = &[
|
||||
(1024, 768, [ 41, 95, 363, 645, 1451]),
|
||||
(1024, 1024, [ 59, 138, 529, 940, 2115]),
|
||||
(1024, 1536, [ 48, 111, 426, 757, 1703]),
|
||||
(1920, 1080, [ 44, 103, 396, 704, 1584]),
|
||||
(2560, 1440, [ 62, 144, 553, 983, 2211]),
|
||||
(3840, 2160, [112, 260, 1001, 1779, 4003]),
|
||||
];
|
||||
|
||||
mod canonical_table_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_matches_pinned_values() {
|
||||
for &(w, h, expected) in MODEL_TABLE {
|
||||
for (quality, expected) in ALL_QUALITIES.iter().zip(expected) {
|
||||
let actual = estimate_output_image_cost(*quality, w, h);
|
||||
assert_eq!(actual, expected, "{w}x{h} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_never_undershoots_fal_published_prices() {
|
||||
for &(w, h, published) in FAL_PUBLISHED_TABLE {
|
||||
for (quality, published) in ALL_QUALITIES.iter().zip(published) {
|
||||
let actual = estimate_output_image_cost(*quality, w, h);
|
||||
assert!(actual >= published, "{w}x{h} {quality:?}: model {actual} < published {published}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_is_within_eight_percent_of_fal_published_prices() {
|
||||
for &(w, h, published) in FAL_PUBLISHED_TABLE {
|
||||
for (quality, published) in ALL_QUALITIES.iter().zip(published) {
|
||||
let actual = estimate_output_image_cost(*quality, w, h);
|
||||
let ceiling = published * 108 / 100 + 1;
|
||||
assert!(actual <= ceiling, "{w}x{h} {quality:?}: model {actual} > {ceiling} (published {published})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_by_nine_rows_are_exact() {
|
||||
// The 16:9 rows pin the slope and the quality multipliers, so they should reproduce
|
||||
// exactly (the 1080p low row is the only one with a fractional base).
|
||||
assert_eq!(estimate_output_image_cost(High, 1920, 1080), 396);
|
||||
assert_eq!(estimate_output_image_cost(High, 2560, 1440), 553);
|
||||
assert_eq!(estimate_output_image_cost(High, 3840, 2160), 1001);
|
||||
assert_eq!(estimate_output_image_cost(Max, 3840, 2160), 4003);
|
||||
}
|
||||
}
|
||||
|
||||
mod quality_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auto_is_priced_as_high() {
|
||||
for &(w, h, _) in FAL_PUBLISHED_TABLE {
|
||||
assert_eq!(
|
||||
estimate_output_image_cost(Auto, w, h),
|
||||
estimate_output_image_cost(High, w, h),
|
||||
"{w}x{h}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_quality_costs_strictly_more() {
|
||||
for &(w, h, _) in FAL_PUBLISHED_TABLE {
|
||||
for pair in ALL_QUALITIES.windows(2) {
|
||||
let lower = estimate_output_image_cost(pair[0], w, h);
|
||||
let higher = estimate_output_image_cost(pair[1], w, h);
|
||||
assert!(lower < higher, "{w}x{h}: {:?} ({lower}) should be < {:?} ({higher})", pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod dimension_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn more_pixels_at_same_aspect_costs_more() {
|
||||
let sizes_16x9 = [(1088, 608), (1920, 1080), (2048, 1152), (2560, 1440), (3072, 1728), (3840, 2160)];
|
||||
for pair in sizes_16x9.windows(2) {
|
||||
let smaller = estimate_output_image_cost(High, pair[0].0, pair[0].1);
|
||||
let larger = estimate_output_image_cost(High, pair[1].0, pair[1].1);
|
||||
assert!(smaller < larger, "{:?} ({smaller}) should be < {:?} ({larger})", pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_does_not_change_cost() {
|
||||
assert_eq!(estimate_output_image_cost(High, 1024, 768), estimate_output_image_cost(High, 768, 1024));
|
||||
assert_eq!(estimate_output_image_cost(High, 1920, 1080), estimate_output_image_cost(High, 1080, 1920));
|
||||
assert_eq!(estimate_output_image_cost(High, 1024, 1536), estimate_output_image_cost(High, 1536, 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_family_bases() {
|
||||
assert_eq!(low_quality_base_tokens(1024, 1024), 158.0);
|
||||
assert_eq!(low_quality_base_tokens(2048, 2048), 158.0);
|
||||
assert_eq!(low_quality_base_tokens(1024, 768), 106.0);
|
||||
assert_eq!(low_quality_base_tokens(768, 1024), 106.0);
|
||||
assert_eq!(low_quality_base_tokens(1024, 1536), 101.0);
|
||||
assert_eq!(low_quality_base_tokens(1920, 1080), 72.0);
|
||||
assert_eq!(low_quality_base_tokens(1088, 608), 72.0);
|
||||
assert_eq!(low_quality_base_tokens(3000, 1000), 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_k_square_high_quality() {
|
||||
// 158 + 36 * 4.194304 = 308.99 tokens * 9 * $0.00003 = $0.08343 => 835 hundredths
|
||||
assert_eq!(estimate_output_image_cost(High, 2048, 2048), 835);
|
||||
}
|
||||
}
|
||||
|
||||
mod reference_image_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_images_cost_eighty_hundredths_each() {
|
||||
// 1,000 tokens * $8 / 1M = $0.008 per image
|
||||
assert_eq!(estimate_reference_images_cost(0), 0);
|
||||
assert_eq!(estimate_reference_images_cost(1), 80);
|
||||
assert_eq!(estimate_reference_images_cost(3), 240);
|
||||
assert_eq!(estimate_reference_images_cost(16), 1280);
|
||||
}
|
||||
}
|
||||
|
||||
mod rounding_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cents_round_up() {
|
||||
assert_eq!(hundredths_of_cent_to_cents(0), 0);
|
||||
assert_eq!(hundredths_of_cent_to_cents(1), 1);
|
||||
assert_eq!(hundredths_of_cent_to_cents(99), 1);
|
||||
assert_eq!(hundredths_of_cent_to_cents(100), 1);
|
||||
assert_eq!(hundredths_of_cent_to_cents(101), 2);
|
||||
assert_eq!(hundredths_of_cent_to_cents(1001), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usd_conversion_rounds_up_without_float_noise() {
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.0), 0);
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.0001), 1);
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.00588), 59);
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.005881), 59);
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.00589), 59);
|
||||
assert_eq!(usd_to_hundredths_of_cent(0.005901), 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/// Quality tier. Higher tiers spend more output image tokens (more detail, more latency).
|
||||
/// Fal's default is `High`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GptImage2p5Quality {
|
||||
/// Let the model choose. Priced as `High` since that is the documented default.
|
||||
Auto,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl GptImage2p5Quality {
|
||||
pub fn to_str(self) -> &'static str {
|
||||
match self {
|
||||
GptImage2p5Quality::Auto => "auto",
|
||||
GptImage2p5Quality::Low => "low",
|
||||
GptImage2p5Quality::Medium => "medium",
|
||||
GptImage2p5Quality::High => "high",
|
||||
GptImage2p5Quality::XHigh => "xhigh",
|
||||
GptImage2p5Quality::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_strings_match_fal_schema() {
|
||||
assert_eq!(GptImage2p5Quality::Auto.to_str(), "auto");
|
||||
assert_eq!(GptImage2p5Quality::Low.to_str(), "low");
|
||||
assert_eq!(GptImage2p5Quality::Medium.to_str(), "medium");
|
||||
assert_eq!(GptImage2p5Quality::High.to_str(), "high");
|
||||
assert_eq!(GptImage2p5Quality::XHigh.to_str(), "xhigh");
|
||||
assert_eq!(GptImage2p5Quality::Max.to_str(), "max");
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_background::GptImage2p5Background;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_pricing::{
|
||||
estimate_output_image_cost, hundredths_of_cent_to_cents,
|
||||
};
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::UsdCents;
|
||||
|
||||
/// Parameters for a GPT Image 2.5 text-to-image request. Identical for Flare and Sunburst;
|
||||
/// the per-endpoint request types wrap this.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5TextToImageParams {
|
||||
/// Text prompt describing the image to generate. 1 to 32,000 characters.
|
||||
pub prompt: String,
|
||||
|
||||
/// Number of images to generate.
|
||||
pub num_images: GptImage2p5NumImages,
|
||||
|
||||
/// Output image size preset. `None` lets Fal default to `landscape_4_3`.
|
||||
pub image_size: Option<GptImage2p5ImageSize>,
|
||||
|
||||
/// Optional resolution tier. When present (and the size is not `Auto`), a custom
|
||||
/// width x height is computed from the preset's aspect ratio and sent instead of the
|
||||
/// preset name.
|
||||
pub resolution: Option<GptImage2Resolution>,
|
||||
|
||||
/// Quality tier. `None` lets Fal default to `high`.
|
||||
pub quality: Option<GptImage2p5Quality>,
|
||||
|
||||
/// Background treatment. `None` lets Fal default to `auto`.
|
||||
pub background: Option<GptImage2p5Background>,
|
||||
|
||||
/// Output format. `None` sends `png`.
|
||||
pub output_format: Option<GptImage2p5OutputFormat>,
|
||||
|
||||
/// Compression level 0 to 100. Only sent for `jpeg` and `webp`.
|
||||
pub output_compression: Option<u8>,
|
||||
}
|
||||
|
||||
impl GptImage2p5TextToImageParams {
|
||||
/// Fal's default when `image_size` is omitted.
|
||||
pub const DEFAULT_IMAGE_SIZE: GptImage2p5ImageSize = GptImage2p5ImageSize::Landscape4x3;
|
||||
|
||||
/// Fal's default when `quality` is omitted.
|
||||
pub const DEFAULT_QUALITY: GptImage2p5Quality = GptImage2p5Quality::High;
|
||||
|
||||
pub fn to_raw_input(&self) -> GptImage2p5TextToImageInput {
|
||||
let output_format = self.output_format.unwrap_or(GptImage2p5OutputFormat::Png);
|
||||
let output_compression = if output_format.supports_compression() {
|
||||
self.output_compression.map(|c| c.min(100))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
GptImage2p5TextToImageInput {
|
||||
prompt: self.prompt.clone(),
|
||||
image_size: self.image_size.map(|size| size.to_raw_param(self.resolution)),
|
||||
background: self.background.map(|b| b.to_str().to_string()),
|
||||
quality: self.quality.map(|q| q.to_str().to_string()),
|
||||
num_images: Some(self.num_images.to_u8()),
|
||||
output_format: Some(output_format.to_str().to_string()),
|
||||
output_compression,
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated cost in whole cents, rounded up per image.
|
||||
pub fn estimate_cost_in_cents(&self) -> UsdCents {
|
||||
let quality = self.quality.unwrap_or(Self::DEFAULT_QUALITY);
|
||||
let (width, height) = self.image_size
|
||||
.unwrap_or(Self::DEFAULT_IMAGE_SIZE)
|
||||
.estimated_output_dimensions(self.resolution);
|
||||
let hundredths_per_image = estimate_output_image_cost(quality, width, height);
|
||||
hundredths_of_cent_to_cents(hundredths_per_image) * self.num_images.to_u64()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
mod to_raw_input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_request_only_sends_required_and_defaulted_fields() {
|
||||
let raw = make_params(One, None, None, None).to_raw_input();
|
||||
assert_eq!(raw.prompt, "test");
|
||||
assert!(raw.image_size.is_none());
|
||||
assert!(raw.background.is_none());
|
||||
assert!(raw.quality.is_none());
|
||||
assert_eq!(raw.num_images, Some(1));
|
||||
assert_eq!(raw.output_format.as_deref(), Some("png"));
|
||||
assert!(raw.output_compression.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_request_serializes_to_expected_json() {
|
||||
let raw = make_params(One, None, None, None).to_raw_input();
|
||||
let json = serde_json::to_value(&raw).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "test",
|
||||
"num_images": 1,
|
||||
"output_format": "png",
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_request_serializes_to_expected_json() {
|
||||
let params = GptImage2p5TextToImageParams {
|
||||
prompt: "a corgi".to_string(),
|
||||
num_images: Three,
|
||||
image_size: Some(Landscape16x9),
|
||||
resolution: Some(TwoK),
|
||||
quality: Some(XHigh),
|
||||
background: Some(GptImage2p5Background::Transparent),
|
||||
output_format: Some(GptImage2p5OutputFormat::Webp),
|
||||
output_compression: Some(80),
|
||||
};
|
||||
let json = serde_json::to_value(params.to_raw_input()).unwrap();
|
||||
assert_eq!(json, serde_json::json!({
|
||||
"prompt": "a corgi",
|
||||
"image_size": { "width": 2048, "height": 1152 },
|
||||
"background": "transparent",
|
||||
"quality": "xhigh",
|
||||
"num_images": 3,
|
||||
"output_format": "webp",
|
||||
"output_compression": 80,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_without_resolution() {
|
||||
let raw = make_params(One, None, Some(Square), None).to_raw_input();
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "square"),
|
||||
GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_size_sends_auto_preset() {
|
||||
let raw = make_params(One, None, Some(Auto), Some(TwoK)).to_raw_input();
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Preset(s) => assert_eq!(s, "auto"),
|
||||
GptImage2p5ImageSizeParam::Custom(_) => panic!("expected preset"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_without_size_is_not_sent() {
|
||||
// No preset means no aspect ratio to derive dimensions from; let Fal default.
|
||||
let raw = make_params(One, None, None, Some(TwoK)).to_raw_input();
|
||||
assert!(raw.image_size.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_dropped_for_png() {
|
||||
let mut params = make_params(One, None, None, None);
|
||||
params.output_format = Some(GptImage2p5OutputFormat::Png);
|
||||
params.output_compression = Some(50);
|
||||
assert!(params.to_raw_input().output_compression.is_none());
|
||||
|
||||
params.output_format = None;
|
||||
assert!(params.to_raw_input().output_compression.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compression_kept_for_jpeg_and_clamped() {
|
||||
let mut params = make_params(One, None, None, None);
|
||||
params.output_format = Some(GptImage2p5OutputFormat::Jpeg);
|
||||
params.output_compression = Some(50);
|
||||
assert_eq!(params.to_raw_input().output_compression, Some(50));
|
||||
|
||||
params.output_compression = Some(200);
|
||||
assert_eq!(params.to_raw_input().output_compression, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_qualities_serialize() {
|
||||
for quality in [GptImage2p5Quality::Auto, Low, Medium, High, XHigh, Max] {
|
||||
let raw = make_params(One, Some(quality), None, None).to_raw_input();
|
||||
assert_eq!(raw.quality.as_deref(), Some(quality.to_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
|
||||
// (size, resolution, [low, medium, high, xhigh, max]) — expected cents per image.
|
||||
// Pinned from the model in `gpt_image_2p5_pricing`; a change here must be deliberate.
|
||||
const GRID: &[(GptImage2p5ImageSize, Option<GptImage2Resolution>, [u64; 5])] = &[
|
||||
// 1:1
|
||||
(Square, None, [1, 2, 6, 10, 22]), // 1024x1024
|
||||
(Square, Some(OneK), [1, 2, 6, 10, 22]), // 1024x1024
|
||||
(Square, Some(TwoK), [1, 3, 9, 15, 34]), // 2048x2048
|
||||
(Square, Some(ThreeK), [2, 4, 13, 22, 50]), // 2880x2880
|
||||
(Square, Some(FourK), [2, 4, 13, 22, 50]), // 2880x2880 (pixel cap)
|
||||
(SquareHd, None, [1, 3, 9, 15, 34]), // 2048x2048
|
||||
(SquareHd, Some(OneK), [1, 2, 6, 10, 22]), // 1024x1024
|
||||
(SquareHd, Some(TwoK), [1, 3, 9, 15, 34]), // 2048x2048
|
||||
(SquareHd, Some(ThreeK), [2, 4, 13, 22, 50]), // 2880x2880
|
||||
(SquareHd, Some(FourK), [2, 4, 13, 22, 50]), // 2880x2880 (pixel cap)
|
||||
// 4:3
|
||||
(Landscape4x3, None, [1, 1, 4, 7, 15]), // 1024x768
|
||||
(Landscape4x3, Some(OneK), [1, 1, 4, 7, 15]), // 1024x768
|
||||
(Landscape4x3, Some(TwoK), [1, 2, 6, 11, 24]), // 2048x1536
|
||||
(Landscape4x3, Some(ThreeK), [2, 3, 10, 18, 39]), // 3072x2304
|
||||
(Landscape4x3, Some(FourK), [2, 3, 11, 20, 44]), // 3312x2480 (pixel cap)
|
||||
(Portrait4x3, None, [1, 1, 4, 7, 15]), // 768x1024
|
||||
(Portrait4x3, Some(OneK), [1, 1, 4, 7, 15]), // 768x1024
|
||||
(Portrait4x3, Some(TwoK), [1, 2, 6, 11, 24]), // 1536x2048
|
||||
(Portrait4x3, Some(ThreeK), [2, 3, 10, 18, 39]), // 2304x3072
|
||||
(Portrait4x3, Some(FourK), [2, 3, 11, 20, 44]), // 2480x3312 (pixel cap)
|
||||
// 16:9
|
||||
(Landscape16x9, None, [1, 2, 4, 8, 16]), // 1920x1080
|
||||
(Landscape16x9, Some(OneK), [1, 1, 3, 5, 11]), // 1088x608
|
||||
(Landscape16x9, Some(TwoK), [1, 2, 5, 8, 17]), // 2048x1152
|
||||
(Landscape16x9, Some(ThreeK), [1, 2, 8, 13, 29]), // 3072x1728
|
||||
(Landscape16x9, Some(FourK), [2, 3, 11, 18, 41]), // 3840x2160
|
||||
(Portrait16x9, None, [1, 2, 4, 8, 16]), // 1080x1920
|
||||
(Portrait16x9, Some(OneK), [1, 1, 3, 5, 11]), // 608x1088
|
||||
(Portrait16x9, Some(TwoK), [1, 2, 5, 8, 17]), // 1152x2048
|
||||
(Portrait16x9, Some(ThreeK), [1, 2, 8, 13, 29]), // 1728x3072
|
||||
(Portrait16x9, Some(FourK), [2, 3, 11, 18, 41]), // 2160x3840
|
||||
// Auto always prices at the conservative 3840x2160
|
||||
(Auto, None, [2, 3, 11, 18, 41]),
|
||||
(Auto, Some(OneK), [2, 3, 11, 18, 41]),
|
||||
(Auto, Some(FourK), [2, 3, 11, 18, 41]),
|
||||
];
|
||||
|
||||
const ALL_QUALITIES: [GptImage2p5Quality; 5] = [Low, Medium, High, XHigh, Max];
|
||||
|
||||
#[test]
|
||||
fn exhaustive_grid() {
|
||||
for &(size, resolution, expected) in GRID {
|
||||
for (quality, expected) in ALL_QUALITIES.iter().zip(expected) {
|
||||
let actual = make_params(One, Some(*quality), Some(size), resolution).estimate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {resolution:?} {quality:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_scales_linearly_across_grid() {
|
||||
for &(size, resolution, _) in GRID {
|
||||
let per_image = make_params(One, Some(High), Some(size), resolution).estimate_cost_in_cents();
|
||||
for (num, n) in [(Two, 2), (Three, 3), (Four, 4)] {
|
||||
let actual = make_params(num, Some(High), Some(size), resolution).estimate_cost_in_cents();
|
||||
assert_eq!(actual, per_image * n, "{size:?} {resolution:?} x{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_never_costs_less() {
|
||||
for size in [Square, SquareHd, Landscape4x3, Portrait4x3, Landscape16x9, Portrait16x9] {
|
||||
let tiers = [OneK, TwoK, ThreeK, FourK];
|
||||
for pair in tiers.windows(2) {
|
||||
let lower = make_params(One, Some(High), Some(size), Some(pair[0])).estimate_cost_in_cents();
|
||||
let higher = make_params(One, Some(High), Some(size), Some(pair[1])).estimate_cost_in_cents();
|
||||
assert!(lower <= higher, "{size:?} {:?} ({lower}) should be <= {:?} ({higher})", pair[0], pair[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_landscape_4x3() {
|
||||
// 1024x768 high = 363 hundredths => 4 cents
|
||||
assert_eq!(make_params(One, None, None, None).estimate_cost_in_cents(), 4);
|
||||
assert_eq!(make_params(One, Some(High), Some(Landscape4x3), None).estimate_cost_in_cents(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_without_size_prices_at_default_aspect() {
|
||||
assert_eq!(
|
||||
make_params(One, Some(High), None, Some(TwoK)).estimate_cost_in_cents(),
|
||||
make_params(One, Some(High), Some(Landscape4x3), Some(TwoK)).estimate_cost_in_cents(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_multiplies_per_image_cents() {
|
||||
let per_image = make_params(One, Some(High), Some(Square), None).estimate_cost_in_cents();
|
||||
assert_eq!(per_image, 6);
|
||||
assert_eq!(make_params(Two, Some(High), Some(Square), None).estimate_cost_in_cents(), 12);
|
||||
assert_eq!(make_params(Three, Some(High), Some(Square), None).estimate_cost_in_cents(), 18);
|
||||
assert_eq!(make_params(Four, Some(High), Some(Square), None).estimate_cost_in_cents(), 24);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_params(
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5TextToImageParams {
|
||||
GptImage2p5TextToImageParams {
|
||||
prompt: "test".to_string(),
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Types shared by the four GPT Image 2.5 endpoints (Flare / Sunburst, text-to-image / edit).
|
||||
//!
|
||||
//! All four endpoints accept the same parameters and are priced from the same token table;
|
||||
//! only the Fal endpoint path differs. The per-endpoint request types under `text/` and
|
||||
//! `edit/` are thin wrappers around the params structs defined here.
|
||||
//!
|
||||
//! GPT Image 2.5 shares GPT Image 2's dimension constraints (multiples of 16, max edge 3840,
|
||||
//! aspect <= 3:1, 655,360 <= pixels <= 8,294,400), so the resolution tiers and custom size
|
||||
//! calculator in `gpt_image_2_resolution` are reused as-is.
|
||||
|
||||
pub mod gpt_image_2p5_text_to_image_params;
|
||||
pub mod gpt_image_2p5_edit_image_params;
|
||||
pub mod gpt_image_2p5_image_size;
|
||||
pub mod gpt_image_2p5_quality;
|
||||
pub mod gpt_image_2p5_background;
|
||||
pub mod gpt_image_2p5_output_format;
|
||||
pub mod gpt_image_2p5_num_images;
|
||||
pub mod gpt_image_2p5_pricing;
|
||||
pub mod raw;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
|
||||
/// Wire shape for `openai/gpt-image-2.5/{flare,sunburst}/edit`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GptImage2p5EditImageInput {
|
||||
/// 1 to 32,000 characters.
|
||||
pub prompt: String,
|
||||
|
||||
/// Reference images. At most 16.
|
||||
pub image_urls: Vec<String>,
|
||||
|
||||
/// Mask indicating which part of the image to edit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
/// Default: "auto" (inferred from the input images)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_size: Option<GptImage2p5ImageSizeParam>,
|
||||
|
||||
/// "auto", "transparent", "opaque"
|
||||
/// Default: "auto"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
|
||||
/// "auto", "low", "medium", "high", "xhigh", "max"
|
||||
/// Default: "high"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<String>,
|
||||
|
||||
/// 1 - 10
|
||||
/// Default: 1
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_images: Option<u8>,
|
||||
|
||||
/// "jpeg", "png", "webp"
|
||||
/// Default: "png"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format: Option<String>,
|
||||
|
||||
/// 0 - 100. Only honoured for "jpeg" and "webp".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_compression: Option<u8>,
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Response shape shared by all four GPT Image 2.5 endpoints.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GptImage2p5ImageOutput {
|
||||
pub images: Vec<GptImage2p5ImageFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GptImage2p5ImageFile {
|
||||
pub url: String,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file_name: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file_size: Option<u64>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<u32>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserializes_fal_example_response() {
|
||||
let json = r#"{"images":[{"file_name":"EnWrO3XWjPE0nxBDpaQrj.png","width":1024,"height":1024,"content_type":"image/png","url":"https://v3b.fal.media/files/b/0a869129/EnWrO3XWjPE0nxBDpaQrj.png"}]}"#;
|
||||
let output: GptImage2p5ImageOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(output.images.len(), 1);
|
||||
assert_eq!(output.images[0].url, "https://v3b.fal.media/files/b/0a869129/EnWrO3XWjPE0nxBDpaQrj.png");
|
||||
assert_eq!(output.images[0].width, Some(1024));
|
||||
assert_eq!(output.images[0].content_type.as_deref(), Some("image/png"));
|
||||
assert_eq!(output.images[0].file_size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_url_only_file() {
|
||||
let json = r#"{"images":[{"url":"https://example.com/a.png"}]}"#;
|
||||
let output: GptImage2p5ImageOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(output.images[0].url, "https://example.com/a.png");
|
||||
assert_eq!(output.images[0].width, None);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::CustomImageSize;
|
||||
|
||||
/// The `image_size` field is either a preset name (e.g. `"square"`, `"auto"`) or an explicit
|
||||
/// `{ width, height }` object.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum GptImage2p5ImageSizeParam {
|
||||
Preset(String),
|
||||
Custom(CustomImageSize),
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
|
||||
/// Wire shape for `openai/gpt-image-2.5/{flare,sunburst}/text-to-image`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GptImage2p5TextToImageInput {
|
||||
/// 1 to 32,000 characters.
|
||||
pub prompt: String,
|
||||
|
||||
/// square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9, auto
|
||||
/// OR a custom { width, height } object.
|
||||
/// Default: "landscape_4_3"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_size: Option<GptImage2p5ImageSizeParam>,
|
||||
|
||||
/// "auto", "transparent", "opaque"
|
||||
/// Default: "auto"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
|
||||
/// "auto", "low", "medium", "high", "xhigh", "max"
|
||||
/// Default: "high"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<String>,
|
||||
|
||||
/// 1 - 10
|
||||
/// Default: 1
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_images: Option<u8>,
|
||||
|
||||
/// "jpeg", "png", "webp"
|
||||
/// Default: "png"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_format: Option<String>,
|
||||
|
||||
/// 0 - 100. Only honoured for "jpeg" and "webp".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_compression: Option<u8>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Over-the-wire request and response shapes for the GPT Image 2.5 endpoints.
|
||||
|
||||
pub mod gpt_image_2p5_text_to_image_input;
|
||||
pub mod gpt_image_2p5_edit_image_input;
|
||||
pub mod gpt_image_2p5_image_output;
|
||||
pub mod gpt_image_2p5_image_size_param;
|
||||
@@ -1 +1,2 @@
|
||||
pub mod gpt_image_2_resolution;
|
||||
pub mod gpt_image_2p5;
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// GPT Image 2.5 Flare image edit.
|
||||
///
|
||||
/// Flare and Sunburst take the same parameters (see `GptImage2p5EditImageParams`);
|
||||
/// this type only selects the endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5FlareEditImageRequest {
|
||||
pub params: GptImage2p5EditImageParams,
|
||||
}
|
||||
|
||||
impl FalEndpoint for GptImage2p5FlareEditImageRequest {
|
||||
const ENDPOINT: &str = "openai/gpt-image-2.5/flare/edit";
|
||||
|
||||
type RawRequest = GptImage2p5EditImageInput;
|
||||
type RawResponse = GptImage2p5ImageOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(self.params.to_raw_input())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL};
|
||||
|
||||
#[test]
|
||||
fn endpoint() {
|
||||
assert_eq!(GptImage2p5FlareEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/edit");
|
||||
assert_eq!(GptImage2p5FlareEditImageRequest::get_endpoint(), "openai/gpt-image-2.5/flare/edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_raw_request_delegates_to_params() {
|
||||
let request = GptImage2p5FlareEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "test".to_string(),
|
||||
image_urls: vec!["https://example.com/image.png".to_string()],
|
||||
mask_url: Some("https://example.com/mask.png".to_string()),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Square),
|
||||
resolution: Some(GptImage2Resolution::TwoK),
|
||||
quality: Some(GptImage2p5Quality::Low),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "test");
|
||||
assert_eq!(raw.image_urls, vec!["https://example.com/image.png".to_string()]);
|
||||
assert_eq!(raw.mask_url.as_deref(), Some("https://example.com/mask.png"));
|
||||
assert_eq!(raw.num_images, Some(2));
|
||||
assert_eq!(raw.quality.as_deref(), Some("low"));
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 2048)),
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_edit_image_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5FlareEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(),
|
||||
image_urls: vec![
|
||||
GHOST_IMAGE_URL.to_string(),
|
||||
TREX_SKELETON_IMAGE_URL.to_string(),
|
||||
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
],
|
||||
mask_url: None,
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_edit_image_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5FlareEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "make the ghost wear a top hat".to_string(),
|
||||
image_urls: vec![GHOST_IMAGE_URL.to_string()],
|
||||
mask_url: None,
|
||||
num_images: GptImage2p5NumImages::One,
|
||||
image_size: Some(GptImage2p5ImageSize::Square),
|
||||
resolution: None,
|
||||
quality: Some(GptImage2p5Quality::High),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(
|
||||
&api_key,
|
||||
"https://example.com/webhook",
|
||||
).await?;
|
||||
println!("Request ID: {:?}", result.request_id);
|
||||
assert!(result.request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
use crate::requests::api::image::edit::gpt_image_2p5_flare_edit_image::api::GptImage2p5FlareEditImageRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for GptImage2p5FlareEditImageRequest {
|
||||
/// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model.
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
self.params.estimate_cost_in_cents()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
// (num_reference_images, size, quality, num_images, expected_cents)
|
||||
const PRESET_CASES: &[(usize, GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(1, Square, Low, One, 2),
|
||||
(1, Square, High, One, 7),
|
||||
(1, Square, High, Four, 28),
|
||||
(1, Square, Max, One, 22),
|
||||
(1, Landscape4x3, High, One, 5),
|
||||
(1, Landscape16x9, High, One, 5),
|
||||
(3, Landscape16x9, High, One, 7),
|
||||
(1, SquareHd, High, One, 10),
|
||||
(1, Auto, High, One, 11),
|
||||
(4, Auto, XHigh, Two, 42),
|
||||
];
|
||||
|
||||
// (num_reference_images, size, resolution, quality, num_images, expected_cents)
|
||||
const RESOLUTION_CASES: &[(usize, GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(1, Square, OneK, High, One, 7),
|
||||
(1, Square, TwoK, High, One, 10),
|
||||
(1, Square, FourK, High, One, 14),
|
||||
(1, Landscape16x9, OneK, High, One, 4),
|
||||
(1, Landscape16x9, FourK, High, One, 11),
|
||||
(2, Portrait4x3, ThreeK, Medium, Three, 15),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_auto_size() {
|
||||
assert_eq!(make_request(1, One, None, None, None).calculate_cost_in_cents(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_costs() {
|
||||
for &(refs, size, quality, num, expected) in PRESET_CASES {
|
||||
let actual = make_request(refs, num, Some(quality), Some(size), None).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{refs} refs {size:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_costs() {
|
||||
for &(refs, size, res, quality, num, expected) in RESOLUTION_CASES {
|
||||
let actual = make_request(refs, num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{refs} refs {size:?} {res:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_params_estimate() {
|
||||
let request = make_request(2, Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK));
|
||||
assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents());
|
||||
}
|
||||
|
||||
fn make_request(
|
||||
num_reference_images: usize,
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5FlareEditImageRequest {
|
||||
GptImage2p5FlareEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "test".to_string(),
|
||||
image_urls: vec!["https://example.com/image.png".to_string(); num_reference_images],
|
||||
mask_url: None,
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
mod cost;
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_edit_image_input::GptImage2p5EditImageInput;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// GPT Image 2.5 Sunburst image edit.
|
||||
///
|
||||
/// Flare and Sunburst take the same parameters (see `GptImage2p5EditImageParams`);
|
||||
/// this type only selects the endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5SunburstEditImageRequest {
|
||||
pub params: GptImage2p5EditImageParams,
|
||||
}
|
||||
|
||||
impl FalEndpoint for GptImage2p5SunburstEditImageRequest {
|
||||
const ENDPOINT: &str = "openai/gpt-image-2.5/sunburst/edit";
|
||||
|
||||
type RawRequest = GptImage2p5EditImageInput;
|
||||
type RawResponse = GptImage2p5ImageOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(self.params.to_raw_input())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL};
|
||||
|
||||
#[test]
|
||||
fn endpoint() {
|
||||
assert_eq!(GptImage2p5SunburstEditImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/edit");
|
||||
assert_eq!(GptImage2p5SunburstEditImageRequest::get_endpoint(), "openai/gpt-image-2.5/sunburst/edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_raw_request_delegates_to_params() {
|
||||
let request = GptImage2p5SunburstEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "test".to_string(),
|
||||
image_urls: vec!["https://example.com/image.png".to_string()],
|
||||
mask_url: Some("https://example.com/mask.png".to_string()),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Square),
|
||||
resolution: Some(GptImage2Resolution::TwoK),
|
||||
quality: Some(GptImage2p5Quality::Low),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "test");
|
||||
assert_eq!(raw.image_urls, vec!["https://example.com/image.png".to_string()]);
|
||||
assert_eq!(raw.mask_url.as_deref(), Some("https://example.com/mask.png"));
|
||||
assert_eq!(raw.num_images, Some(2));
|
||||
assert_eq!(raw.quality.as_deref(), Some("low"));
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 2048)),
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_edit_image_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5SunburstEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly".to_string(),
|
||||
image_urls: vec![
|
||||
GHOST_IMAGE_URL.to_string(),
|
||||
TREX_SKELETON_IMAGE_URL.to_string(),
|
||||
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
|
||||
],
|
||||
mask_url: None,
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_edit_image_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5SunburstEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "make the ghost wear a top hat".to_string(),
|
||||
image_urls: vec![GHOST_IMAGE_URL.to_string()],
|
||||
mask_url: None,
|
||||
num_images: GptImage2p5NumImages::One,
|
||||
image_size: Some(GptImage2p5ImageSize::Square),
|
||||
resolution: None,
|
||||
quality: Some(GptImage2p5Quality::High),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(
|
||||
&api_key,
|
||||
"https://example.com/webhook",
|
||||
).await?;
|
||||
println!("Request ID: {:?}", result.request_id);
|
||||
assert!(result.request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
use crate::requests::api::image::edit::gpt_image_2p5_sunburst_edit_image::api::GptImage2p5SunburstEditImageRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for GptImage2p5SunburstEditImageRequest {
|
||||
/// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model.
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
self.params.estimate_cost_in_cents()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_edit_image_params::GptImage2p5EditImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
// (num_reference_images, size, quality, num_images, expected_cents)
|
||||
const PRESET_CASES: &[(usize, GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(1, Square, Low, One, 2),
|
||||
(1, Square, High, One, 7),
|
||||
(1, Square, High, Four, 28),
|
||||
(1, Square, Max, One, 22),
|
||||
(1, Landscape4x3, High, One, 5),
|
||||
(1, Landscape16x9, High, One, 5),
|
||||
(3, Landscape16x9, High, One, 7),
|
||||
(1, SquareHd, High, One, 10),
|
||||
(1, Auto, High, One, 11),
|
||||
(4, Auto, XHigh, Two, 42),
|
||||
];
|
||||
|
||||
// (num_reference_images, size, resolution, quality, num_images, expected_cents)
|
||||
const RESOLUTION_CASES: &[(usize, GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(1, Square, OneK, High, One, 7),
|
||||
(1, Square, TwoK, High, One, 10),
|
||||
(1, Square, FourK, High, One, 14),
|
||||
(1, Landscape16x9, OneK, High, One, 4),
|
||||
(1, Landscape16x9, FourK, High, One, 11),
|
||||
(2, Portrait4x3, ThreeK, Medium, Three, 15),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_auto_size() {
|
||||
assert_eq!(make_request(1, One, None, None, None).calculate_cost_in_cents(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_costs() {
|
||||
for &(refs, size, quality, num, expected) in PRESET_CASES {
|
||||
let actual = make_request(refs, num, Some(quality), Some(size), None).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{refs} refs {size:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_costs() {
|
||||
for &(refs, size, res, quality, num, expected) in RESOLUTION_CASES {
|
||||
let actual = make_request(refs, num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{refs} refs {size:?} {res:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_params_estimate() {
|
||||
let request = make_request(2, Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK));
|
||||
assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents());
|
||||
}
|
||||
|
||||
fn make_request(
|
||||
num_reference_images: usize,
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5SunburstEditImageRequest {
|
||||
GptImage2p5SunburstEditImageRequest {
|
||||
params: GptImage2p5EditImageParams {
|
||||
prompt: "test".to_string(),
|
||||
image_urls: vec!["https://example.com/image.png".to_string(); num_reference_images],
|
||||
mask_url: None,
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
mod cost;
|
||||
@@ -3,5 +3,7 @@ pub mod flux_1_schnell_edit_image;
|
||||
pub mod gpt_image_1_edit_image;
|
||||
pub mod gpt_image_1p5_edit_image;
|
||||
pub mod gpt_image_2_edit_image;
|
||||
pub mod gpt_image_2p5_flare_edit_image;
|
||||
pub mod gpt_image_2p5_sunburst_edit_image;
|
||||
pub mod nano_banana_2_edit_image;
|
||||
pub mod nano_banana_pro_edit_image;
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// GPT Image 2.5 Flare text-to-image.
|
||||
///
|
||||
/// Flare and Sunburst take the same parameters (see `GptImage2p5TextToImageParams`);
|
||||
/// this type only selects the endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5FlareTextToImageRequest {
|
||||
pub params: GptImage2p5TextToImageParams,
|
||||
}
|
||||
|
||||
impl FalEndpoint for GptImage2p5FlareTextToImageRequest {
|
||||
const ENDPOINT: &str = "openai/gpt-image-2.5/flare/text-to-image";
|
||||
|
||||
type RawRequest = GptImage2p5TextToImageInput;
|
||||
type RawResponse = GptImage2p5ImageOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(self.params.to_raw_input())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
#[test]
|
||||
fn endpoint() {
|
||||
assert_eq!(GptImage2p5FlareTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/flare/text-to-image");
|
||||
assert_eq!(GptImage2p5FlareTextToImageRequest::get_endpoint(), "openai/gpt-image-2.5/flare/text-to-image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_raw_request_delegates_to_params() {
|
||||
let request = GptImage2p5FlareTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "test".to_string(),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Landscape16x9),
|
||||
resolution: Some(GptImage2Resolution::TwoK),
|
||||
quality: Some(GptImage2p5Quality::Medium),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Jpeg),
|
||||
output_compression: Some(75),
|
||||
},
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "test");
|
||||
assert_eq!(raw.num_images, Some(2));
|
||||
assert_eq!(raw.quality.as_deref(), Some("medium"));
|
||||
assert_eq!(raw.output_format.as_deref(), Some("jpeg"));
|
||||
assert_eq!(raw.output_compression, Some(75));
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 1152)),
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_text_to_image_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5FlareTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "an anime girl riding on the back of a t-rex".to_string(),
|
||||
num_images: GptImage2p5NumImages::One,
|
||||
image_size: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_text_to_image_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5FlareTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "a corgi wearing sunglasses at the beach".to_string(),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Landscape16x9),
|
||||
resolution: None,
|
||||
quality: Some(GptImage2p5Quality::High),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(
|
||||
&api_key,
|
||||
"https://example.com/webhook",
|
||||
).await?;
|
||||
println!("Request ID: {:?}", result.request_id);
|
||||
assert!(result.request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
use crate::requests::api::image::text::gpt_image_2p5_flare_text_to_image::api::GptImage2p5FlareTextToImageRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for GptImage2p5FlareTextToImageRequest {
|
||||
/// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model.
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
self.params.estimate_cost_in_cents()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
// (size, quality, num_images, expected_cents)
|
||||
const PRESET_CASES: &[(GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(Square, Low, One, 1),
|
||||
(Square, High, One, 6),
|
||||
(Square, High, Four, 24),
|
||||
(Square, Max, One, 22),
|
||||
(Landscape4x3, High, One, 4),
|
||||
(Landscape16x9, High, One, 4),
|
||||
(Landscape16x9, XHigh, Two, 16),
|
||||
(SquareHd, High, One, 9),
|
||||
(Auto, High, One, 11),
|
||||
];
|
||||
|
||||
// (size, resolution, quality, num_images, expected_cents)
|
||||
const RESOLUTION_CASES: &[(GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(Square, OneK, High, One, 6),
|
||||
(Square, TwoK, High, One, 9),
|
||||
(Square, FourK, High, One, 13),
|
||||
(Landscape16x9, OneK, High, One, 3),
|
||||
(Landscape16x9, FourK, High, One, 11),
|
||||
(Portrait4x3, ThreeK, Medium, Three, 9),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_landscape_4x3() {
|
||||
assert_eq!(make_request(One, None, None, None).calculate_cost_in_cents(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_costs() {
|
||||
for &(size, quality, num, expected) in PRESET_CASES {
|
||||
let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_costs() {
|
||||
for &(size, res, quality, num, expected) in RESOLUTION_CASES {
|
||||
let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_params_estimate() {
|
||||
let request = make_request(Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK));
|
||||
assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents());
|
||||
}
|
||||
|
||||
fn make_request(
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5FlareTextToImageRequest {
|
||||
GptImage2p5FlareTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "test".to_string(),
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
mod cost;
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use crate::error::fal_error_plus::FalErrorPlus;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_output::GptImage2p5ImageOutput;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_text_to_image_input::GptImage2p5TextToImageInput;
|
||||
use crate::requests::traits::fal_endpoint_trait::FalEndpoint;
|
||||
|
||||
/// GPT Image 2.5 Sunburst text-to-image.
|
||||
///
|
||||
/// Flare and Sunburst take the same parameters (see `GptImage2p5TextToImageParams`);
|
||||
/// this type only selects the endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GptImage2p5SunburstTextToImageRequest {
|
||||
pub params: GptImage2p5TextToImageParams,
|
||||
}
|
||||
|
||||
impl FalEndpoint for GptImage2p5SunburstTextToImageRequest {
|
||||
const ENDPOINT: &str = "openai/gpt-image-2.5/sunburst/text-to-image";
|
||||
|
||||
type RawRequest = GptImage2p5TextToImageInput;
|
||||
type RawResponse = GptImage2p5ImageOutput;
|
||||
|
||||
fn to_raw_request(&self) -> Result<Self::RawRequest, FalErrorPlus> {
|
||||
Ok(self.params.to_raw_input())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_output_format::GptImage2p5OutputFormat;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::raw::gpt_image_2p5_image_size_param::GptImage2p5ImageSizeParam;
|
||||
use errors::AnyhowResult;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
#[test]
|
||||
fn endpoint() {
|
||||
assert_eq!(GptImage2p5SunburstTextToImageRequest::ENDPOINT, "openai/gpt-image-2.5/sunburst/text-to-image");
|
||||
assert_eq!(GptImage2p5SunburstTextToImageRequest::get_endpoint(), "openai/gpt-image-2.5/sunburst/text-to-image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_raw_request_delegates_to_params() {
|
||||
let request = GptImage2p5SunburstTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "test".to_string(),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Landscape16x9),
|
||||
resolution: Some(GptImage2Resolution::TwoK),
|
||||
quality: Some(GptImage2p5Quality::Medium),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Jpeg),
|
||||
output_compression: Some(75),
|
||||
},
|
||||
};
|
||||
let raw = request.to_raw_request().unwrap();
|
||||
assert_eq!(raw.prompt, "test");
|
||||
assert_eq!(raw.num_images, Some(2));
|
||||
assert_eq!(raw.quality.as_deref(), Some("medium"));
|
||||
assert_eq!(raw.output_format.as_deref(), Some("jpeg"));
|
||||
assert_eq!(raw.output_compression, Some(75));
|
||||
match raw.image_size.unwrap() {
|
||||
GptImage2p5ImageSizeParam::Custom(c) => assert_eq!((c.width, c.height), (2048, 1152)),
|
||||
GptImage2p5ImageSizeParam::Preset(_) => panic!("expected custom"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_text_to_image_queue() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5SunburstTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "an anime girl riding on the back of a t-rex".to_string(),
|
||||
num_images: GptImage2p5NumImages::One,
|
||||
image_size: None,
|
||||
resolution: None,
|
||||
quality: None,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_queue_request(&api_key).await?;
|
||||
println!("Request ID: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key, incurs costs
|
||||
async fn test_text_to_image_webhook() -> AnyhowResult<()> {
|
||||
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
|
||||
let api_key = FalApiKey::from_str(&secret);
|
||||
|
||||
let request = GptImage2p5SunburstTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "a corgi wearing sunglasses at the beach".to_string(),
|
||||
num_images: GptImage2p5NumImages::Two,
|
||||
image_size: Some(GptImage2p5ImageSize::Landscape16x9),
|
||||
resolution: None,
|
||||
quality: Some(GptImage2p5Quality::High),
|
||||
background: None,
|
||||
output_format: Some(GptImage2p5OutputFormat::Png),
|
||||
output_compression: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result = request.send_webhook_request(
|
||||
&api_key,
|
||||
"https://example.com/webhook",
|
||||
).await?;
|
||||
println!("Request ID: {:?}", result.request_id);
|
||||
assert!(result.request_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NB: Pricing tests are in cost.rs
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
use crate::requests::api::image::text::gpt_image_2p5_sunburst_text_to_image::api::GptImage2p5SunburstTextToImageRequest;
|
||||
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
|
||||
|
||||
impl FalRequestCostCalculator for GptImage2p5SunburstTextToImageRequest {
|
||||
/// Flare and Sunburst are priced identically; see `gpt_image_2p5_pricing` for the model.
|
||||
fn calculate_cost_in_cents(&self) -> UsdCents {
|
||||
self.params.estimate_cost_in_cents()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_image_size::GptImage2p5ImageSize;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_num_images::GptImage2p5NumImages;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_quality::GptImage2p5Quality;
|
||||
use crate::requests::api::image::common::gpt_image_2p5::gpt_image_2p5_text_to_image_params::GptImage2p5TextToImageParams;
|
||||
use GptImage2Resolution::*;
|
||||
use GptImage2p5ImageSize::*;
|
||||
use GptImage2p5NumImages::*;
|
||||
use GptImage2p5Quality::{Low, Medium, High, XHigh, Max};
|
||||
|
||||
// (size, quality, num_images, expected_cents)
|
||||
const PRESET_CASES: &[(GptImage2p5ImageSize, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(Square, Low, One, 1),
|
||||
(Square, High, One, 6),
|
||||
(Square, High, Four, 24),
|
||||
(Square, Max, One, 22),
|
||||
(Landscape4x3, High, One, 4),
|
||||
(Landscape16x9, High, One, 4),
|
||||
(Landscape16x9, XHigh, Two, 16),
|
||||
(SquareHd, High, One, 9),
|
||||
(Auto, High, One, 11),
|
||||
];
|
||||
|
||||
// (size, resolution, quality, num_images, expected_cents)
|
||||
const RESOLUTION_CASES: &[(GptImage2p5ImageSize, GptImage2Resolution, GptImage2p5Quality, GptImage2p5NumImages, u64)] = &[
|
||||
(Square, OneK, High, One, 6),
|
||||
(Square, TwoK, High, One, 9),
|
||||
(Square, FourK, High, One, 13),
|
||||
(Landscape16x9, OneK, High, One, 3),
|
||||
(Landscape16x9, FourK, High, One, 11),
|
||||
(Portrait4x3, ThreeK, Medium, Three, 9),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn defaults_to_high_quality_landscape_4x3() {
|
||||
assert_eq!(make_request(One, None, None, None).calculate_cost_in_cents(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_costs() {
|
||||
for &(size, quality, num, expected) in PRESET_CASES {
|
||||
let actual = make_request(num, Some(quality), Some(size), None).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_costs() {
|
||||
for &(size, res, quality, num, expected) in RESOLUTION_CASES {
|
||||
let actual = make_request(num, Some(quality), Some(size), Some(res)).calculate_cost_in_cents();
|
||||
assert_eq!(actual, expected, "{size:?} {res:?} {quality:?} {num:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_shared_params_estimate() {
|
||||
let request = make_request(Three, Some(XHigh), Some(Portrait16x9), Some(ThreeK));
|
||||
assert_eq!(request.calculate_cost_in_cents(), request.params.estimate_cost_in_cents());
|
||||
}
|
||||
|
||||
fn make_request(
|
||||
num_images: GptImage2p5NumImages,
|
||||
quality: Option<GptImage2p5Quality>,
|
||||
image_size: Option<GptImage2p5ImageSize>,
|
||||
resolution: Option<GptImage2Resolution>,
|
||||
) -> GptImage2p5SunburstTextToImageRequest {
|
||||
GptImage2p5SunburstTextToImageRequest {
|
||||
params: GptImage2p5TextToImageParams {
|
||||
prompt: "test".to_string(),
|
||||
num_images,
|
||||
image_size,
|
||||
resolution,
|
||||
quality,
|
||||
background: None,
|
||||
output_format: None,
|
||||
output_compression: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
mod cost;
|
||||
@@ -3,5 +3,7 @@ pub mod flux_1_schnell_text_to_image;
|
||||
pub mod gpt_image_1_text_to_image;
|
||||
pub mod gpt_image_1p5_text_to_image;
|
||||
pub mod gpt_image_2_text_to_image;
|
||||
pub mod gpt_image_2p5_flare_text_to_image;
|
||||
pub mod gpt_image_2p5_sunburst_text_to_image;
|
||||
pub mod nano_banana_2_text_to_image;
|
||||
pub mod nano_banana_pro_text_to_image;
|
||||
Reference in new issue
Block a user