1use std::sync::Arc;
4
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use imagegen_bridge_artifacts::{
7 ArtifactPublication, ArtifactStore, ImageLimits, ImageMetadata, MAX_EMBEDDED_METADATA_BYTES,
8 RemoteImageFetcher, StoredArtifactContent, detect_border_chroma_key, embed_image_metadata,
9 inspect_image, inspect_transparent_alpha, remove_chroma_key, thumbnail_png,
10};
11use imagegen_bridge_core::{
12 BridgeError, CompatibilityMode, ErrorCode, GeneratedImage, GenerationParameters,
13 ImageOperation, ImagePayload, ImageRequest, ImageResponse, ImageSize, MultiImageFailurePolicy,
14 Normalization, OutputFormat, OutputOptions, RequestPolicies, ResponseFormat,
15};
16use serde::Serialize;
17use url::Url;
18
19use crate::transparency::TransparencyPlan;
20
21#[derive(Clone)]
23pub struct MaterializationConfig {
24 pub image_limits: ImageLimits,
26 pub max_base64_chars: usize,
28 pub max_response_bytes: usize,
30 pub artifact_store: Option<Arc<ArtifactStore>>,
32 pub remote_output_fetcher: Option<RemoteImageFetcher>,
34 pub public_artifact_base_url: Option<Url>,
36}
37
38impl std::fmt::Debug for MaterializationConfig {
39 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 formatter
41 .debug_struct("MaterializationConfig")
42 .field("image_limits", &self.image_limits)
43 .field("max_base64_chars", &self.max_base64_chars)
44 .field("max_response_bytes", &self.max_response_bytes)
45 .field("artifact_store", &self.artifact_store.is_some())
46 .field(
47 "remote_output_fetcher",
48 &self.remote_output_fetcher.is_some(),
49 )
50 .field("public_artifact_base_url", &self.public_artifact_base_url)
51 .finish()
52 }
53}
54
55impl Default for MaterializationConfig {
56 fn default() -> Self {
57 Self {
58 image_limits: ImageLimits::default(),
59 max_base64_chars: 128 * 1024 * 1024,
60 max_response_bytes: 256 * 1024 * 1024,
61 artifact_store: None,
62 remote_output_fetcher: None,
63 public_artifact_base_url: None,
64 }
65 }
66}
67
68pub(crate) struct OutputMaterializer {
69 config: MaterializationConfig,
70}
71
72impl OutputMaterializer {
73 pub(crate) fn new(config: MaterializationConfig) -> Result<Self, BridgeError> {
74 if config.max_base64_chars == 0 || config.max_response_bytes == 0 {
75 return Err(configuration_error(
76 "output payload limits must be greater than zero",
77 ));
78 }
79 if let Some(base) = &config.public_artifact_base_url {
80 let valid = matches!(base.scheme(), "http" | "https")
81 && base.username().is_empty()
82 && base.password().is_none()
83 && base.query().is_none()
84 && base.fragment().is_none()
85 && base.path().ends_with('/');
86 if !valid {
87 return Err(configuration_error(
88 "public artifact base URL must be credential-free HTTP(S) ending in a slash",
89 ));
90 }
91 if config.artifact_store.is_none() {
92 return Err(configuration_error(
93 "public artifact URL delivery requires an artifact store",
94 ));
95 }
96 }
97 Ok(Self { config })
98 }
99
100 pub(crate) const fn has_artifact_store(&self) -> bool {
101 self.config.artifact_store.is_some()
102 }
103
104 pub(crate) fn read_artifact(
105 &self,
106 artifact_id: &str,
107 ) -> Result<StoredArtifactContent, BridgeError> {
108 self.config
109 .artifact_store
110 .as_ref()
111 .ok_or_else(|| configuration_error("artifact output is not configured"))?
112 .read(artifact_id)
113 }
114
115 pub(crate) fn read_thumbnail(
116 &self,
117 artifact_id: &str,
118 maximum_edge: u32,
119 ) -> Result<Vec<u8>, BridgeError> {
120 let artifact = self.read_artifact(artifact_id)?;
121 thumbnail_png(&artifact.bytes, maximum_edge, self.config.image_limits).map_err(|error| {
122 BridgeError {
123 code: ErrorCode::Artifact,
124 ..error
125 }
126 })
127 }
128
129 pub(crate) async fn materialize(
130 &self,
131 mut response: ImageResponse,
132 original: &ImageRequest,
133 effective: &ImageRequest,
134 transparency: Option<TransparencyPlan>,
135 ) -> Result<ImageResponse, BridgeError> {
136 Self::validate_output_set(
137 &response,
138 effective.parameters.n,
139 effective.parameters.failure_policy,
140 )?;
141
142 let mut aggregate_bytes = response_payload_bytes(&response)?;
143 self.check_response_budget(aggregate_bytes)?;
144 let provider_images = std::mem::take(&mut response.data);
145 let mut verified = Vec::with_capacity(provider_images.len());
146 for image in provider_images {
147 let mut image = self
148 .verify(&image, effective.parameters.output_format)
149 .await?;
150 if let Some(plan) = transparency {
151 let detected_key = detect_border_chroma_key(&image.bytes, self.config.image_limits)
152 .map_err(as_artifact_error)?;
153 let mut chroma = plan.chroma;
154 chroma.key = detected_key;
155 let processed = remove_chroma_key(
156 &image.bytes,
157 effective.parameters.output_format,
158 chroma,
159 self.config.image_limits,
160 )
161 .map_err(as_artifact_error)?;
162 response.normalizations.push(Normalization {
163 field: format!("data[{}].transparency.key_color", image.index),
164 requested: Some(serde_json::json!(plan.chroma.key.hex())),
165 effective: Some(serde_json::json!(detected_key.hex())),
166 reason: "sampled_provider_output_border".to_owned(),
167 });
168 response.normalizations.push(alpha_normalization(
169 image.index,
170 processed.alpha,
171 "validated_chroma_key_alpha",
172 ));
173 image.bytes = processed.bytes;
174 image.metadata = processed.metadata;
175 } else if effective.parameters.background
176 == imagegen_bridge_core::Background::Transparent
177 {
178 let alpha = inspect_transparent_alpha(&image.bytes, self.config.image_limits)
179 .map_err(as_artifact_error)?;
180 response.normalizations.push(alpha_normalization(
181 image.index,
182 alpha,
183 "validated_native_alpha",
184 ));
185 }
186 aggregate_bytes = aggregate_bytes
187 .checked_add(image.bytes.len())
188 .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
189 self.check_response_budget(aggregate_bytes)?;
190 verified.push(image);
191 }
192
193 Self::reconcile_dimensions(
194 &mut response,
195 &verified,
196 &effective.parameters.size,
197 effective.policies.compatibility,
198 )?;
199
200 let mut projected = Vec::with_capacity(verified.len());
201 for mut image in verified {
202 if effective.output.metadata.embeds() {
203 let encoded = embedded_metadata(original, effective, &response, &image)?;
204 let (bytes, metadata) = embed_image_metadata(
205 &image.bytes,
206 image.metadata.format,
207 &encoded,
208 self.config.image_limits,
209 )?;
210 image.bytes = bytes;
211 image.metadata = metadata;
212 }
213 let projected_image = self.project(image, &effective.output)?;
214 aggregate_bytes = aggregate_bytes
215 .checked_add(payload_bytes(&projected_image.payload))
216 .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
217 self.check_response_budget(aggregate_bytes)?;
218 projected.push(projected_image);
219 }
220 response.data = projected;
221 Ok(response)
222 }
223
224 fn check_response_budget(&self, bytes: usize) -> Result<(), BridgeError> {
225 if bytes > self.config.max_response_bytes {
226 return Err(protocol_error(
227 "aggregate response payload exceeds the configured byte limit",
228 )
229 .with_detail("maximum_bytes", self.config.max_response_bytes));
230 }
231 Ok(())
232 }
233
234 pub(crate) fn attach_metadata(
235 &self,
236 original: &ImageRequest,
237 effective: &ImageRequest,
238 response: &mut ImageResponse,
239 ) -> Result<(), BridgeError> {
240 if !effective.output.metadata.writes_sidecar() {
241 return Ok(());
242 }
243 let store = self
244 .config
245 .artifact_store
246 .as_ref()
247 .ok_or_else(|| configuration_error("artifact output is not configured"))?;
248 let snapshot = response.clone();
249 let operation = operation_summary(&original.operation);
250 for image in &mut response.data {
251 let (id, name) = match &image.payload {
252 ImagePayload::Artifact {
253 id,
254 name: Some(name),
255 } => (id.as_str(), name.as_str()),
256 _ => {
257 return Err(protocol_error(
258 "metadata sidecar policy requires bridge artifact output",
259 ));
260 }
261 };
262 let encoded = serde_json::to_vec_pretty(&ArtifactMetadataSidecar {
263 version: 1,
264 request_id: &snapshot.id,
265 created: snapshot.created,
266 operation: &operation,
267 original_prompt: &original.prompt,
268 effective_prompt: &effective.prompt,
269 negative_prompt: original.negative_prompt.as_deref(),
270 policies: &effective.policies,
271 provider: &snapshot.provider,
272 model: &snapshot.model,
273 requested: &snapshot.requested,
274 effective: &snapshot.effective,
275 normalizations: &snapshot.normalizations,
276 attempts: &snapshot.attempts,
277 revised_prompt: snapshot.revised_prompt.as_deref(),
278 usage: snapshot.usage.as_ref(),
279 session: snapshot.session.as_ref(),
280 timings: &snapshot.timings,
281 warnings: &snapshot.warnings,
282 image: ArtifactMetadataImage {
283 index: image.index,
284 artifact_name: name,
285 format: image.format,
286 width: image.width,
287 height: image.height,
288 bytes: image.bytes,
289 sha256: &image.sha256,
290 generation_ms: image.generation_ms,
291 },
292 })
293 .map_err(|_| artifact_error("could not encode artifact metadata"))?;
294 image.metadata_name = Some(store.attach_metadata(id, name, &encoded)?.name);
295 }
296 Ok(())
297 }
298
299 fn validate_output_set(
300 response: &ImageResponse,
301 expected_count: u8,
302 expected_failure_policy: MultiImageFailurePolicy,
303 ) -> Result<(), BridgeError> {
304 let allows_failures = expected_failure_policy == MultiImageFailurePolicy::BestEffort;
305 if !allows_failures && !response.failures.is_empty() {
306 return Err(protocol_error(
307 "provider returned partial failures for a fail-fast request",
308 ));
309 }
310 let actual = response.data.len().saturating_add(response.failures.len());
311 if actual != usize::from(expected_count) {
312 return Err(protocol_error(
313 "provider returned a different number of output results than negotiated",
314 )
315 .with_detail("expected", expected_count)
316 .with_detail("actual", actual));
317 }
318 let mut indices = response
319 .data
320 .iter()
321 .map(|image| image.index)
322 .chain(response.failures.iter().map(|failure| failure.index))
323 .collect::<Vec<_>>();
324 indices.sort_unstable();
325 if indices != (0..expected_count).collect::<Vec<_>>() {
326 return Err(protocol_error(
327 "provider returned duplicate or missing output indices",
328 ));
329 }
330 if !response
331 .data
332 .windows(2)
333 .all(|pair| pair[0].index < pair[1].index)
334 || !response
335 .failures
336 .windows(2)
337 .all(|pair| pair[0].index < pair[1].index)
338 {
339 return Err(protocol_error(
340 "provider returned output results in unstable index order",
341 ));
342 }
343 Ok(())
344 }
345
346 fn reconcile_dimensions(
347 response: &mut ImageResponse,
348 verified: &[VerifiedImage],
349 expected_size: &ImageSize,
350 compatibility: CompatibilityMode,
351 ) -> Result<(), BridgeError> {
352 let Some((expected_width, expected_height)) = expected_size.dimensions() else {
353 return Ok(());
354 };
355 let Some(first) = verified.first() else {
356 return Ok(());
357 };
358 let actual = (first.metadata.width, first.metadata.height);
359 if verified
360 .iter()
361 .any(|image| (image.metadata.width, image.metadata.height) != actual)
362 {
363 return Err(protocol_error(
364 "provider returned inconsistent dimensions across generated images",
365 ));
366 }
367 if actual == (expected_width, expected_height) {
368 return Ok(());
369 }
370 if compatibility == CompatibilityMode::Strict {
371 return Err(protocol_error(
372 "provider output dimensions do not match the negotiated size",
373 )
374 .with_detail("expected", expected_size.to_string())
375 .with_detail("actual", format!("{}x{}", actual.0, actual.1)));
376 }
377 let actual_size = ImageSize::exact(actual.0, actual.1)?;
378 response.effective.size = actual_size.clone();
379 response.normalizations.push(Normalization {
380 field: "parameters.size".to_owned(),
381 requested: Some(serde_json::Value::String(expected_size.to_string())),
382 effective: Some(serde_json::Value::String(actual_size.to_string())),
383 reason: "provider_output_dimensions_differed".to_owned(),
384 });
385 if !response
386 .warnings
387 .iter()
388 .any(|warning| warning == "provider_output_dimensions_differed")
389 {
390 response
391 .warnings
392 .push("provider_output_dimensions_differed".to_owned());
393 }
394 Ok(())
395 }
396
397 async fn verify(
398 &self,
399 image: &GeneratedImage,
400 expected_format: OutputFormat,
401 ) -> Result<VerifiedImage, BridgeError> {
402 let (bytes, metadata) = match &image.payload {
403 ImagePayload::B64Json { b64_json } => {
404 if b64_json.len() > self.config.max_base64_chars {
405 return Err(protocol_error(
406 "provider image base64 exceeds the configured limit",
407 ));
408 }
409 let bytes = STANDARD
410 .decode(b64_json.trim())
411 .map_err(|_| protocol_error("provider returned malformed base64 image data"))?;
412 let metadata =
413 inspect_image(&bytes, self.config.image_limits).map_err(as_artifact_error)?;
414 (bytes, metadata)
415 }
416 ImagePayload::Url { url } => {
417 let fetcher = self.config.remote_output_fetcher.as_ref().ok_or_else(|| {
418 configuration_error("provider URL output retrieval is not configured")
419 })?;
420 let loaded = fetcher.fetch(url).await.map_err(as_artifact_error)?;
421 (loaded.bytes, loaded.metadata)
422 }
423 ImagePayload::Artifact { .. } | ImagePayload::Metadata => {
424 return Err(protocol_error(
425 "provider returned an unverifiable internal payload type",
426 ));
427 }
428 };
429 if metadata.format != expected_format {
430 return Err(protocol_error(
431 "provider output format does not match the negotiated format",
432 ));
433 }
434 if image.format != metadata.format
435 || image.width != metadata.width
436 || image.height != metadata.height
437 || image.bytes != metadata.bytes
438 || image.sha256 != metadata.sha256
439 {
440 return Err(protocol_error(
441 "provider output metadata does not match the decoded image",
442 ));
443 }
444 Ok(VerifiedImage {
445 index: image.index,
446 bytes,
447 metadata,
448 generation_ms: image.generation_ms,
449 })
450 }
451
452 fn project(
453 &self,
454 image: VerifiedImage,
455 output: &OutputOptions,
456 ) -> Result<GeneratedImage, BridgeError> {
457 let payload = match output.response_format {
458 ResponseFormat::B64Json => ImagePayload::B64Json {
459 b64_json: STANDARD.encode(&image.bytes),
460 },
461 ResponseFormat::Metadata => ImagePayload::Metadata,
462 ResponseFormat::Artifact | ResponseFormat::Url => {
463 let public_base = if output.response_format == ResponseFormat::Url {
464 Some(
465 self.config
466 .public_artifact_base_url
467 .as_ref()
468 .ok_or_else(|| {
469 configuration_error("public artifact base URL is not configured")
470 })?,
471 )
472 } else {
473 None
474 };
475 let store = self
476 .config
477 .artifact_store
478 .as_ref()
479 .ok_or_else(|| configuration_error("artifact output is not configured"))?;
480 let stored = store.publish_with_options(
481 &image.bytes,
482 output.filename_prefix.as_deref(),
483 Some(image.metadata.format),
484 ArtifactPublication {
485 directory: output.directory.as_deref(),
486 filename: output.filename.as_deref(),
487 collision: output.collision,
488 },
489 )?;
490 if output.response_format == ResponseFormat::Artifact {
491 ImagePayload::Artifact {
492 id: stored.id,
493 name: Some(stored.name),
494 }
495 } else {
496 let base = public_base.ok_or_else(|| {
497 configuration_error("public artifact base URL is not configured")
498 })?;
499 let url = base.join(&stored.name).map_err(|_| {
500 configuration_error("could not construct public artifact URL")
501 })?;
502 ImagePayload::Url {
503 url: url.to_string(),
504 }
505 }
506 }
507 };
508 Ok(GeneratedImage {
509 index: image.index,
510 payload,
511 format: image.metadata.format,
512 width: image.metadata.width,
513 height: image.metadata.height,
514 bytes: image.metadata.bytes,
515 sha256: image.metadata.sha256,
516 generation_ms: image.generation_ms,
517 metadata_name: None,
518 })
519 }
520}
521
522fn alpha_normalization(
523 index: u8,
524 alpha: imagegen_bridge_artifacts::AlphaSummary,
525 reason: &str,
526) -> Normalization {
527 Normalization {
528 field: format!("data[{index}].transparency.alpha"),
529 requested: None,
530 effective: Some(serde_json::json!({
531 "total_pixels": alpha.total_pixels,
532 "transparent_pixels": alpha.transparent_pixels,
533 "partial_pixels": alpha.partial_pixels,
534 "opaque_pixels": alpha.opaque_pixels,
535 "transparent_corners": alpha.transparent_corners,
536 })),
537 reason: reason.to_owned(),
538 }
539}
540
541#[derive(Serialize)]
542struct ArtifactMetadataSidecar<'a> {
543 version: u8,
544 request_id: &'a str,
545 created: u64,
546 operation: &'a ArtifactOperationSummary,
547 original_prompt: &'a str,
548 effective_prompt: &'a str,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 negative_prompt: Option<&'a str>,
551 policies: &'a RequestPolicies,
552 provider: &'a str,
553 model: &'a str,
554 requested: &'a GenerationParameters,
555 effective: &'a GenerationParameters,
556 normalizations: &'a [Normalization],
557 #[serde(skip_serializing_if = "slice_is_empty")]
558 attempts: &'a [imagegen_bridge_core::ProviderAttempt],
559 #[serde(skip_serializing_if = "Option::is_none")]
560 revised_prompt: Option<&'a str>,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 usage: Option<&'a imagegen_bridge_core::Usage>,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 session: Option<&'a imagegen_bridge_core::SessionMetadata>,
565 timings: &'a imagegen_bridge_core::Timings,
566 warnings: &'a [String],
567 image: ArtifactMetadataImage<'a>,
568}
569
570#[derive(Serialize)]
571struct EmbeddedArtifactMetadata<'a> {
572 version: u8,
573 kind: &'static str,
574 request_id: &'a str,
575 created: u64,
576 operation: &'a ArtifactOperationSummary,
577 original_prompt: &'a str,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 effective_prompt: Option<&'a str>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 negative_prompt: Option<&'a str>,
582 policies: &'a RequestPolicies,
583 provider: &'a str,
584 model: &'a str,
585 requested: &'a GenerationParameters,
586 effective: &'a GenerationParameters,
587 #[serde(skip_serializing_if = "slice_is_empty")]
588 normalizations: &'a [Normalization],
589 #[serde(skip_serializing_if = "Option::is_none")]
590 revised_prompt: Option<&'a str>,
591 #[serde(skip_serializing_if = "Option::is_none")]
592 usage: Option<&'a imagegen_bridge_core::Usage>,
593 #[serde(skip_serializing_if = "Option::is_none")]
594 session: Option<&'a imagegen_bridge_core::SessionMetadata>,
595 timings: EmbeddedMetadataTimings,
596 #[serde(skip_serializing_if = "slice_is_empty")]
597 warnings: &'a [String],
598 image: EmbeddedMetadataImage,
599}
600
601#[derive(Serialize)]
602struct EmbeddedMetadataTimings {
603 #[serde(rename = "queue_ms")]
604 queue: u64,
605 #[serde(rename = "provider_ms")]
606 provider: u64,
607 #[serde(rename = "generation_ms", skip_serializing_if = "Option::is_none")]
608 generation: Option<u64>,
609}
610
611#[derive(Serialize)]
612struct EmbeddedMetadataImage {
613 index: u8,
614 format: OutputFormat,
615 width: u32,
616 height: u32,
617}
618
619fn embedded_metadata(
620 original: &ImageRequest,
621 effective: &ImageRequest,
622 response: &ImageResponse,
623 image: &VerifiedImage,
624) -> Result<Vec<u8>, BridgeError> {
625 let operation = operation_summary(&original.operation);
626 let mut value = serde_json::to_value(EmbeddedArtifactMetadata {
627 version: 1,
628 kind: "imagegen_bridge_generation",
629 request_id: &response.id,
630 created: response.created,
631 operation: &operation,
632 original_prompt: &original.prompt,
633 effective_prompt: (original.prompt != effective.prompt)
634 .then_some(effective.prompt.as_str()),
635 negative_prompt: original.negative_prompt.as_deref(),
636 policies: &effective.policies,
637 provider: &response.provider,
638 model: &response.model,
639 requested: &response.requested,
640 effective: &response.effective,
641 normalizations: &response.normalizations,
642 revised_prompt: response.revised_prompt.as_deref(),
643 usage: response.usage.as_ref(),
644 session: response.session.as_ref(),
645 timings: EmbeddedMetadataTimings {
646 queue: response.timings.queue_ms,
647 provider: response.timings.provider_ms,
648 generation: image.generation_ms,
649 },
650 warnings: &response.warnings,
651 image: EmbeddedMetadataImage {
652 index: image.index,
653 format: image.metadata.format,
654 width: image.metadata.width,
655 height: image.metadata.height,
656 },
657 })
658 .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
659 let mut encoded = serde_json::to_vec(&value)
660 .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
661 if encoded.len() <= MAX_EMBEDDED_METADATA_BYTES {
662 return Ok(encoded);
663 }
664
665 let object = value
666 .as_object_mut()
667 .ok_or_else(|| artifact_error("embedded artifact metadata is not an object"))?;
668 let mut omitted = Vec::new();
669 for field in [
670 "usage",
671 "session",
672 "normalizations",
673 "warnings",
674 "revised_prompt",
675 ] {
676 if object.remove(field).is_none() {
677 continue;
678 }
679 omitted.push(field);
680 object.insert(
681 "omitted_fields".to_owned(),
682 serde_json::to_value(&omitted)
683 .map_err(|_| artifact_error("could not encode omitted metadata fields"))?,
684 );
685 encoded = serde_json::to_vec(&object)
686 .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
687 if encoded.len() <= MAX_EMBEDDED_METADATA_BYTES {
688 return Ok(encoded);
689 }
690 }
691 Err(artifact_error(
692 "embedded artifact metadata exceeds the portable 40 KiB limit",
693 ))
694}
695
696fn slice_is_empty<T>(value: &&[T]) -> bool {
697 value.is_empty()
698}
699
700#[derive(Serialize)]
701struct ArtifactMetadataImage<'a> {
702 index: u8,
703 artifact_name: &'a str,
704 format: OutputFormat,
705 width: u32,
706 height: u32,
707 bytes: u64,
708 sha256: &'a str,
709 #[serde(skip_serializing_if = "Option::is_none")]
710 generation_ms: Option<u64>,
711}
712
713#[derive(Serialize)]
714struct ArtifactOperationSummary {
715 kind: &'static str,
716 input_images: usize,
717 reference_images: usize,
718 mask: bool,
719}
720
721fn operation_summary(operation: &ImageOperation) -> ArtifactOperationSummary {
722 match operation {
723 ImageOperation::Generate { reference_images } => ArtifactOperationSummary {
724 kind: "generate",
725 input_images: 0,
726 reference_images: reference_images.len(),
727 mask: false,
728 },
729 ImageOperation::Edit {
730 images,
731 mask,
732 reference_images,
733 } => ArtifactOperationSummary {
734 kind: "edit",
735 input_images: images.len(),
736 reference_images: reference_images.len(),
737 mask: mask.is_some(),
738 },
739 }
740}
741
742struct VerifiedImage {
743 index: u8,
744 bytes: Vec<u8>,
745 metadata: ImageMetadata,
746 generation_ms: Option<u64>,
747}
748
749fn response_payload_bytes(response: &ImageResponse) -> Result<usize, BridgeError> {
750 let mut total = response
751 .id
752 .len()
753 .checked_add(response.provider.len())
754 .and_then(|value| value.checked_add(response.model.len()))
755 .and_then(|value| value.checked_add(response.revised_prompt.as_deref().map_or(0, str::len)))
756 .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
757 for warning in &response.warnings {
758 total = total
759 .checked_add(warning.len())
760 .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
761 }
762 for image in &response.data {
763 total = total
764 .checked_add(payload_bytes(&image.payload))
765 .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
766 }
767 Ok(total)
768}
769
770fn payload_bytes(payload: &ImagePayload) -> usize {
771 match payload {
772 ImagePayload::B64Json { b64_json } => b64_json.len(),
773 ImagePayload::Url { url } => url.len(),
774 ImagePayload::Artifact { id, name } => {
775 id.len().saturating_add(name.as_deref().map_or(0, str::len))
776 }
777 ImagePayload::Metadata => 0,
778 }
779}
780
781fn as_artifact_error(error: BridgeError) -> BridgeError {
782 BridgeError {
783 code: ErrorCode::Artifact,
784 message: "provider output image failed independent verification".to_owned(),
785 provider: error.provider,
786 details: error.details,
787 ..error
788 }
789}
790
791fn configuration_error(message: impl Into<String>) -> BridgeError {
792 BridgeError::new(ErrorCode::Configuration, message)
793}
794
795fn artifact_error(message: impl Into<String>) -> BridgeError {
796 BridgeError::new(ErrorCode::Artifact, message)
797}
798
799fn protocol_error(message: impl Into<String>) -> BridgeError {
800 BridgeError::new(ErrorCode::Protocol, message)
801}
802
803#[cfg(test)]
804mod tests {
805 #![allow(clippy::unwrap_used)]
806
807 use std::fs;
808
809 use imagegen_bridge_artifacts::extract_embedded_metadata;
810 use imagegen_bridge_core::{
811 ArtifactMetadataPolicy, GenerationParameters, ImageFailure, Timings,
812 };
813
814 use super::*;
815 use crate::transparency::TransparencyPlan;
816
817 const ONE_PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
818
819 fn image(index: u8) -> GeneratedImage {
820 GeneratedImage {
821 index,
822 payload: ImagePayload::Metadata,
823 format: OutputFormat::Png,
824 width: 1,
825 height: 1,
826 bytes: 1,
827 sha256: "0".repeat(64),
828 generation_ms: Some(1),
829 metadata_name: None,
830 }
831 }
832
833 fn keyed_image() -> GeneratedImage {
834 let mut rgba = image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 255, 0, 255]));
835 for y in 5..15 {
836 for x in 5..15 {
837 rgba.put_pixel(x, y, image::Rgba([220, 30, 20, 255]));
838 }
839 }
840 let mut encoded = std::io::Cursor::new(Vec::new());
841 image::DynamicImage::ImageRgba8(rgba)
842 .write_to(&mut encoded, image::ImageFormat::Png)
843 .unwrap();
844 let bytes = encoded.into_inner();
845 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
846 GeneratedImage {
847 index: 0,
848 payload: ImagePayload::B64Json {
849 b64_json: STANDARD.encode(bytes),
850 },
851 format: metadata.format,
852 width: metadata.width,
853 height: metadata.height,
854 bytes: metadata.bytes,
855 sha256: metadata.sha256,
856 generation_ms: None,
857 metadata_name: None,
858 }
859 }
860
861 #[tokio::test]
862 async fn chroma_postprocessing_is_applied_before_projection() {
863 let materializer = OutputMaterializer::new(MaterializationConfig::default()).unwrap();
864 let mut request = ImageRequest::generate("isolated red square");
865 request.parameters.background = imagegen_bridge_core::Background::Transparent;
866 let projected = materializer
867 .materialize(
868 response(vec![keyed_image()], Vec::new()),
869 &request,
870 &request,
871 Some(TransparencyPlan {
872 chroma: imagegen_bridge_artifacts::ChromaKeyOptions::default(),
873 }),
874 )
875 .await
876 .unwrap();
877 assert!(matches!(
878 projected.data[0].payload,
879 ImagePayload::B64Json { .. }
880 ));
881 let ImagePayload::B64Json { b64_json } = &projected.data[0].payload else {
882 return;
883 };
884 let decoded = image::load_from_memory(&STANDARD.decode(b64_json).unwrap())
885 .unwrap()
886 .to_rgba8();
887 assert_eq!(decoded.get_pixel(0, 0).0, [0, 0, 0, 0]);
888 assert_eq!(decoded.get_pixel(10, 10).0, [220, 30, 20, 255]);
889 assert!(projected.normalizations.iter().any(|item| {
890 item.field == "data[0].transparency.alpha"
891 && item.reason == "validated_chroma_key_alpha"
892 }));
893 }
894
895 fn response(data: Vec<GeneratedImage>, failures: Vec<ImageFailure>) -> ImageResponse {
896 ImageResponse {
897 id: "test".to_owned(),
898 created: 0,
899 provider: "test".to_owned(),
900 model: "test".to_owned(),
901 requested: GenerationParameters::default(),
902 effective: GenerationParameters::default(),
903 normalizations: Vec::new(),
904 attempts: Vec::new(),
905 data,
906 failures,
907 revised_prompt: None,
908 usage: None,
909 session: None,
910 timings: Timings::default(),
911 warnings: Vec::new(),
912 }
913 }
914
915 #[test]
916 fn accepts_complete_indexed_best_effort_results() {
917 let response = response(
918 vec![image(0), image(2)],
919 vec![ImageFailure {
920 index: 1,
921 error: BridgeError::new(ErrorCode::Upstream, "failed"),
922 generation_ms: 1,
923 }],
924 );
925 OutputMaterializer::validate_output_set(&response, 3, MultiImageFailurePolicy::BestEffort)
926 .unwrap();
927 }
928
929 #[test]
930 fn rejects_partial_failures_in_fail_fast_and_duplicate_indices() {
931 let response = response(
932 vec![image(0), image(1)],
933 vec![ImageFailure {
934 index: 1,
935 error: BridgeError::new(ErrorCode::Upstream, "failed"),
936 generation_ms: 1,
937 }],
938 );
939 assert!(
940 OutputMaterializer::validate_output_set(
941 &response,
942 3,
943 MultiImageFailurePolicy::FailFast,
944 )
945 .is_err()
946 );
947 assert!(
948 OutputMaterializer::validate_output_set(
949 &response,
950 3,
951 MultiImageFailurePolicy::BestEffort,
952 )
953 .is_err()
954 );
955 }
956
957 #[tokio::test]
958 async fn rejects_outputs_that_exceed_the_aggregate_response_budget() {
959 let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
960 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
961 let generated = |index| GeneratedImage {
962 index,
963 payload: ImagePayload::B64Json {
964 b64_json: ONE_PIXEL_PNG.to_owned(),
965 },
966 format: metadata.format,
967 width: metadata.width,
968 height: metadata.height,
969 bytes: metadata.bytes,
970 sha256: metadata.sha256.clone(),
971 generation_ms: None,
972 metadata_name: None,
973 };
974 let materializer = OutputMaterializer::new(MaterializationConfig {
975 max_response_bytes: 120,
976 ..MaterializationConfig::default()
977 })
978 .unwrap();
979 let mut request = ImageRequest::generate("two images");
980 request.parameters.n = 2;
981 let error = materializer
982 .materialize(
983 response(vec![generated(0), generated(1)], Vec::new()),
984 &request,
985 &request,
986 None,
987 )
988 .await
989 .unwrap_err();
990 assert_eq!(error.code, ErrorCode::Protocol);
991 assert!(error.message.contains("aggregate response payload"));
992 }
993
994 #[test]
995 fn artifact_projection_honors_per_request_placement() {
996 let root = tempfile::tempdir().unwrap();
997 let store = Arc::new(ArtifactStore::new(root.path(), ImageLimits::default()).unwrap());
998 let materializer = OutputMaterializer::new(MaterializationConfig {
999 artifact_store: Some(Arc::clone(&store)),
1000 ..MaterializationConfig::default()
1001 })
1002 .unwrap();
1003 let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1004 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1005 let output = OutputOptions {
1006 response_format: ResponseFormat::Artifact,
1007 directory: Some("portraits".to_owned()),
1008 filename: Some("woman.png".to_owned()),
1009 metadata: ArtifactMetadataPolicy::Sidecar,
1010 ..OutputOptions::default()
1011 };
1012 let projected = materializer
1013 .project(
1014 VerifiedImage {
1015 index: 0,
1016 bytes,
1017 metadata,
1018 generation_ms: Some(1),
1019 },
1020 &output,
1021 )
1022 .unwrap();
1023 assert!(matches!(
1024 projected.payload,
1025 ImagePayload::Artifact { name: Some(ref name), .. } if name == "portraits/woman.png"
1026 ));
1027 assert!(root.path().join("portraits/woman.png").is_file());
1028
1029 let mut original = ImageRequest::generate("an original prompt");
1030 original.negative_prompt = Some("blur".to_owned());
1031 original.output = output;
1032 let mut response = response(vec![projected], Vec::new());
1033 response.provider = "codex-app-server".to_owned();
1034 response.model = "gpt-image-2".to_owned();
1035 materializer
1036 .attach_metadata(&original, &original, &mut response)
1037 .unwrap();
1038 let metadata_name = response.data[0].metadata_name.as_deref().unwrap();
1039 let sidecar: serde_json::Value =
1040 serde_json::from_slice(&fs::read(root.path().join(metadata_name)).unwrap()).unwrap();
1041 assert_eq!(sidecar["original_prompt"], "an original prompt");
1042 assert_eq!(sidecar["negative_prompt"], "blur");
1043 assert_eq!(sidecar["provider"], "codex-app-server");
1044 assert_eq!(sidecar["model"], "gpt-image-2");
1045 assert_eq!(sidecar["image"]["artifact_name"], "portraits/woman.png");
1046 assert_eq!(sidecar["image"]["width"], 1);
1047 }
1048
1049 #[tokio::test]
1050 async fn embedded_policy_updates_payload_checksum_and_carries_generation_contract() {
1051 let materializer = OutputMaterializer::new(MaterializationConfig::default()).unwrap();
1052 let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1053 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1054 let generated = GeneratedImage {
1055 index: 0,
1056 payload: ImagePayload::B64Json {
1057 b64_json: STANDARD.encode(&bytes),
1058 },
1059 format: metadata.format,
1060 width: metadata.width,
1061 height: metadata.height,
1062 bytes: metadata.bytes,
1063 sha256: metadata.sha256.clone(),
1064 generation_ms: Some(41),
1065 metadata_name: None,
1066 };
1067 let mut provider_response = response(vec![generated], Vec::new());
1068 provider_response.provider = "codex-app-server".to_owned();
1069 provider_response.model = "gpt-image-2".to_owned();
1070 provider_response.timings.queue_ms = 3;
1071 provider_response.timings.provider_ms = 40;
1072 let mut request = ImageRequest::generate("a red paper fox");
1073 request.output.metadata = ArtifactMetadataPolicy::Embedded;
1074
1075 let projected = materializer
1076 .materialize(provider_response, &request, &request, None)
1077 .await
1078 .unwrap();
1079 let b64_json = match &projected.data[0].payload {
1080 ImagePayload::B64Json { b64_json } => Some(b64_json),
1081 _ => None,
1082 }
1083 .unwrap();
1084 let embedded = STANDARD.decode(b64_json).unwrap();
1085 let record: serde_json::Value = serde_json::from_slice(
1086 &extract_embedded_metadata(&embedded, ImageLimits::default())
1087 .unwrap()
1088 .unwrap(),
1089 )
1090 .unwrap();
1091 assert_eq!(record["kind"], "imagegen_bridge_generation");
1092 assert_eq!(record["original_prompt"], "a red paper fox");
1093 assert_eq!(record["provider"], "codex-app-server");
1094 assert_eq!(record["model"], "gpt-image-2");
1095 assert_eq!(record["timings"]["provider_ms"], 40);
1096 assert_eq!(record["timings"]["generation_ms"], 41);
1097 assert_ne!(projected.data[0].sha256, metadata.sha256);
1098 assert_eq!(
1099 projected.data[0].sha256,
1100 inspect_image(&embedded, ImageLimits::default())
1101 .unwrap()
1102 .sha256
1103 );
1104 }
1105
1106 #[tokio::test]
1107 async fn combined_policy_publishes_embedded_xmp_and_matching_sidecar() {
1108 let root = tempfile::tempdir().unwrap();
1109 let store = Arc::new(ArtifactStore::new(root.path(), ImageLimits::default()).unwrap());
1110 let materializer = OutputMaterializer::new(MaterializationConfig {
1111 artifact_store: Some(Arc::clone(&store)),
1112 ..MaterializationConfig::default()
1113 })
1114 .unwrap();
1115 let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1116 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1117 let generated = GeneratedImage {
1118 index: 0,
1119 payload: ImagePayload::B64Json {
1120 b64_json: STANDARD.encode(&bytes),
1121 },
1122 format: metadata.format,
1123 width: metadata.width,
1124 height: metadata.height,
1125 bytes: metadata.bytes,
1126 sha256: metadata.sha256,
1127 generation_ms: Some(7),
1128 metadata_name: None,
1129 };
1130 let mut request = ImageRequest::generate("a glass lighthouse");
1131 request.output = OutputOptions {
1132 response_format: ResponseFormat::Artifact,
1133 filename: Some("lighthouse.png".to_owned()),
1134 metadata: ArtifactMetadataPolicy::SidecarAndEmbedded,
1135 ..OutputOptions::default()
1136 };
1137 let mut provider_response = response(vec![generated], Vec::new());
1138 provider_response.provider = "codex-app-server".to_owned();
1139 provider_response.model = "gpt-image-2".to_owned();
1140 let mut projected = materializer
1141 .materialize(provider_response, &request, &request, None)
1142 .await
1143 .unwrap();
1144 materializer
1145 .attach_metadata(&request, &request, &mut projected)
1146 .unwrap();
1147
1148 let (id, name) = match &projected.data[0].payload {
1149 ImagePayload::Artifact { id, name } => Some((id, name)),
1150 _ => None,
1151 }
1152 .unwrap();
1153 assert_eq!(name.as_deref(), Some("lighthouse.png"));
1154 let stored = store.read(id).unwrap();
1155 let embedded: serde_json::Value = serde_json::from_slice(
1156 &extract_embedded_metadata(&stored.bytes, ImageLimits::default())
1157 .unwrap()
1158 .unwrap(),
1159 )
1160 .unwrap();
1161 assert_eq!(embedded["original_prompt"], "a glass lighthouse");
1162 assert_eq!(stored.metadata.sha256, projected.data[0].sha256);
1163 assert!(
1164 root.path()
1165 .join(projected.data[0].metadata_name.as_deref().unwrap())
1166 .is_file()
1167 );
1168 }
1169
1170 #[test]
1171 fn embedded_record_reports_optional_fields_omitted_for_container_portability() {
1172 let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1173 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1174 let image = VerifiedImage {
1175 index: 0,
1176 bytes,
1177 metadata,
1178 generation_ms: Some(9),
1179 };
1180 let request = ImageRequest::generate("a small prompt");
1181 let mut provider_response = response(Vec::new(), Vec::new());
1182 provider_response.revised_prompt = Some("r".repeat(50 * 1024));
1183
1184 let encoded = embedded_metadata(&request, &request, &provider_response, &image).unwrap();
1185 assert!(encoded.len() <= MAX_EMBEDDED_METADATA_BYTES);
1186 let value: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
1187 assert!(value.get("revised_prompt").is_none());
1188 assert!(
1189 value["omitted_fields"]
1190 .as_array()
1191 .unwrap()
1192 .iter()
1193 .any(|field| field == "revised_prompt")
1194 );
1195 }
1196}