Skip to main content

zencodec_render/
render.rs

1use alloc::{boxed::Box, sync::Arc};
2use core::fmt;
3use zencodec::{
4    At, CodecError,
5    collection::{Cause, Collection, ImageId, ImageRequest},
6    image::{ImageOutput, OutputError, PackedImageOwner, Rect},
7    operation::{
8        CheckedCollection, CollectionOperationError, OperationPolicy, Outcome, PolicySet,
9        resolve_image,
10    },
11    storage::{SharedStorage, StorageBudget, StorageError},
12};
13use zenpixels::{Cicp, ColorContext, ColorOrigin, PixelDescriptor, PixelSlice, PixelSliceMut};
14use zenpixels_convert::{ConvertOptions, OutputPlan, OutputProfile, cms::PluggableCms};
15
16/// Current means current samples, not the original file's pre-transform space.
17/// Explicit targets never consult opaque source extensions for color authority.
18#[derive(Clone)]
19#[non_exhaustive]
20pub enum ColorTarget {
21    /// Preserve the current sample color space, not original-file provenance.
22    Current,
23    /// Convert to this named color space.
24    Named(Cicp),
25    /// Convert to this ICC profile, requiring a suitable supplied CMS.
26    Icc(Arc<[u8]>),
27}
28/// Required display output. No unsupported setting is silently ignored.
29pub struct RenderRequest {
30    /// Required output color space.
31    pub target: ColorTarget,
32    /// Required packed pixel descriptor, including alpha interpretation.
33    pub required: PixelDescriptor,
34    /// Oriented canvas coordinates. None requests the full oriented canvas.
35    pub region: Option<Rect>,
36    /// Explicit conversion behavior, including clipping and channel loss.
37    pub conversion: ConvertOptions,
38}
39impl Default for RenderRequest {
40    fn default() -> Self {
41        Self {
42            target: ColorTarget::Named(Cicp::new(1, 13, 0, true)),
43            required: PixelDescriptor::RGBA8_SRGB,
44            region: None,
45            conversion: ConvertOptions::permissive(),
46        }
47    }
48}
49impl RenderRequest {
50    /// Require a descriptor in the current sample color space.
51    pub fn new(required: PixelDescriptor) -> Self {
52        Self {
53            target: ColorTarget::Current,
54            required,
55            region: None,
56            conversion: ConvertOptions::retain_channels(),
57        }
58    }
59    /// Set an explicit output color space.
60    pub fn with_target(mut self, target: ColorTarget) -> Self {
61        self.target = target;
62        self
63    }
64    /// Select an oriented-canvas rectangle; None means the complete canvas.
65    pub fn with_region(mut self, region: Option<Rect>) -> Self {
66        self.region = region;
67        self
68    }
69    /// Set explicit conversion behavior.
70    pub fn with_conversion(mut self, conversion: ConvertOptions) -> Self {
71        self.conversion = conversion;
72        self
73    }
74}
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76#[non_exhaustive]
77/// Stage at which rendering failed.
78pub enum RenderStage {
79    /// Prepare a transform for the held source.
80    ColorPlan,
81    /// Apply orientation and display-coordinate crop.
82    Geometry,
83    /// Execute the prepared color conversion.
84    Color,
85    /// Ask the operation policy before returning output.
86    Publication,
87}
88/// Rendering failure retaining the concrete original cause.
89pub struct RenderFailure {
90    /// Stage that failed.
91    pub stage: RenderStage,
92    cause: Cause,
93}
94impl fmt::Debug for RenderFailure {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.debug_struct("RenderFailure")
97            .field("stage", &self.stage)
98            .finish_non_exhaustive()
99    }
100}
101impl fmt::Display for RenderFailure {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "selected-image render failed during {:?}", self.stage)
104    }
105}
106impl core::error::Error for RenderFailure {
107    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
108        Some(self.cause.as_ref())
109    }
110}
111fn fail(stage: RenderStage, cause: impl Into<Cause>) -> At<RenderFailure> {
112    At::wrap(RenderFailure {
113        stage,
114        cause: cause.into(),
115    })
116}
117fn check(pulse: &dyn zencodec::Pulse) -> Result<(), Cause> {
118    pulse
119        .check()
120        .map_err(|reason| At::wrap(CodecError::new(None, reason.into())).into())
121}
122
123struct Color {
124    data: SharedStorage<u64>,
125    width: u32,
126    rows: u32,
127    stride: usize,
128    descriptor: PixelDescriptor,
129    context: Arc<ColorContext>,
130}
131impl PackedImageOwner for Color {
132    fn pixels(&self) -> Result<PixelSlice<'_>, At<OutputError>> {
133        PixelSlice::new(
134            bytemuck::cast_slice(self.data.as_slice()),
135            self.width,
136            self.rows,
137            self.stride,
138            self.descriptor,
139        )
140        .map(|pixels| pixels.with_color_context(self.context.clone()))
141        .map_err(|cause| {
142            At::wrap(OutputError::Mapping(zencodec::frame::MapError::Backend(
143                Box::new(cause),
144            )))
145        })
146    }
147}
148
149/// RenderRequest configuration and the admitted destination pool can be reused; a
150/// fresh OutputPlan binds each exact held source. Never cache by item/epoch alone.
151pub struct Renderer<'a> {
152    /// Reusable requested presentation.
153    pub request: RenderRequest,
154    /// Managed output budget; excludes native codec and CMS workspace.
155    pub storage: StorageBudget,
156    /// Optional color-management implementation. Unsupported conversions fail.
157    pub cms: Option<&'a dyn PluggableCms>,
158}
159impl<'a> Renderer<'a> {
160    /// Render to sRGB RGBA8 over the full oriented canvas by default.
161    /// Color conversion rounds depth reduction and clips to the display range.
162    /// Use an explicit request for higher precision or extended-range output.
163    pub fn new(storage: StorageBudget) -> Self {
164        Self {
165            request: RenderRequest::default(),
166            storage,
167            cms: None,
168        }
169    }
170    /// Replace the reusable output requirements.
171    pub fn with_request(mut self, request: RenderRequest) -> Self {
172        self.request = request;
173        self
174    }
175    /// Supply a CMS for profile conversions that require one.
176    pub fn with_cms(mut self, cms: &'a dyn PluggableCms) -> Self {
177        self.cms = Some(cms);
178        self
179    }
180
181    /// Render the first top-level image in discovery order. Returns `None` only
182    /// when discovery succeeds with no images. Discovery, decoding, rendering
183    /// and policy errors propagate; unsupported images are never skipped.
184    /// Each call starts discovery anew; use a cursor and `render_selected` to
185    /// traverse a collection. This does not choose a thumbnail or child image.
186    pub fn render_first<C: Collection, R: OperationPolicy>(
187        &self,
188        images: &mut CheckedCollection<C, R>,
189    ) -> Result<Option<Outcome<ImageOutput>>, At<CollectionOperationError>> {
190        let mut cursor = images.images()?;
191        images
192            .next(&mut cursor)?
193            .map(|id| self.render_selected(images, &id))
194            .transpose()
195    }
196
197    /// Consumer convenience over the shared collection operation. The current
198    /// candidate reconstructs the full native selection; its display crop does
199    /// not promise proportional native work or bypass native dimension limits.
200    pub fn render_selected<C: Collection, R: OperationPolicy>(
201        &self,
202        images: &mut CheckedCollection<C, R>,
203        image: &ImageId,
204    ) -> Result<Outcome<ImageOutput>, At<CollectionOperationError>> {
205        images.decode_with(image, &ImageRequest::new(), |held, pulse| {
206            self.apply(held, Some(pulse))
207        })
208    }
209
210    /// Render an already decoded image, then publish once through policy.
211    /// `None` adds no per-call control; policy and storage checks still apply.
212    pub fn render<R: OperationPolicy + ?Sized>(
213        &self,
214        image: ImageOutput,
215        policy: &mut R,
216        pulse: Option<&dyn zencodec::Pulse>,
217    ) -> Result<Outcome<ImageOutput>, At<RenderFailure>> {
218        let output = self.apply(image, pulse)?;
219        resolve_image(output, policy, PolicySet::new(), pulse)
220            .map_err(|e| fail(RenderStage::Publication, e))
221    }
222
223    /// Compatibility entry point for separately configured presets.
224    /// Both sets of requirements apply; bound policy preferences take priority.
225    #[deprecated(note = "use render with a PolicySet or BoundPolicy")]
226    pub fn render_with_preset<R: OperationPolicy + ?Sized>(
227        &self,
228        image: ImageOutput,
229        resolver: &mut R,
230        preset: PolicySet,
231        pulse: Option<&dyn zencodec::Pulse>,
232    ) -> Result<Outcome<ImageOutput>, At<RenderFailure>> {
233        let output = self.apply(image, pulse)?;
234        resolve_image(output, resolver, preset, pulse)
235            .map_err(|e| fail(RenderStage::Publication, e))
236    }
237
238    fn apply(
239        &self,
240        image: ImageOutput,
241        pulse: Option<&dyn zencodec::Pulse>,
242    ) -> Result<ImageOutput, At<RenderFailure>> {
243        let control = pulse.unwrap_or(&zencodec::NoPulse);
244        check(control).map_err(|e| fail(RenderStage::ColorPlan, e))?;
245        // Prepare the actual transform before allocating projected samples.
246        let mut plan = image
247            .with_pixels(|pixels| {
248                let target = match &self.request.target {
249                    ColorTarget::Current => OutputProfile::SameAsOrigin,
250                    ColorTarget::Named(cicp) => OutputProfile::Named(*cicp),
251                    ColorTarget::Icc(icc) => OutputProfile::Icc(icc.clone()),
252                };
253                OutputPlan::new_with_descriptor(
254                    pixels.descriptor(),
255                    pixels.color_context().cloned(),
256                    &ColorOrigin::assumed(),
257                    target,
258                    self.request.required,
259                    &self.request.conversion,
260                    self.cms,
261                )
262            })
263            .map_err(|e| fail(RenderStage::ColorPlan, e))?
264            .map_err(|e| fail(RenderStage::ColorPlan, e))?;
265        let projected = crate::geometry::project(&image, self.request.region, &self.storage, pulse)
266            .map_err(|e| fail(RenderStage::Geometry, e))?;
267        // Do not retain native uncompressed color while preparing/converting a
268        // replacement. Independently held caller clones remain charged.
269        drop(image);
270        let unchanged = projected
271            .with_pixels(|pixels| {
272                plan.borrow_rows(pixels.clone()).map(|borrowed| {
273                    borrowed.is_some_and(|view| {
274                        view.descriptor() == pixels.descriptor()
275                            && view.color_context() == pixels.color_context()
276                    })
277                })
278            })
279            .map_err(|e| fail(RenderStage::Color, e))?
280            .map_err(|e| fail(RenderStage::Color, e))?;
281        let output = if unchanged {
282            projected
283        } else {
284            let color = projected
285                .with_pixels(|pixels| self.convert(pixels, &mut plan, control))
286                .map_err(|e| fail(RenderStage::Color, e))?
287                .map_err(|e| fail(RenderStage::Color, e))?;
288            projected
289                .replace_packed_color(color)
290                .map_err(|e| fail(RenderStage::Color, e))?
291        };
292        Ok(output)
293    }
294
295    fn convert(
296        &self,
297        source: PixelSlice<'_>,
298        plan: &mut OutputPlan,
299        pulse: &dyn zencodec::Pulse,
300    ) -> Result<Color, Cause> {
301        let descriptor = plan.output_descriptor();
302        let (width, rows) = (source.width(), source.rows());
303        let stride = (width as usize)
304            .checked_add(8)
305            .map(|n| n & !7)
306            .and_then(|n| n.checked_mul(descriptor.bytes_per_pixel()))
307            .ok_or(StorageError::SizeOverflow)?;
308        let bytes = stride
309            .checked_mul(rows as usize)
310            .ok_or(StorageError::SizeOverflow)?;
311        check(pulse)?;
312        plan.prepare(width)?;
313        check(pulse)?;
314        let mut data = self.storage.filled(bytes / 8, 0xa5a5_a5a5_a5a5_a5a5u64)?;
315        let mut context = None;
316        // Check cancellation between rows. Partially written storage remains
317        // private and drops on failure; no resolver observes a partial image.
318        for y in 0..rows {
319            check(pulse)?;
320            let offset = y as usize * stride;
321            let destination = PixelSliceMut::new(
322                &mut bytemuck::cast_slice_mut(data.as_mut_slice())[offset..offset + stride],
323                width,
324                1,
325                stride,
326                descriptor,
327            )?;
328            let converted = plan.convert_rows(source.crop_view(0, y, width, 1), destination)?;
329            context = converted.color_context().cloned();
330        }
331        check(pulse)?;
332        Ok(Color {
333            data: data.freeze(),
334            width,
335            rows,
336            stride,
337            descriptor,
338            context: context.ok_or(OutputError::MissingPlane)?,
339        })
340    }
341}