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