diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs new file mode 100644 index 0000000000..c064c27426 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_background.rs @@ -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"); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs new file mode 100644 index 0000000000..107a7011cb --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_edit_image_params.rs @@ -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, + + /// Optional mask URL indicating which part of the image to edit. + pub mask_url: Option, + + /// 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, + + /// 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, + + /// Quality tier. `None` lets Fal default to `high`. + pub quality: Option, + + /// Background treatment. `None` lets Fal default to `auto`. + pub background: Option, + + /// Output format. `None` sends `png`. + pub output_format: Option, + + /// Compression level 0 to 100. Only sent for `jpeg` and `webp`. + pub output_compression: Option, +} + +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, [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, + image_size: Option, + resolution: Option, + ) -> 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, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs new file mode 100644 index 0000000000..76f96bc77b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_image_size.rs @@ -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) -> 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) -> (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 { + 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)); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs new file mode 100644 index 0000000000..1b4c1a4156 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_num_images.rs @@ -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); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs new file mode 100644 index 0000000000..86beb9e57d --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_output_format.rs @@ -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()); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs new file mode 100644 index 0000000000..5f46c46c45 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_pricing.rs @@ -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); + } + } +} + diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs new file mode 100644 index 0000000000..dcb4d959a1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_quality.rs @@ -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"); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs new file mode 100644 index 0000000000..981df1802a --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/gpt_image_2p5_text_to_image_params.rs @@ -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, + + /// 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, + + /// Quality tier. `None` lets Fal default to `high`. + pub quality: Option, + + /// Background treatment. `None` lets Fal default to `auto`. + pub background: Option, + + /// Output format. `None` sends `png`. + pub output_format: Option, + + /// Compression level 0 to 100. Only sent for `jpeg` and `webp`. + pub output_compression: Option, +} + +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, [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, + image_size: Option, + resolution: Option, + ) -> GptImage2p5TextToImageParams { + GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs new file mode 100644 index 0000000000..ba1a1f7edd --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs new file mode 100644 index 0000000000..22b69685f1 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_edit_image_input.rs @@ -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, + + /// Mask indicating which part of the image to edit. + #[serde(skip_serializing_if = "Option::is_none")] + pub mask_url: Option, + + /// 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, + + /// "auto", "transparent", "opaque" + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "auto", "low", "medium", "high", "xhigh", "max" + /// Default: "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 10 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// 0 - 100. Only honoured for "jpeg" and "webp". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_compression: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs new file mode 100644 index 0000000000..50723c40ce --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_output.rs @@ -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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GptImage2p5ImageFile { + pub url: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_name: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_size: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, +} + +#[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); + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs new file mode 100644 index 0000000000..d4ac496c27 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_image_size_param.rs @@ -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), +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs new file mode 100644 index 0000000000..8dc1375065 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/gpt_image_2p5_text_to_image_input.rs @@ -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, + + /// "auto", "transparent", "opaque" + /// Default: "auto" + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + + /// "auto", "low", "medium", "high", "xhigh", "max" + /// Default: "high" + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + + /// 1 - 10 + /// Default: 1 + #[serde(skip_serializing_if = "Option::is_none")] + pub num_images: Option, + + /// "jpeg", "png", "webp" + /// Default: "png" + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + + /// 0 - 100. Only honoured for "jpeg" and "webp". + #[serde(skip_serializing_if = "Option::is_none")] + pub output_compression: Option, +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs new file mode 100644 index 0000000000..ad391b11e9 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/common/gpt_image_2p5/raw/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs index b2f0f88d31..aff347168d 100644 --- a/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/image/common/mod.rs @@ -1 +1,2 @@ pub mod gpt_image_2_resolution; +pub mod gpt_image_2p5; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs new file mode 100644 index 0000000000..2ede5dd9c6 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/api.rs @@ -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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs new file mode 100644 index 0000000000..669e3a9c16 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/cost.rs @@ -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, + image_size: Option, + resolution: Option, + ) -> 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, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_flare_edit_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs new file mode 100644 index 0000000000..b4610be636 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/api.rs @@ -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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs new file mode 100644 index 0000000000..9429ab0983 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/cost.rs @@ -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, + image_size: Option, + resolution: Option, + ) -> 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, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/gpt_image_2p5_sunburst_edit_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs index f80cf97491..09b7d8f959 100644 --- a/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/image/edit/mod.rs @@ -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; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs new file mode 100644 index 0000000000..9971daafea --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/api.rs @@ -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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs new file mode 100644 index 0000000000..53b40f8b2b --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/cost.rs @@ -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, + image_size: Option, + resolution: Option, + ) -> GptImage2p5FlareTextToImageRequest { + GptImage2p5FlareTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_flare_text_to_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs new file mode 100644 index 0000000000..6b285850ae --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/api.rs @@ -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 { + 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 +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs new file mode 100644 index 0000000000..eb04a98055 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/cost.rs @@ -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, + image_size: Option, + resolution: Option, + ) -> GptImage2p5SunburstTextToImageRequest { + GptImage2p5SunburstTextToImageRequest { + params: GptImage2p5TextToImageParams { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + background: None, + output_format: None, + output_compression: None, + }, + } + } +} diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs new file mode 100644 index 0000000000..184a91c577 --- /dev/null +++ b/crates/api_clients/fal_client/src/requests/api/image/text/gpt_image_2p5_sunburst_text_to_image/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +mod cost; diff --git a/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs b/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs index 076ab1b390..4fb5e1821b 100644 --- a/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs +++ b/crates/api_clients/fal_client/src/requests/api/image/text/mod.rs @@ -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;