Skip to main content

imagegen_bridge_core/
parameters.rs

1//! Common image-generation parameters and their wire representation.
2
3use std::{fmt, str::FromStr};
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::{BridgeError, ErrorCode};
9
10macro_rules! string_enum {
11    ($(#[$meta:meta])* $visibility:vis enum $name:ident {
12        $($(#[$variant_meta:meta])* $variant:ident => $wire:literal),+ $(,)?
13    }) => {
14        $(#[$meta])*
15        #[derive(
16            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
17            Serialize, Deserialize, JsonSchema,
18        )]
19        #[serde(rename_all = "snake_case")]
20        $visibility enum $name {
21            $(
22                $(#[$variant_meta])*
23                #[doc = concat!("Wire value `", $wire, "`.")]
24                #[serde(rename = $wire)]
25                $variant
26            ),+
27        }
28
29        impl fmt::Display for $name {
30            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31                formatter.write_str(match self { $(Self::$variant => $wire),+ })
32            }
33        }
34    };
35}
36
37string_enum! {
38    /// Requested generation quality.
39    pub enum Quality {
40        Auto => "auto",
41        Low => "low",
42        Medium => "medium",
43        High => "high",
44    }
45}
46
47impl Default for Quality {
48    fn default() -> Self {
49        Self::Auto
50    }
51}
52
53string_enum! {
54    /// Encoded output image format.
55    pub enum OutputFormat {
56        Png => "png",
57        Jpeg => "jpeg",
58        Webp => "webp",
59    }
60}
61
62impl Default for OutputFormat {
63    fn default() -> Self {
64        Self::Png
65    }
66}
67
68string_enum! {
69    /// Requested background behavior.
70    pub enum Background {
71        Auto => "auto",
72        Opaque => "opaque",
73        Transparent => "transparent",
74    }
75}
76
77string_enum! {
78    /// How a transparent-background result is produced.
79    pub enum TransparencyMode {
80        /// Prefer native alpha and otherwise use a local chroma-key matte.
81        Auto => "auto",
82        /// Require provider-native alpha output.
83        Native => "native",
84        /// Generate a flat key background and remove it locally.
85        ChromaKey => "chroma_key",
86    }
87}
88
89impl Default for TransparencyMode {
90    fn default() -> Self {
91        Self::Auto
92    }
93}
94
95string_enum! {
96    /// Conditions under which an ordered provider fallback may run.
97    pub enum FallbackPolicy {
98        /// Fall back only for unavailable, unsupported, or pre-output failures.
99        OnUnavailable => "on_unavailable",
100        /// Also fall back for known-outcome provider failures, excluding safety and cancellation.
101        OnError => "on_error",
102    }
103}
104
105impl Default for FallbackPolicy {
106    fn default() -> Self {
107        Self::OnUnavailable
108    }
109}
110
111string_enum! {
112    /// Preferred execution shape for bridge-emulated multi-image fan-out.
113    pub enum BatchExecution {
114        /// Use provider/session-aware bridge defaults.
115        Auto => "auto",
116        /// Dispatch one fan-out chunk at a time.
117        Sequential => "sequential",
118        /// Use the provider's configured bounded parallelism.
119        Parallel => "parallel",
120    }
121}
122
123impl Default for BatchExecution {
124    fn default() -> Self {
125        Self::Auto
126    }
127}
128
129impl Default for Background {
130    fn default() -> Self {
131        Self::Auto
132    }
133}
134
135string_enum! {
136    /// Provider moderation strictness where configurable.
137    pub enum Moderation {
138        Auto => "auto",
139        Low => "low",
140    }
141}
142
143impl Default for Moderation {
144    fn default() -> Self {
145        Self::Auto
146    }
147}
148
149string_enum! {
150    /// Behavior when one output in a multi-image request fails.
151    pub enum MultiImageFailurePolicy {
152        FailFast => "fail_fast",
153        BestEffort => "best_effort",
154    }
155}
156
157impl Default for MultiImageFailurePolicy {
158    fn default() -> Self {
159        Self::FailFast
160    }
161}
162
163string_enum! {
164    /// Fidelity used when processing image inputs.
165    pub enum InputFidelity {
166        Low => "low",
167        High => "high",
168    }
169}
170
171string_enum! {
172    /// Image-generation tool action for conversational transports.
173    pub enum ImageAction {
174        Auto => "auto",
175        Generate => "generate",
176        Edit => "edit",
177    }
178}
179
180impl Default for ImageAction {
181    fn default() -> Self {
182        Self::Auto
183    }
184}
185
186string_enum! {
187    /// Desired output payload representation.
188    pub enum ResponseFormat {
189        B64Json => "b64_json",
190        Url => "url",
191        Artifact => "artifact",
192        Metadata => "metadata",
193    }
194}
195
196impl Default for ResponseFormat {
197    fn default() -> Self {
198        Self::B64Json
199    }
200}
201
202string_enum! {
203    /// Atomic behavior when an explicit artifact filename already exists.
204    pub enum ArtifactCollisionPolicy {
205        Error => "error",
206        Suffix => "suffix",
207    }
208}
209
210impl Default for ArtifactCollisionPolicy {
211    fn default() -> Self {
212        Self::Error
213    }
214}
215
216string_enum! {
217    /// Optional portable generation-metadata persistence policy.
218    pub enum ArtifactMetadataPolicy {
219        None => "none",
220        Sidecar => "sidecar",
221        Embedded => "embedded",
222        SidecarAndEmbedded => "sidecar_and_embedded",
223    }
224}
225
226impl Default for ArtifactMetadataPolicy {
227    fn default() -> Self {
228        Self::None
229    }
230}
231
232impl ArtifactMetadataPolicy {
233    /// Whether the policy writes a JSON file beside each bridge-owned artifact.
234    #[must_use]
235    pub const fn writes_sidecar(self) -> bool {
236        matches!(self, Self::Sidecar | Self::SidecarAndEmbedded)
237    }
238
239    /// Whether the policy embeds a bounded XMP generation record in image bytes.
240    #[must_use]
241    pub const fn embeds(self) -> bool {
242        matches!(self, Self::Embedded | Self::SidecarAndEmbedded)
243    }
244}
245
246string_enum! {
247    /// Compatibility policy used during provider negotiation.
248    pub enum CompatibilityMode {
249        Strict => "strict",
250        Normalize => "normalize",
251        BestEffort => "best_effort",
252    }
253}
254
255impl Default for CompatibilityMode {
256    fn default() -> Self {
257        Self::Strict
258    }
259}
260
261string_enum! {
262    /// Handling policy for a negative prompt.
263    pub enum NegativePromptMode {
264        Auto => "auto",
265        Native => "native",
266        Merge => "merge",
267        Reject => "reject",
268    }
269}
270
271impl Default for NegativePromptMode {
272    fn default() -> Self {
273        Self::Auto
274    }
275}
276
277string_enum! {
278    /// Visibility and requirement policy for an upstream revised prompt.
279    pub enum RevisedPromptPolicy {
280        Include => "include",
281        Omit => "omit",
282        Require => "require",
283    }
284}
285
286impl Default for RevisedPromptPolicy {
287    fn default() -> Self {
288        Self::Include
289    }
290}
291
292string_enum! {
293    /// Codex conversation behavior for a request.
294    pub enum SessionMode {
295        Isolated => "isolated",
296        Persistent => "persistent",
297        Thread => "thread",
298    }
299}
300
301impl Default for SessionMode {
302    fn default() -> Self {
303        Self::Isolated
304    }
305}
306
307string_enum! {
308    /// Coarse output resolution hint.
309    pub enum Resolution {
310        OneK => "1k",
311        TwoK => "2k",
312        FourK => "4k",
313    }
314}
315
316/// Image size represented as `auto` or `WIDTHxHEIGHT`.
317#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
318pub struct ImageSize(String);
319
320impl ImageSize {
321    /// Automatic provider-selected size.
322    pub const AUTO: &'static str = "auto";
323
324    /// Constructs a validated explicit size.
325    pub fn exact(width: u32, height: u32) -> Result<Self, BridgeError> {
326        if width == 0 || height == 0 {
327            return Err(BridgeError::new(
328                ErrorCode::InvalidRequest,
329                "image dimensions must be greater than zero",
330            ));
331        }
332        Ok(Self(format!("{width}x{height}")))
333    }
334
335    /// Returns `None` for `auto`, otherwise the explicit dimensions.
336    #[must_use]
337    pub fn dimensions(&self) -> Option<(u32, u32)> {
338        if self.0 == Self::AUTO {
339            return None;
340        }
341        let (width, height) = self.0.split_once('x')?;
342        Some((width.parse().ok()?, height.parse().ok()?))
343    }
344
345    /// Returns true when the provider should choose the size.
346    #[must_use]
347    pub fn is_auto(&self) -> bool {
348        self.0 == Self::AUTO
349    }
350
351    /// Returns the stable wire value.
352    #[must_use]
353    pub fn as_str(&self) -> &str {
354        &self.0
355    }
356}
357
358impl Default for ImageSize {
359    fn default() -> Self {
360        Self(Self::AUTO.to_owned())
361    }
362}
363
364impl fmt::Display for ImageSize {
365    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
366        formatter.write_str(&self.0)
367    }
368}
369
370impl FromStr for ImageSize {
371    type Err = BridgeError;
372
373    fn from_str(value: &str) -> Result<Self, Self::Err> {
374        if value == Self::AUTO {
375            return Ok(Self::default());
376        }
377        let (width, height) = value.split_once('x').ok_or_else(|| {
378            BridgeError::new(
379                ErrorCode::InvalidRequest,
380                "size must be `auto` or `WIDTHxHEIGHT`",
381            )
382        })?;
383        if width.is_empty()
384            || height.is_empty()
385            || (width.len() > 1 && width.starts_with('0'))
386            || (height.len() > 1 && height.starts_with('0'))
387            || !width.bytes().all(|byte| byte.is_ascii_digit())
388            || !height.bytes().all(|byte| byte.is_ascii_digit())
389        {
390            return Err(BridgeError::new(
391                ErrorCode::InvalidRequest,
392                "size must be `auto` or `WIDTHxHEIGHT`",
393            ));
394        }
395        Self::exact(
396            width.parse().map_err(|_| {
397                BridgeError::new(ErrorCode::InvalidRequest, "image width is out of range")
398            })?,
399            height.parse().map_err(|_| {
400                BridgeError::new(ErrorCode::InvalidRequest, "image height is out of range")
401            })?,
402        )
403    }
404}
405
406impl Serialize for ImageSize {
407    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
408    where
409        S: Serializer,
410    {
411        serializer.serialize_str(&self.0)
412    }
413}
414
415impl<'de> Deserialize<'de> for ImageSize {
416    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
417    where
418        D: Deserializer<'de>,
419    {
420        let value = String::deserialize(deserializer)?;
421        value.parse().map_err(de::Error::custom)
422    }
423}
424
425impl JsonSchema for ImageSize {
426    fn schema_name() -> std::borrow::Cow<'static, str> {
427        "ImageSize".into()
428    }
429
430    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
431        json_schema!({
432            "type": "string",
433            "pattern": "^(auto|[1-9][0-9]*x[1-9][0-9]*)$",
434            "examples": ["auto", "1024x1024", "1536x1024"]
435        })
436    }
437}
438
439/// Aspect ratio represented as `WIDTH:HEIGHT` with non-zero integer terms.
440#[derive(Debug, Clone, PartialEq, Eq, Hash)]
441pub struct AspectRatio(String);
442
443impl AspectRatio {
444    /// Constructs a reduced aspect ratio.
445    pub fn new(width: u32, height: u32) -> Result<Self, BridgeError> {
446        if width == 0 || height == 0 {
447            return Err(BridgeError::new(
448                ErrorCode::InvalidRequest,
449                "aspect ratio terms must be greater than zero",
450            ));
451        }
452        let divisor = gcd(width, height);
453        Ok(Self(format!("{}:{}", width / divisor, height / divisor)))
454    }
455
456    /// Returns the reduced integer ratio.
457    #[must_use]
458    pub fn terms(&self) -> (u32, u32) {
459        let (width, height) = self.0.split_once(':').unwrap_or(("1", "1"));
460        (width.parse().unwrap_or(1), height.parse().unwrap_or(1))
461    }
462
463    /// Returns the stable wire value.
464    #[must_use]
465    pub fn as_str(&self) -> &str {
466        &self.0
467    }
468}
469
470impl FromStr for AspectRatio {
471    type Err = BridgeError;
472
473    fn from_str(value: &str) -> Result<Self, Self::Err> {
474        let (width, height) = value.split_once(':').ok_or_else(|| {
475            BridgeError::new(
476                ErrorCode::InvalidRequest,
477                "aspect_ratio must use `WIDTH:HEIGHT`",
478            )
479        })?;
480        Self::new(
481            width.parse().map_err(|_| {
482                BridgeError::new(ErrorCode::InvalidRequest, "aspect ratio width is invalid")
483            })?,
484            height.parse().map_err(|_| {
485                BridgeError::new(ErrorCode::InvalidRequest, "aspect ratio height is invalid")
486            })?,
487        )
488    }
489}
490
491impl fmt::Display for AspectRatio {
492    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
493        formatter.write_str(&self.0)
494    }
495}
496
497impl Serialize for AspectRatio {
498    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
499    where
500        S: Serializer,
501    {
502        serializer.serialize_str(&self.0)
503    }
504}
505
506impl<'de> Deserialize<'de> for AspectRatio {
507    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
508    where
509        D: Deserializer<'de>,
510    {
511        String::deserialize(deserializer)?
512            .parse()
513            .map_err(de::Error::custom)
514    }
515}
516
517impl JsonSchema for AspectRatio {
518    fn schema_name() -> std::borrow::Cow<'static, str> {
519        "AspectRatio".into()
520    }
521
522    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
523        json_schema!({
524            "type": "string",
525            "pattern": "^[1-9][0-9]*:[1-9][0-9]*$",
526            "examples": ["1:1", "3:2", "16:9"]
527        })
528    }
529}
530
531const fn gcd(mut left: u32, mut right: u32) -> u32 {
532    while right != 0 {
533        let remainder = left % right;
534        left = right;
535        right = remainder;
536    }
537    left
538}
539
540#[cfg(test)]
541mod tests {
542    #![allow(clippy::unwrap_used)]
543
544    use super::*;
545
546    #[test]
547    fn image_size_round_trips_as_a_string() {
548        let size: ImageSize = "1536x1024".parse().unwrap();
549        assert_eq!(size.dimensions(), Some((1536, 1024)));
550        assert_eq!(serde_json::to_string(&size).unwrap(), "\"1536x1024\"");
551        assert_eq!(
552            serde_json::from_str::<ImageSize>("\"1536x1024\"").unwrap(),
553            size
554        );
555    }
556
557    #[test]
558    fn image_size_rejects_ambiguous_values() {
559        for value in ["", "1024", "0x1024", "1024X1024", "1x2x3", "01x1"] {
560            assert!(value.parse::<ImageSize>().is_err(), "accepted {value}");
561        }
562    }
563
564    #[test]
565    fn aspect_ratio_is_reduced() {
566        let ratio: AspectRatio = "1920:1080".parse().unwrap();
567        assert_eq!(ratio.as_str(), "16:9");
568        assert_eq!(ratio.terms(), (16, 9));
569    }
570}