1use 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 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 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 pub enum Background {
71 Auto => "auto",
72 Opaque => "opaque",
73 Transparent => "transparent",
74 }
75}
76
77string_enum! {
78 pub enum TransparencyMode {
80 Auto => "auto",
82 Native => "native",
84 ChromaKey => "chroma_key",
86 }
87}
88
89impl Default for TransparencyMode {
90 fn default() -> Self {
91 Self::Auto
92 }
93}
94
95string_enum! {
96 pub enum FallbackPolicy {
98 OnUnavailable => "on_unavailable",
100 OnError => "on_error",
102 }
103}
104
105impl Default for FallbackPolicy {
106 fn default() -> Self {
107 Self::OnUnavailable
108 }
109}
110
111string_enum! {
112 pub enum BatchExecution {
114 Auto => "auto",
116 Sequential => "sequential",
118 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 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 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 pub enum InputFidelity {
166 Low => "low",
167 High => "high",
168 }
169}
170
171string_enum! {
172 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 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 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 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 #[must_use]
235 pub const fn writes_sidecar(self) -> bool {
236 matches!(self, Self::Sidecar | Self::SidecarAndEmbedded)
237 }
238
239 #[must_use]
241 pub const fn embeds(self) -> bool {
242 matches!(self, Self::Embedded | Self::SidecarAndEmbedded)
243 }
244}
245
246string_enum! {
247 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 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 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 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 pub enum Resolution {
310 OneK => "1k",
311 TwoK => "2k",
312 FourK => "4k",
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
318pub struct ImageSize(String);
319
320impl ImageSize {
321 pub const AUTO: &'static str = "auto";
323
324 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 #[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 #[must_use]
347 pub fn is_auto(&self) -> bool {
348 self.0 == Self::AUTO
349 }
350
351 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
441pub struct AspectRatio(String);
442
443impl AspectRatio {
444 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 #[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 #[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}