Skip to main content

cu_sensor_payloads/
image.rs

1use bincode::de::Decoder;
2use bincode::error::DecodeError;
3use bincode::{Decode, Encode};
4use core::fmt::Debug;
5use core::ops::Range;
6use cu29::prelude::*;
7
8#[cfg(feature = "image")]
9use image::{ImageBuffer, Pixel};
10#[cfg(feature = "kornia")]
11use kornia_image::Image;
12#[cfg(feature = "kornia")]
13use kornia_image::allocator::ImageAllocator;
14use serde::{Deserialize, Serialize, Serializer};
15
16#[derive(Default, Debug, Encode, Decode, Clone, Copy, Serialize, Deserialize, Reflect)]
17pub struct CuImageBufferFormat {
18    pub width: u32,
19    pub height: u32,
20    pub stride: u32,
21    pub pixel_format: [u8; 4],
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct CuImagePlaneLayout {
26    pub offset_bytes: usize,
27    pub row_bytes: u32,
28    pub stride_bytes: u32,
29    pub height: u32,
30}
31
32impl CuImagePlaneLayout {
33    pub fn byte_len(&self) -> usize {
34        self.stride_bytes as usize * self.height as usize
35    }
36
37    pub fn byte_range(&self) -> Range<usize> {
38        self.offset_bytes..self.offset_bytes + self.byte_len()
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum CuImageMemoryLayout {
44    Packed { bytes_per_pixel: u32 },
45    SemiPlanar420,
46    Planar420,
47    SinglePlane,
48}
49
50impl CuImageBufferFormat {
51    fn memory_layout(&self) -> CuImageMemoryLayout {
52        match &self.pixel_format {
53            b"GRAY" | b"Y800" => CuImageMemoryLayout::Packed { bytes_per_pixel: 1 },
54            b"YUYV" | b"UYVY" => CuImageMemoryLayout::Packed { bytes_per_pixel: 2 },
55            b"RGB3" | b"BGR3" | b"RGB " | b"BGR " => {
56                CuImageMemoryLayout::Packed { bytes_per_pixel: 3 }
57            }
58            b"RGBA" | b"BGRA" => CuImageMemoryLayout::Packed { bytes_per_pixel: 4 },
59            b"NV12" | b"NV21" => CuImageMemoryLayout::SemiPlanar420,
60            b"I420" | b"YV12" => CuImageMemoryLayout::Planar420,
61            _ => CuImageMemoryLayout::SinglePlane,
62        }
63    }
64
65    pub fn plane_count(&self) -> usize {
66        match self.memory_layout() {
67            CuImageMemoryLayout::Planar420 => 3,
68            CuImageMemoryLayout::SemiPlanar420 => 2,
69            CuImageMemoryLayout::Packed { .. } | CuImageMemoryLayout::SinglePlane => 1,
70        }
71    }
72
73    pub fn is_packed(&self) -> bool {
74        matches!(self.memory_layout(), CuImageMemoryLayout::Packed { .. })
75    }
76
77    pub fn packed_row_bytes(&self) -> Option<u32> {
78        match self.memory_layout() {
79            CuImageMemoryLayout::Packed { bytes_per_pixel } => Some(self.width * bytes_per_pixel),
80            CuImageMemoryLayout::SemiPlanar420
81            | CuImageMemoryLayout::Planar420
82            | CuImageMemoryLayout::SinglePlane => None,
83        }
84    }
85
86    pub fn plane(&self, index: usize) -> Option<CuImagePlaneLayout> {
87        let y_plane_bytes = self.stride as usize * self.height as usize;
88        let chroma_height = self.height.div_ceil(2);
89
90        match self.memory_layout() {
91            CuImageMemoryLayout::Packed { bytes_per_pixel } if index == 0 => {
92                Some(CuImagePlaneLayout {
93                    offset_bytes: 0,
94                    row_bytes: self.width * bytes_per_pixel,
95                    stride_bytes: self.stride,
96                    height: self.height,
97                })
98            }
99            CuImageMemoryLayout::SinglePlane if index == 0 => Some(CuImagePlaneLayout {
100                offset_bytes: 0,
101                row_bytes: self.stride,
102                stride_bytes: self.stride,
103                height: self.height,
104            }),
105            CuImageMemoryLayout::SemiPlanar420 if index == 0 => Some(CuImagePlaneLayout {
106                offset_bytes: 0,
107                row_bytes: self.width,
108                stride_bytes: self.stride,
109                height: self.height,
110            }),
111            CuImageMemoryLayout::SemiPlanar420 if index == 1 => Some(CuImagePlaneLayout {
112                offset_bytes: y_plane_bytes,
113                row_bytes: self.width.div_ceil(2) * 2,
114                stride_bytes: self.stride,
115                height: chroma_height,
116            }),
117            CuImageMemoryLayout::Planar420 if index == 0 => Some(CuImagePlaneLayout {
118                offset_bytes: 0,
119                row_bytes: self.width,
120                stride_bytes: self.stride,
121                height: self.height,
122            }),
123            CuImageMemoryLayout::Planar420 if index == 1 => Some({
124                let chroma_stride = self.stride.div_ceil(2);
125                CuImagePlaneLayout {
126                    offset_bytes: y_plane_bytes,
127                    row_bytes: self.width.div_ceil(2),
128                    stride_bytes: chroma_stride,
129                    height: chroma_height,
130                }
131            }),
132            CuImageMemoryLayout::Planar420 if index == 2 => Some({
133                let chroma_stride = self.stride.div_ceil(2);
134                let chroma_plane_bytes = chroma_stride as usize * chroma_height as usize;
135                CuImagePlaneLayout {
136                    offset_bytes: y_plane_bytes + chroma_plane_bytes,
137                    row_bytes: self.width.div_ceil(2),
138                    stride_bytes: chroma_stride,
139                    height: chroma_height,
140                }
141            }),
142            _ => None,
143        }
144    }
145
146    pub fn is_valid(&self) -> bool {
147        (0..self.plane_count()).all(|index| {
148            self.plane(index)
149                .map(|plane| plane.row_bytes <= plane.stride_bytes)
150                .unwrap_or(false)
151        })
152    }
153
154    pub fn required_bytes(&self) -> usize {
155        self.plane(self.plane_count().saturating_sub(1))
156            .map(|plane| plane.offset_bytes + plane.byte_len())
157            .unwrap_or(0)
158    }
159
160    pub fn byte_size(&self) -> usize {
161        self.required_bytes()
162    }
163}
164
165#[derive(Debug, Default, Clone, Encode, Reflect)]
166#[reflect(from_reflect = false, no_field_bounds, type_path = false)]
167pub struct CuImage<A>
168where
169    A: ArrayLike<Element = u8> + Send + Sync + 'static,
170{
171    pub seq: u64,
172    pub format: CuImageBufferFormat,
173    #[reflect(ignore)]
174    pub buffer_handle: CuHandle<A>,
175}
176
177impl<A> TypePath for CuImage<A>
178where
179    A: ArrayLike<Element = u8> + Send + Sync + 'static,
180{
181    fn type_path() -> &'static str {
182        "cu_sensor_payloads::CuImage"
183    }
184
185    fn short_type_path() -> &'static str {
186        "CuImage"
187    }
188
189    fn type_ident() -> Option<&'static str> {
190        Some("CuImage")
191    }
192
193    fn crate_name() -> Option<&'static str> {
194        Some("cu_sensor_payloads")
195    }
196
197    fn module_path() -> Option<&'static str> {
198        Some("cu_sensor_payloads")
199    }
200}
201
202impl<A> Decode<()> for CuImage<A>
203where
204    A: ArrayLike<Element = u8> + Send + Sync + 'static,
205    CuHandle<A>: Decode<()>,
206{
207    fn decode<D: Decoder<Context = ()>>(decoder: &mut D) -> Result<Self, DecodeError> {
208        let seq: u64 = Decode::decode(decoder)?;
209        let format: CuImageBufferFormat = Decode::decode(decoder)?;
210        let buffer_handle: CuHandle<A> = Decode::decode(decoder)?;
211
212        Ok(Self {
213            seq,
214            format,
215            buffer_handle,
216        })
217    }
218}
219
220impl<'de, A> Deserialize<'de> for CuImage<A>
221where
222    A: ArrayLike<Element = u8> + Send + Sync + 'static,
223    CuHandle<A>: Deserialize<'de>,
224{
225    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
226    where
227        D: serde::Deserializer<'de>,
228    {
229        #[derive(Deserialize)]
230        struct CuImageWire<H> {
231            seq: u64,
232            format: CuImageBufferFormat,
233            handle: H,
234        }
235
236        let wire = CuImageWire::<CuHandle<A>>::deserialize(deserializer)?;
237        Ok(Self {
238            seq: wire.seq,
239            format: wire.format,
240            buffer_handle: wire.handle,
241        })
242    }
243}
244
245impl<A> Serialize for CuImage<A>
246where
247    A: ArrayLike<Element = u8> + Send + Sync + 'static,
248    CuHandle<A>: Serialize,
249{
250    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
251    where
252        S: Serializer,
253    {
254        use serde::ser::SerializeStruct;
255        let mut struct_ = serializer.serialize_struct("CuImage", 3)?;
256        struct_.serialize_field("seq", &self.seq)?;
257        struct_.serialize_field("format", &self.format)?;
258        struct_.serialize_field("handle", &self.buffer_handle)?;
259        struct_.end()
260    }
261}
262
263impl<A> CuImage<A>
264where
265    A: ArrayLike<Element = u8> + Send + Sync + 'static,
266{
267    pub fn new(format: CuImageBufferFormat, buffer_handle: CuHandle<A>) -> Self {
268        assert!(
269            format.is_valid(),
270            "Image format layout is invalid for the declared stride."
271        );
272        assert!(
273            format.required_bytes() <= buffer_handle.with_inner(|i| i.len()),
274            "Buffer size must at least match the format."
275        );
276        CuImage {
277            seq: 0,
278            format,
279            buffer_handle,
280        }
281    }
282}
283
284// Codegen gate; the inherent forwards below are what actually propagate the policy.
285impl<A> cu29::pool::HandleContentAware for CuImage<A> where
286    A: ArrayLike<Element = u8> + Send + Sync + 'static
287{
288}
289
290impl<A> CuImage<A>
291where
292    A: ArrayLike<Element = u8> + Send + Sync + 'static,
293{
294    /// Forward of [`CuHandle::payload_should_log`]. The unified-log encoder resolves
295    /// to this inherent method (via autoref-specialization) when the payload type is
296    /// `CuImage<A>`, so the configured `HandleContent` policy on the inner handle
297    /// drives whether the image bytes are written to the log.
298    pub fn payload_should_log(&self) -> bool {
299        self.buffer_handle.payload_should_log()
300    }
301
302    /// Forward of [`CuHandle::apply_handle_content_policy`]. The runtime calls this
303    /// (via autoref-specialization) on every source-produced `CuImage` payload to
304    /// stamp it with the source's configured `NodeLogging.handle_content` mode
305    /// before downstream consumers see it.
306    pub fn apply_handle_content_policy(&self, mode: cu29::pool::HandleContent) {
307        self.buffer_handle.apply_handle_content_policy(mode);
308    }
309
310    /// Consumer-side convenience: mark the underlying buffer as read. The unified-log
311    /// encoder records the full payload for this frame (when the source uses
312    /// `HandleContent::TouchedOnly`); without this call the payload is skipped.
313    pub fn mark_touched(&self) {
314        self.buffer_handle.mark_touched();
315    }
316
317    pub fn with_plane_bytes<R>(
318        &self,
319        plane_index: usize,
320        f: impl FnOnce(&[u8], CuImagePlaneLayout) -> R,
321    ) -> CuResult<R> {
322        let plane = self
323            .format
324            .plane(plane_index)
325            .ok_or_else(|| CuError::from(format!("Invalid image plane index {plane_index}")))?;
326        Ok(self.buffer_handle.with_inner(|inner| {
327            let range = plane.byte_range();
328            f(&inner[range], plane)
329        }))
330    }
331
332    pub fn with_plane_bytes_mut<R>(
333        &mut self,
334        plane_index: usize,
335        f: impl FnOnce(&mut [u8], CuImagePlaneLayout) -> R,
336    ) -> CuResult<R> {
337        let plane = self
338            .format
339            .plane(plane_index)
340            .ok_or_else(|| CuError::from(format!("Invalid image plane index {plane_index}")))?;
341        Ok(self.buffer_handle.with_inner_mut(|inner| {
342            let range = plane.byte_range();
343            f(&mut inner[range], plane)
344        }))
345    }
346
347    /// Builds an ImageBuffer from the image crate backed by the CuImage's pixel data.
348    #[cfg(feature = "image")]
349    pub fn as_image_buffer<P: Pixel>(&self) -> CuResult<ImageBuffer<P, &[P::Subpixel]>> {
350        let width = self.format.width;
351        let height = self.format.height;
352        let plane = self
353            .format
354            .plane(0)
355            .ok_or_else(|| CuError::from("Image format has no addressable planes"))?;
356        if self.format.plane_count() != 1 {
357            return Err(CuError::from(
358                "ImageBuffer compatibility requires a single-plane packed image.",
359            ));
360        }
361        if plane.row_bytes != plane.stride_bytes {
362            return Err(CuError::from(
363                "ImageBuffer compatibility requires tightly packed rows without padding.",
364            ));
365        }
366
367        self.with_plane_bytes(0, |data, _| {
368            let raw_pixels: &[P::Subpixel] = unsafe {
369                core::slice::from_raw_parts(
370                    data.as_ptr() as *const P::Subpixel,
371                    data.len() / core::mem::size_of::<P::Subpixel>(),
372                )
373            };
374            ImageBuffer::from_raw(width, height, raw_pixels)
375                .ok_or("Could not create the image:: buffer".into())
376        })?
377    }
378
379    #[cfg(feature = "kornia")]
380    pub fn as_kornia_image<T: Clone, const C: usize, K: ImageAllocator>(
381        &self,
382        k: K,
383    ) -> CuResult<Image<T, C, K>> {
384        let width = self.format.width as usize;
385        let height = self.format.height as usize;
386        let plane = self
387            .format
388            .plane(0)
389            .ok_or_else(|| CuError::from("Image format has no addressable planes"))?;
390        if self.format.plane_count() != 1 {
391            return Err(CuError::from(
392                "Kornia compatibility requires a single-plane packed image.",
393            ));
394        }
395        if plane.row_bytes != plane.stride_bytes {
396            return Err(CuError::from(
397                "Kornia compatibility requires tightly packed rows without padding.",
398            ));
399        }
400
401        let size = width * height * C;
402        self.with_plane_bytes(0, |data, _| {
403            let raw_pixels: &[T] = unsafe {
404                core::slice::from_raw_parts(
405                    data.as_ptr() as *const T,
406                    data.len() / core::mem::size_of::<T>(),
407                )
408            };
409
410            unsafe { Image::from_raw_parts([height, width].into(), raw_pixels.as_ptr(), size, k) }
411                .map_err(|e| CuError::new_with_cause("Could not create a Kornia Image", e))
412        })?
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::{CuImageBufferFormat, CuImagePlaneLayout};
419
420    fn assert_plane(
421        plane: Option<CuImagePlaneLayout>,
422        offset_bytes: usize,
423        row_bytes: u32,
424        stride_bytes: u32,
425        height: u32,
426    ) {
427        assert_eq!(
428            plane,
429            Some(CuImagePlaneLayout {
430                offset_bytes,
431                row_bytes,
432                stride_bytes,
433                height,
434            })
435        );
436    }
437
438    #[test]
439    fn packed_rgb3_layout_uses_single_plane() {
440        let format = CuImageBufferFormat {
441            width: 4,
442            height: 2,
443            stride: 12,
444            pixel_format: *b"RGB3",
445        };
446
447        assert!(format.is_packed());
448        assert_eq!(format.plane_count(), 1);
449        assert_eq!(format.packed_row_bytes(), Some(12));
450        assert_plane(format.plane(0), 0, 12, 12, 2);
451        assert_eq!(format.required_bytes(), 24);
452        assert!(format.is_valid());
453    }
454
455    #[test]
456    fn nv12_layout_exposes_two_planes() {
457        let format = CuImageBufferFormat {
458            width: 640,
459            height: 360,
460            stride: 640,
461            pixel_format: *b"NV12",
462        };
463
464        assert!(!format.is_packed());
465        assert_eq!(format.plane_count(), 2);
466        assert_plane(format.plane(0), 0, 640, 640, 360);
467        assert_plane(format.plane(1), 230_400, 640, 640, 180);
468        assert_eq!(format.required_bytes(), 345_600);
469        assert!(format.is_valid());
470    }
471
472    #[test]
473    fn i420_layout_exposes_three_planes() {
474        let format = CuImageBufferFormat {
475            width: 640,
476            height: 360,
477            stride: 640,
478            pixel_format: *b"I420",
479        };
480
481        assert_eq!(format.plane_count(), 3);
482        assert_plane(format.plane(0), 0, 640, 640, 360);
483        assert_plane(format.plane(1), 230_400, 320, 320, 180);
484        assert_plane(format.plane(2), 288_000, 320, 320, 180);
485        assert_eq!(format.required_bytes(), 345_600);
486        assert!(format.is_valid());
487    }
488
489    #[test]
490    fn invalid_stride_is_detected_for_packed_formats() {
491        let format = CuImageBufferFormat {
492            width: 4,
493            height: 2,
494            stride: 4,
495            pixel_format: *b"RGB3",
496        };
497
498        assert!(!format.is_valid());
499        assert_eq!(format.packed_row_bytes(), Some(12));
500    }
501
502    #[test]
503    fn byte_size_for_packed_formats_is_stride_times_height() {
504        let format = CuImageBufferFormat {
505            width: 4,
506            height: 3,
507            stride: 16,
508            pixel_format: *b"BGRA",
509        };
510
511        assert_eq!(format.byte_size(), 48);
512    }
513
514    #[test]
515    fn byte_size_for_nv12_includes_uv_plane() {
516        let format = CuImageBufferFormat {
517            width: 1280,
518            height: 720,
519            stride: 1280,
520            pixel_format: *b"NV12",
521        };
522
523        assert_eq!(format.byte_size(), 1_382_400);
524    }
525
526    #[test]
527    fn byte_size_for_i420_includes_chroma_planes() {
528        let format = CuImageBufferFormat {
529            width: 640,
530            height: 480,
531            stride: 640,
532            pixel_format: *b"I420",
533        };
534
535        assert_eq!(format.byte_size(), 460_800);
536    }
537
538    // ---- only-log-what-you-use: end-to-end encode behavior on CuImage ----
539    //
540    // These tests stand in for the codegen-emitted per-slot encode block. They run
541    // the same logic the cu29-derive macro emits at each output slot:
542    //
543    //   1. Stamp the source's configured policy on the payload's CuHandle(s) via
544    //      `apply_handle_content_policy(mode)`.
545    //   2. Ask the (now-policy-aware) payload whether to log via `payload_should_log()`.
546    //   3. Either call the normal encoder (full payload) or `encode_metadata_only`
547    //      (presence tag 0u8 + tov + metadata, no payload bytes).
548    //
549    // Both step 1 and step 2 require concrete-type method dispatch (autoref
550    // specialization). The codegen-emitted block has concrete types; the generic
551    // `Encode for CuStampedData` impl does not, which is exactly why the policy
552    // check lives in codegen and not in the generic encoder.
553    //
554    // Running these tests against the real `encode_metadata_only` helper proves the
555    // full chain end-to-end without needing to spin up a `copper_runtime!` graph.
556    mod only_log_what_you_use {
557        use crate::CuImage;
558        use bincode::config;
559        use cu29::config::HandleContent;
560        use cu29::cutask::{CuMsg, encode_metadata_only};
561        use cu29::pool::CuHandle;
562        // No trait import needed here: `CuImage` defines inherent
563        // `apply_handle_content_policy` and `payload_should_log` methods that win
564        // method resolution. Codegen brings the fallback traits into scope because
565        // it doesn't know the payload's concrete type up front — for these tests
566        // the type is `CuImage<Vec<u8>>` so the inherent path always resolves.
567
568        const FORMAT: super::super::CuImageBufferFormat = super::super::CuImageBufferFormat {
569            width: 2,
570            height: 2,
571            stride: 2,
572            pixel_format: *b"GRAY",
573        };
574
575        fn make_image() -> CuImage<Vec<u8>> {
576            // Source-side default: handles are minted with HandleContent::All — only
577            // the codegen-emitted prelude flips them to the configured policy.
578            let handle = CuHandle::new_detached(vec![0xDE, 0xAD, 0xBE, 0xEF]);
579            CuImage::new(FORMAT, handle)
580        }
581
582        /// Mirrors `build_per_slot_encode_block`: stamp the policy, ask the payload,
583        /// either run the normal Encode or the metadata-only helper. Returns the
584        /// serialized byte stream — exactly what codegen would write for this slot.
585        ///
586        /// The `PayloadDefault*` trait imports at the top of this module bring the
587        /// autoref-specialization fallbacks into scope so method resolution picks
588        /// `CuImage`'s inherent overrides at this concrete-type call site (the same
589        /// trick codegen uses inside `build_per_slot_encode_block`).
590        fn encode_with_policy(msg: &CuMsg<CuImage<Vec<u8>>>, mode: HandleContent) -> Vec<u8> {
591            let should_log = match msg.payload() {
592                Some(p) => {
593                    p.apply_handle_content_policy(mode);
594                    p.payload_should_log()
595                }
596                None => false,
597            };
598
599            // Use an in-memory writer that grows on demand.
600            use bincode::enc::write::Writer;
601            struct VecWriter(Vec<u8>);
602            impl Writer for VecWriter {
603                fn write(&mut self, bytes: &[u8]) -> Result<(), bincode::error::EncodeError> {
604                    self.0.extend_from_slice(bytes);
605                    Ok(())
606                }
607            }
608            let mut encoder =
609                bincode::enc::EncoderImpl::new(VecWriter(Vec::new()), config::standard());
610            use bincode::enc::Encode as _;
611            if should_log {
612                msg.encode(&mut encoder).expect("encode");
613            } else {
614                encode_metadata_only(msg, &mut encoder).expect("encode metadata-only");
615            }
616            encoder.into_writer().0
617        }
618
619        /// Untouched + TouchedOnly → encoder writes the no-payload tag.
620        #[test]
621        fn touched_only_untouched_skips_payload() {
622            let image = make_image();
623            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
624            let bytes = encode_with_policy(&msg, HandleContent::TouchedOnly);
625            assert_eq!(
626                bytes.first().copied(),
627                Some(0u8),
628                "untouched TouchedOnly must emit no-payload tag"
629            );
630        }
631
632        /// Touched + TouchedOnly → encoder writes the full payload.
633        #[test]
634        fn touched_only_touched_keeps_payload() {
635            let image = make_image();
636            image.mark_touched();
637            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
638            let bytes = encode_with_policy(&msg, HandleContent::TouchedOnly);
639            assert_eq!(bytes.first().copied(), Some(1u8));
640            assert!(bytes.len() > 8, "payload bytes must follow the present tag");
641        }
642
643        /// `HandleContent::None` strips the payload regardless of touch state.
644        #[test]
645        fn none_always_skips_payload() {
646            let image = make_image();
647            image.mark_touched();
648            let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
649            let bytes = encode_with_policy(&msg, HandleContent::None);
650            assert_eq!(bytes.first().copied(), Some(0u8));
651        }
652
653        /// `HandleContent::All` always keeps the payload — codegen's zero-cost path
654        /// for the common case (it emits no prelude at all, but the result is the
655        /// same as calling apply_handle_content_policy(All)).
656        #[test]
657        fn all_mode_keeps_payload_with_or_without_touch() {
658            for touched in [false, true] {
659                let image = make_image();
660                if touched {
661                    image.mark_touched();
662                }
663                let msg: CuMsg<CuImage<Vec<u8>>> = CuMsg::new(Some(image));
664                let bytes = encode_with_policy(&msg, HandleContent::All);
665                assert_eq!(
666                    bytes.first().copied(),
667                    Some(1u8),
668                    "All mode must keep payload (touched={touched})"
669                );
670            }
671        }
672
673        /// A consumer that holds a cloned handle and marks it touched is observed by
674        /// the encoder, which reads the original. This is the multi-task case.
675        #[test]
676        fn touched_flag_is_shared_across_clones() {
677            let image = make_image();
678            image.apply_handle_content_policy(HandleContent::TouchedOnly);
679            let consumer_view = image.buffer_handle.clone();
680            let _ = consumer_view.with_touched_inner(|inner| inner.as_ref()[0]);
681            assert!(image.payload_should_log());
682        }
683    }
684}