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