Skip to main content

nsi_ffi_wrap/output/
mod.rs

1#![cfg_attr(feature = "nightly", doc(cfg(feature = "output")))]
2// The triple-Box (`Box<Box<Box<dyn Fn…>>>`) pattern below is required for
3// crossing the C-FFI boundary: the outer Box turns the trait object into a
4// thin pointer, the middle Box is captured by ndspy as `void*`, and the
5// inner Box is what the user originally allocated. Clippy doesn't recognise
6// the FFI shape and flags it as `redundant_allocation`. Same for the `new`
7// constructor that returns a `*mut`-shaped state for ndspy to keep — it's
8// not idiomatic Rust but it IS the C-API contract.
9#![allow(clippy::redundant_allocation)]
10#![allow(clippy::new_ret_no_self)]
11//! Output driver callbacks.
12//!
13//! This module provides type-safe, generic callback support for streaming pixel
14//! data during and after renders. Callbacks are generic over pixel type (`f32`,
15//! `u16`, `u8`, etc.) with zero runtime cost via monomorphization.
16//!
17//! ## Callback Types
18//!
19//! * [`FnOpen`] -- Called once when the output driver is opened by the renderer.
20//!
21//! * [`FnWrite`] -- Called for each bucket of pixel data. Generic over pixel
22//!   type `T: PixelType`. Receives only the bucket data, not the full image.
23//!
24//! * [`FnFinish`] -- Called once when the output driver is closed. Does NOT
25//!   receive pixel data (matching the C ndspy API). Use [`AccumulatingCallbacks`]
26//!   if you need the full accumulated image.
27//!
28//! ## Pixel Types
29//!
30//! The callbacks are generic over pixel type via the [`PixelType`] trait.
31//! Use the corresponding typed driver constant:
32//!
33//! * [`FERRIS_F32`] -- 32-bit float pixels.
34//! * [`FERRIS_U32`] / [`FERRIS_I32`] -- 32-bit integer pixels.
35//! * [`FERRIS_U16`] / [`FERRIS_I16`] -- 16-bit integer pixels.
36//! * [`FERRIS_U8`] / [`FERRIS_I8`] -- 8-bit integer pixels.
37//!
38//! ## Example: Streaming buckets
39//!
40//! ```ignore
41//! use nsi_ffi_wrap as nsi;
42//! use std::sync::{Arc, Mutex};
43//!
44//! let pixels = Arc::new(Mutex::new(Vec::<f32>::new()));
45//! let pixels_write = Arc::clone(&pixels);
46//!
47//! // Write callback receives each bucket as it's rendered.
48//! let write = nsi::output::WriteCallback::<f32>::new(
49//!     move |_name, width, _height, x0, x1, y0, y1, fmt, bucket: &[f32]| {
50//!         let mut buf = pixels_write.lock().unwrap();
51//!         // Accumulate bucket into full image buffer...
52//!         nsi::output::Error::None
53//!     },
54//! );
55//!
56//! ctx.set_attribute(
57//!     "driver",
58//!     &[
59//!         nsi::string!("drivername", nsi::output::FERRIS_F32),
60//!         nsi::string!("imagefilename", "render"),
61//!         nsi::callback!("callback.write", write),
62//!     ],
63//! );
64//! ```
65//!
66//! ## Example: Using AccumulatingCallbacks
67//!
68//! For the common case where you need the full accumulated image at the end,
69//! use [`AccumulatingCallbacks`]:
70//!
71//! ```ignore
72//! use nsi_ffi_wrap as nsi;
73//!
74//! let (write, finish) = nsi::output::AccumulatingCallbacks::<f32>::new(
75//!     |name, width, height, fmt, pixels: Vec<f32>| {
76//!         // Called once with the complete accumulated image.
77//!         write_exr(&name, width, height, &pixels);
78//!         nsi::output::Error::None
79//!     },
80//! );
81//!
82//! ctx.set_attribute(
83//!     "driver",
84//!     &[
85//!         nsi::string!("drivername", nsi::output::FERRIS_F32),
86//!         nsi::string!("imagefilename", "render"),
87//!         nsi::callback!("callback.write", write),
88//!         nsi::callback!("callback.finish", finish),
89//!     ],
90//! );
91//! ```
92//!
93//! ## Color Profiles
94//!
95//! The pixel color data that the renderer generates is linear and
96//! scene-referred. I.e. relative to whatever units you used to describe
97//! illuminants in your scene.
98//!
99//! Using the
100//! [`"colorprofile"` attribute](https://nsi.readthedocs.io/en/latest/nodes.html?highlight=outputlayer#the-outputlayer-node)
101//! of an [`OutputLayer`](crate::OUTPUT_LAYER) you can ask the
102//! renderer to apply an [Open Color IO](https://opencolorio.org/) (OCIO)
103//! [profile/LUT](https://github.com/colour-science/OpenColorIO-Configs/tree/feature/aces-1.2-config/aces_1.2/luts)
104//! before quantizing (see below).
105//!
106//! Once OCIO has a [Rust wrapper](https://crates.io/crates/opencolorio) you can easily choose to
107//! do these color conversions yourself. In the meantime there is the
108//! [`colorspace`](https://crates.io/crates/colorspace) crate which has some useful profiles built
109//! in, e.g. [ACEScg](https://en.wikipedia.org/wiki/Academy_Color_Encoding_System#ACEScg).
110//!
111//! ```
112//! # use nsi_ffi_wrap as nsi;
113//! # let ctx = nsi::Context::new(None).unwrap();
114//! ctx.create("beauty", nsi::OUTPUT_LAYER, None);
115//! ctx.set_attribute(
116//!     "beauty",
117//!     &[
118//!         // The Ci variable comes from Open Shading Language.
119//!         nsi::string!("variablename", "Ci"),
120//!         // We want the pixel data 'display-referred' in sRGB and quantized down to 0.0..255.0.
121//!         nsi::string!("colorprofile", "/home/luts/linear_to_sRGB.spi1d"),
122//!         nsi::string!("scalarformat", "uint8"),
123//!     ],
124//! );
125//! ```
126//!
127//! ## Quantization
128//!
129//! Using the [`"scalarformat"`
130//! attribute](https://nsi.readthedocs.io/en/latest/nodes.html?highlight=outputlayer#the-outputlayer-node)
131//! of an [`OutputLayer`](crate::OUTPUT_LAYER) you can ask the
132//! renderer to quantize data down to a suitable range. For example, setting
133//! this to `"uint16"` will get you valid `u16` values from `0.0..65535.0`, but
134//! stored in the `f32`s of the `pixel_data` buffer. The value of `1.0` will map
135//! to `65535.0` and everything above will be clipped. You can convert
136//! such a value straight via `f32 as u16`.
137//!
138//! Unless you asked the renderer to also apply some color profile (see above)
139//! the data is linear. To look good on a screen it needs to be made
140//! display-referred.
141//!
142//! See the `output` example on how to do this with a simple, display-referred
143//! `sRGB` curve.
144use crate::argument::CallbackPtr;
145use std::{
146    ffi::CStr,
147    mem::size_of,
148    os::raw::{c_char, c_int, c_void},
149};
150
151pub mod pixel_format;
152pub use pixel_format::*;
153
154pub mod pixel_type;
155pub use pixel_type::*;
156
157/// Driver name for f32 pixel type.
158pub static FERRIS_F32: &str = "ferris_f32";
159/// Driver name for u32 pixel type.
160pub static FERRIS_U32: &str = "ferris_u32";
161/// Driver name for i32 pixel type.
162pub static FERRIS_I32: &str = "ferris_i32";
163/// Driver name for u16 pixel type.
164pub static FERRIS_U16: &str = "ferris_u16";
165/// Driver name for i16 pixel type.
166pub static FERRIS_I16: &str = "ferris_i16";
167/// Driver name for u8 pixel type.
168pub static FERRIS_U8: &str = "ferris_u8";
169/// Driver name for i8 pixel type.
170pub static FERRIS_I8: &str = "ferris_i8";
171
172/// Legacy driver name - defaults to f32.
173/// Deprecated: Use [`FERRIS_F32`], [`FERRIS_U16`], etc. for type-specific drivers.
174#[deprecated(
175    since = "0.9.0",
176    note = "Use FERRIS_F32, FERRIS_U16, etc. for type-specific drivers"
177)]
178pub static FERRIS: &str = "ferris_f32";
179
180/// An error type the callbacks return to communicate with the
181/// renderer.
182#[repr(u32)]
183#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, num_enum::IntoPrimitive)]
184pub enum Error {
185    /// Everything is dandy.
186    None = ndspy_sys::PtDspyError::None as _,
187    /// We ran out of memory.
188    NoMemory = ndspy_sys::PtDspyError::NoMemory as _,
189    /// We do no support this request.
190    Unsupported = ndspy_sys::PtDspyError::Unsupported as _,
191    BadParameters = ndspy_sys::PtDspyError::BadParams as _,
192    NoResource = ndspy_sys::PtDspyError::NoResource as _,
193    /// Something else went wrong.
194    Undefined = ndspy_sys::PtDspyError::Undefined as _,
195    /// Stop the render.
196    Stop = ndspy_sys::PtDspyError::Stop as _,
197}
198
199impl From<Error> for ndspy_sys::PtDspyError {
200    fn from(item: Error) -> ndspy_sys::PtDspyError {
201        match item {
202            Error::None => ndspy_sys::PtDspyError::None,
203            Error::NoMemory => ndspy_sys::PtDspyError::NoMemory,
204            Error::Unsupported => ndspy_sys::PtDspyError::Unsupported,
205            Error::BadParameters => ndspy_sys::PtDspyError::BadParams,
206            Error::NoResource => ndspy_sys::PtDspyError::NoResource,
207            Error::Undefined => ndspy_sys::PtDspyError::Undefined,
208            Error::Stop => ndspy_sys::PtDspyError::Stop,
209        }
210    }
211}
212
213/// A closure which is called once per
214/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance.
215///
216/// It is passed to ɴsɪ via the `"callback.open"` attribute on that node.
217///
218/// The closure is called once, before the renderer starts sending pixels to the
219/// output driver.
220///
221/// # Arguments
222/// The `pixel_format` parameter is an array of strings that details the
223/// composition of the `f32` data that the renderer will send to the [`FnWrite`]
224/// and/or [`FnFinish`] closures.
225///
226/// # Example
227/// ```
228/// # #[cfg(feature = "output")]
229/// # {
230/// # use nsi_ffi_wrap as nsi;
231/// # use nsi::output::PixelFormat;
232/// # let ctx = nsi::Context::new(None).unwrap();
233/// # ctx.create("display_driver", nsi::OUTPUT_DRIVER, None);
234/// let open = nsi::output::OpenCallback::new(
235///     |name: &str,
236///      width: usize,
237///      height: usize,
238///      pixel_format: &nsi::output::PixelFormat| {
239///         println!(
240///             "Resolution: {}×{}\nPixel Format:\n{:?}",
241///             width, height, pixel_format
242///         );
243///         nsi::output::Error::None
244///     },
245/// );
246/// # }
247/// ```
248pub trait FnOpen<'a>: FnMut(
249    // Filename.
250    &str,
251    // Width.
252    usize,
253    // Height.
254    usize,
255    // Pixel format.
256    &PixelFormat,
257) -> Error
258+ 'a {}
259
260#[doc(hidden)]
261impl<'a, T: FnMut(&str, usize, usize, &PixelFormat) -> Error + 'a> FnOpen<'a>
262    for T
263{
264}
265
266// FIXME once trait aliases are in stable.
267/*
268trait FnOpen<'a> = FnMut(
269        // Filename.
270        &str,
271        // Width.
272        usize,
273        // Height.
274        usize,
275        // Pixel format.
276        &PixelFormat,
277    ) -> Error
278    + 'a
279*/
280
281/// A closure which is called for each bucket of pixels the
282/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance sends
283/// during rendering.
284///
285/// The closure receives ONLY the bucket data, not an accumulated image.
286/// Bucket dimensions are: `(x_max_plus_one - x_min) x (y_max_plus_one - y_min)`.
287/// Data layout is row-major with channels interleaved per pixel.
288///
289/// It is passed to ɴsɪ via the `"callback.write"` attribute on that node.
290///
291/// # Type Parameter
292///
293/// `T` is the pixel scalar type (e.g., `f32`, `u16`, `u8`). It must implement
294/// [`PixelType`]. The driver name must match the type (e.g., `FERRIS_F32` for `f32`).
295///
296/// # Example
297/// ```ignore
298/// let write = nsi::output::WriteCallback::<f32>::new(
299///     |name: &str,
300///      width: usize,
301///      height: usize,
302///      x_min: usize,
303///      x_max_plus_one: usize,
304///      y_min: usize,
305///      y_max_plus_one: usize,
306///      pixel_format: &nsi::output::PixelFormat,
307///      bucket_data: &[f32]| {
308///         // bucket_data contains ONLY this bucket, not the full image
309///         // Upload bucket to GPU texture, etc.
310///         nsi::output::Error::None
311///     },
312/// );
313///
314/// ctx.set_attribute(
315///     "driver",
316///     &[
317///         nsi::string!("drivername", nsi::output::FERRIS_F32),
318///         nsi::callback!("callback.write", write),
319///     ],
320/// );
321/// ```
322pub trait FnWrite<'a, T: PixelType>: FnMut(
323        // Filename.
324        &str,
325        // Full image width.
326        usize,
327        // Full image height.
328        usize,
329        // Bucket x_min.
330        usize,
331        // Bucket x_max_plus_one.
332        usize,
333        // Bucket y_min.
334        usize,
335        // Bucket y_max_plus_one.
336        usize,
337        // Pixel format.
338        &PixelFormat,
339        // Bucket pixel data (NOT the full image!).
340        &[T],
341    ) -> Error
342    + 'a {}
343
344#[doc(hidden)]
345impl<'a, T: PixelType, F> FnWrite<'a, T> for F where
346    F: FnMut(
347            &str,
348            usize,
349            usize,
350            usize,
351            usize,
352            usize,
353            usize,
354            &PixelFormat,
355            &[T],
356        ) -> Error
357        + 'a
358{
359}
360
361/// A closure which is called once per
362/// [`OutputDriver`](crate::OUTPUT_DRIVER) instance when rendering completes.
363///
364/// It is passed to ɴsɪ via the `"callback.finish"` attribute on that node.
365///
366/// **Note:** This closure does NOT receive pixel data. This matches the C ndspy
367/// API behavior where the display driver is responsible for accumulating pixels
368/// if needed. Use [`AccumulatingCallbacks`] if you need the complete image.
369///
370/// # Example
371/// ```ignore
372/// let finish = nsi::output::FinishCallback::new(
373///     |name: String,
374///      width: usize,
375///      height: usize,
376///      pixel_format: nsi::output::PixelFormat| {
377///         println!("Rendering complete: {}x{}", width, height);
378///         nsi::output::Error::None
379///     },
380/// );
381///
382/// ctx.set_attribute(
383///     "driver",
384///     &[
385///         nsi::string!("drivername", nsi::output::FERRIS_F32),
386///         nsi::callback!("callback.finish", finish),
387///     ],
388/// );
389/// ```
390pub trait FnFinish<'a>: FnMut(
391    // Filename.
392    String,
393    // Width.
394    usize,
395    // Height.
396    usize,
397    // Pixel format.
398    PixelFormat,
399) -> Error
400+ 'a {}
401
402#[doc(hidden)]
403impl<'a, F> FnFinish<'a> for F where
404    F: FnMut(String, usize, usize, PixelFormat) -> Error + 'a
405{
406}
407
408enum Query {}
409
410trait FnQuery<'a>: FnMut(Query) -> Error + 'a {}
411impl<'a, T: FnMut(Query) -> Error + 'a> FnQuery<'a> for T {}
412
413// FIXME once trait aliases are in stable.
414/*
415pub trait FnQuery<'a> = dyn FnMut(Query) -> Error + 'a;
416*/
417
418/// Wrapper to pass an [`FnOpen`] closure to an
419/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
420pub struct OpenCallback<'a>(Box<Box<Box<dyn FnOpen<'a>>>>);
421
422// Why do we need a triple Box here? Why does a Box<Box<T>> not suffice?
423// This is a known pattern for passing closures through FFI boundaries.
424// The issue is related to fat pointers (trait objects):
425// - Box<dyn Trait> is a fat pointer (16 bytes: data ptr + vtable ptr)
426// - Box<Box<dyn Trait>> is a thin pointer (8 bytes) to the fat pointer
427// - Box<Box<Box<dyn Trait>>> is a thin pointer to a thin pointer
428//
429// When casting through *const c_void, type information is lost.
430// With double Box, reconstructing the fat pointer fails (segfault).
431// Triple Box ensures we always deal with thin pointers at the FFI boundary.
432impl<'a> OpenCallback<'a> {
433    pub fn new<F>(fn_open: F) -> Self
434    where
435        F: FnOpen<'a>,
436    {
437        OpenCallback(Box::new(Box::new(Box::new(fn_open))))
438    }
439}
440
441impl CallbackPtr for OpenCallback<'_> {
442    #[doc(hidden)]
443    fn to_ptr(self) -> *const core::ffi::c_void {
444        Box::into_raw(self.0) as *const _ as _
445    }
446}
447/// Wrapper to pass an [`FnWrite`] closure to an
448/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
449///
450/// # Type Parameter
451///
452/// `T` is the pixel scalar type. Use the matching driver name:
453/// - `WriteCallback::<f32>` with `FERRIS_F32`
454/// - `WriteCallback::<u16>` with `FERRIS_U16`
455/// - etc.
456pub struct WriteCallback<'a, T: PixelType>(Box<Box<Box<dyn FnWrite<'a, T>>>>);
457
458impl<'a, T: PixelType> WriteCallback<'a, T> {
459    pub fn new<F>(fn_write: F) -> Self
460    where
461        F: FnWrite<'a, T>,
462    {
463        WriteCallback(Box::new(Box::new(Box::new(fn_write))))
464    }
465}
466
467impl<T: PixelType> CallbackPtr for WriteCallback<'_, T> {
468    #[doc(hidden)]
469    fn to_ptr(self) -> *const core::ffi::c_void {
470        Box::into_raw(self.0) as *const _ as _
471    }
472}
473
474/// Wrapper to pass an [`FnFinish`] closure to an
475/// [`OutputDriver`](crate::OUTPUT_DRIVER) node.
476///
477/// **Note:** `FnFinish` does not receive pixel data. If you need the complete
478/// accumulated image, use [`AccumulatingCallbacks`] instead.
479pub struct FinishCallback<'a>(Box<Box<Box<dyn FnFinish<'a>>>>);
480
481impl<'a> FinishCallback<'a> {
482    pub fn new<F>(fn_finish: F) -> Self
483    where
484        F: FnFinish<'a>,
485    {
486        FinishCallback(Box::new(Box::new(Box::new(fn_finish))))
487    }
488}
489
490impl CallbackPtr for FinishCallback<'_> {
491    #[doc(hidden)]
492    fn to_ptr(self) -> *const core::ffi::c_void {
493        Box::into_raw(self.0) as *const _ as _
494    }
495}
496
497/// Internal data structure passed through FFI as the image handle.
498/// Generic over pixel type T for zero-cost type handling.
499struct DisplayData<'a, T: PixelType> {
500    name: String,
501    width: usize,
502    height: usize,
503    pixel_format: PixelFormat,
504    // NO pixel_data buffer - we pass buckets directly to callbacks
505    fn_write: Option<Box<Box<Box<dyn FnWrite<'a, T>>>>>,
506    fn_finish: Option<Box<Box<Box<dyn FnFinish<'a>>>>>,
507    // FIXME: unused atm.
508    fn_query: Option<Box<Box<Box<dyn FnQuery<'a>>>>>,
509    // PhantomData to ensure T is used
510    _phantom: std::marker::PhantomData<T>,
511}
512
513fn extract_callback<T: ?Sized>(
514    name: &str,
515    type_: u8,
516    len: usize,
517    parameters: &[ndspy_sys::UserParameter],
518) -> Option<Box<Box<Box<T>>>> {
519    for p in parameters.iter() {
520        // SAFETY: Parameter names come from NSI API and should be valid C strings
521        if p.name.is_null() {
522            continue;
523        }
524        let p_name = match unsafe { CStr::from_ptr(p.name) }.to_str() {
525            Ok(name) => name,
526            Err(_) => continue,
527        };
528
529        if name == p_name
530            && type_ == p.valueType as _
531            && len == p.valueCount as _
532        {
533            if !p.value.is_null() {
534                // SAFETY: p.value was created by Box::into_raw in the callback's to_ptr method
535                // The type cast is valid because we verified the parameter type matches
536                return Some(unsafe {
537                    Box::from_raw(p.value as *mut Box<Box<T>>)
538                });
539            } else {
540                // Parameter exists but value is missing - exit quietly.
541                break;
542            }
543        }
544    }
545    None
546}
547
548// Generic trampoline function for the FnOpen callback.
549// Each instantiation (image_open::<f32>, image_open::<u16>, etc.) handles a specific pixel type.
550pub(crate) extern "C" fn image_open<T: PixelType>(
551    image_handle_ptr: *mut ndspy_sys::PtDspyImageHandle,
552    _driver_name: *const c_char,
553    output_filename: *const c_char,
554    width: c_int,
555    height: c_int,
556    parameters_count: c_int,
557    parameters: *const ndspy_sys::UserParameter,
558    format_count: c_int,
559    format: *mut ndspy_sys::PtDspyDevFormat,
560    flag_stuff: *mut ndspy_sys::PtFlagStuff,
561) -> ndspy_sys::PtDspyError {
562    // Catch any panics to prevent unwinding into C code.
563    match std::panic::catch_unwind(|| {
564        if (image_handle_ptr.is_null())
565            || (output_filename.is_null())
566            || format.is_null()
567            || (format_count <= 0)
568            || ((parameters_count > 0) && parameters.is_null())
569        {
570            return Error::BadParameters.into();
571        }
572
573        // SAFETY: We only read from the parameters slice, never modify it.
574        let parameters = unsafe {
575            std::slice::from_raw_parts(parameters, parameters_count as _)
576        };
577
578        let mut display_data = Box::new(DisplayData::<T> {
579            name: {
580                // SAFETY: output_filename is checked for null above and comes from NSI C API
581                let c_str = unsafe { CStr::from_ptr(output_filename) };
582                c_str.to_string_lossy().into_owned()
583            },
584            width: width as _,
585            height: height as _,
586            pixel_format: PixelFormat::default(),
587            // NO pixel_data allocation - we pass buckets directly
588            fn_write: extract_callback::<dyn FnWrite<T>>(
589                "callback.write",
590                b'p',
591                1,
592                parameters,
593            ),
594            fn_finish: extract_callback::<dyn FnFinish>(
595                "callback.finish",
596                b'p',
597                1,
598                parameters,
599            ),
600            fn_query: None,
601            _phantom: std::marker::PhantomData,
602        });
603
604        // SAFETY: format is a valid pointer to format_count elements from NSI C API.
605        let format = unsafe {
606            std::slice::from_raw_parts_mut(format, format_count as _)
607        };
608
609        // Set format to the requested pixel type T
610        format.iter_mut().for_each(|f| f.type_ = T::NDSPY_TYPE);
611
612        display_data.pixel_format = PixelFormat::new(format);
613
614        let error = if let Some(mut fn_open) =
615            extract_callback::<dyn FnOpen>("callback.open", b'p', 1, parameters)
616        {
617            let error = fn_open(
618                &display_data.name,
619                width as _,
620                height as _,
621                &display_data.pixel_format,
622            );
623            // wtf?
624            Box::leak(fn_open);
625
626            error
627        } else {
628            Error::None
629        };
630
631        // SAFETY: image_handle_ptr and flag_stuff are valid pointers from NSI C API
632        unsafe {
633            *image_handle_ptr = Box::into_raw(display_data) as _;
634            // Preserve renderer-provided flags, but clear the empty-bucket request.
635            (*flag_stuff).flags &=
636                !(ndspy_sys::PkDspyFlagsWantsEmptyBuckets as i32);
637        }
638
639        error.into()
640    }) {
641        Ok(result) => result,
642        Err(_) => {
643            // If we panicked, return an error to the renderer
644            Error::Undefined.into()
645        }
646    }
647}
648
649// FIXME: this will be used for a FnProgress callback later.
650#[unsafe(no_mangle)]
651pub(crate) extern "C" fn image_query(
652    _image_handle_ptr: ndspy_sys::PtDspyImageHandle,
653    query_type: ndspy_sys::PtDspyQueryType,
654    data_len: c_int,
655    data: *mut c_void,
656) -> ndspy_sys::PtDspyError {
657    // Catch any panics to prevent unwinding into C code.
658    match std::panic::catch_unwind(|| {
659        match query_type {
660        ndspy_sys::PtDspyQueryType::RenderProgress => {
661            if (data_len as usize)
662                < core::mem::size_of::<ndspy_sys::PtDspyRenderProgressFuncPtr>()
663                || data.is_null()
664            {
665                Error::BadParameters
666            } else {
667                // SAFETY: data is a valid pointer to PtDspyRenderProgressFuncPtr
668                // as specified by the query type and validated by data_len check.
669                unsafe {
670                    let func_ptr = data as *mut ndspy_sys::PtDspyRenderProgressFuncPtr;
671                    *func_ptr = Some(image_progress);
672                }
673                Error::None
674            }
675        }
676        ndspy_sys::PtDspyQueryType::Progressive => {
677            if (data_len as usize) < size_of::<ndspy_sys::PtDspyProgressiveInfo>()
678                || data.is_null()
679            {
680                Error::BadParameters
681            } else {
682                // SAFETY: data points to PtDspyProgressiveInfo with validated length.
683                unsafe {
684                    let info = data as *mut ndspy_sys::PtDspyProgressiveInfo;
685                    (*info).acceptProgressive = 1;
686                }
687                Error::None
688            }
689        }
690        ndspy_sys::PtDspyQueryType::Thread => {
691            if (data_len as usize) < size_of::<ndspy_sys::PtDspyThreadInfo>()
692                || data.is_null()
693            {
694                Error::BadParameters
695            } else {
696                // SAFETY: data points to PtDspyThreadInfo with validated length.
697                unsafe {
698                    let info = data as *mut ndspy_sys::PtDspyThreadInfo;
699                    // Allow multithreaded buckets.
700                    (*info).multithread = 1;
701                }
702                Error::None
703            }
704        }
705        ndspy_sys::PtDspyQueryType::Cooked => {
706            if (data_len as usize) < size_of::<ndspy_sys::PtDspyCookedInfo>()
707                || data.is_null()
708            {
709                Error::BadParameters
710            } else {
711                // SAFETY: data points to PtDspyCookedInfo with validated length.
712                unsafe {
713                    let info = data as *mut ndspy_sys::PtDspyCookedInfo;
714                    // Accept filtered pixel data (cooked=1 = PkDspyCQDefault).
715                    (*info).cooked = 1;
716                }
717                Error::None
718            }
719        }
720        // StopQuery asks "should rendering stop?" - return None to continue.
721        ndspy_sys::PtDspyQueryType::Stop => Error::None,
722        ndspy_sys::PtDspyQueryType::Overwrite => {
723            if (data_len as usize) < size_of::<ndspy_sys::PtDspyOverwriteInfo>()
724                || data.is_null()
725            {
726                Error::BadParameters
727            } else {
728                // SAFETY: data points to PtDspyOverwriteInfo with validated length.
729                unsafe {
730                    let info = data as *mut ndspy_sys::PtDspyOverwriteInfo;
731                    // Allow the renderer to overwrite existing files.
732                    (*info).overwrite = 1;
733                }
734                Error::None
735            }
736        }
737        _ => Error::Unsupported,
738    }
739    .into()
740    }) {
741        Ok(result) => result,
742        Err(_) => {
743            // If we panicked, return an error to the renderer
744            Error::Undefined.into()
745        }
746    }
747}
748
749// Generic trampoline function for the FnWrite callback.
750// Passes bucket data directly to callback - NO accumulation, NO memcpy to full buffer.
751pub(crate) extern "C" fn image_write<T: PixelType>(
752    image_handle_ptr: ndspy_sys::PtDspyImageHandle,
753    x_min: c_int,
754    x_max_plus_one: c_int,
755    y_min: c_int,
756    y_max_plus_one: c_int,
757    _entry_size: c_int,
758    pixel_data: *const u8,
759) -> ndspy_sys::PtDspyError {
760    // Catch any panics to prevent unwinding into C code
761    match std::panic::catch_unwind(|| {
762        // SAFETY: image_handle_ptr should be valid as it was created by image_open
763        if image_handle_ptr.is_null() {
764            return Error::BadParameters.into();
765        }
766        let display_data =
767            unsafe { &mut *(image_handle_ptr as *mut DisplayData<T>) };
768
769        let channels = display_data.pixel_format.channels();
770        let bucket_width = (x_max_plus_one - x_min) as usize;
771        let bucket_height = (y_max_plus_one - y_min) as usize;
772        let bucket_pixel_count = bucket_width * bucket_height * channels;
773
774        // SAFETY: pixel_data comes from the renderer and should be valid
775        if pixel_data.is_null() {
776            return Error::None.into();
777        }
778
779        // Zero-cost slice creation - just pointer reinterpretation, no copy!
780        let bucket_data = unsafe {
781            std::slice::from_raw_parts(
782                pixel_data as *const T,
783                bucket_pixel_count,
784            )
785        };
786
787        // Pass bucket directly to callback - NO memcpy, NO accumulation!
788        if let Some(ref mut fn_write) = display_data.fn_write {
789            fn_write(
790                &display_data.name,
791                display_data.width,
792                display_data.height,
793                x_min as _,
794                x_max_plus_one as _,
795                y_min as _,
796                y_max_plus_one as _,
797                &display_data.pixel_format,
798                bucket_data, // Just the bucket!
799            )
800        } else {
801            Error::None
802        }
803        .into()
804    }) {
805        Ok(result) => result,
806        Err(_) => {
807            // If we panicked, return an error to the renderer
808            Error::Undefined.into()
809        }
810    }
811}
812
813// Generic trampoline function for the FnFinish callback.
814// FnFinish does NOT receive pixel data - user accumulates if needed.
815pub(crate) extern "C" fn image_close<T: PixelType>(
816    image_handle_ptr: ndspy_sys::PtDspyImageHandle,
817) -> ndspy_sys::PtDspyError {
818    // Catch any panics to prevent unwinding into C code
819    match std::panic::catch_unwind(|| {
820        // SAFETY: image_handle_ptr should be valid as it was created by image_open
821        if image_handle_ptr.is_null() {
822            return Error::BadParameters.into();
823        }
824        let mut display_data =
825            unsafe { Box::from_raw(image_handle_ptr as *mut DisplayData<T>) };
826
827        // FnFinish receives no pixel data - user accumulates if needed
828        let error = if let Some(ref mut fn_finish) = display_data.fn_finish {
829            fn_finish(
830                std::mem::take(&mut display_data.name),
831                display_data.width,
832                display_data.height,
833                std::mem::take(&mut display_data.pixel_format),
834            )
835        } else {
836            Error::None
837        };
838
839        // SAFETY: The callbacks were passed to us via FFI from Box::into_raw.
840        // They should be dropped when DisplayData is dropped, but this causes
841        // a double-free. This suggests the callbacks are being freed elsewhere,
842        // possibly by the renderer. For now, we leak them to prevent crashes.
843        // TODO: Investigate why double-free occurs and fix properly.
844        if let Some(fn_write) = display_data.fn_write.take() {
845            Box::leak(fn_write);
846        }
847        if let Some(fn_query) = display_data.fn_query.take() {
848            Box::leak(fn_query);
849        }
850        if let Some(fn_finish) = display_data.fn_finish.take() {
851            Box::leak(fn_finish);
852        }
853
854        error.into()
855    }) {
856        Ok(result) => result,
857        Err(_) => {
858            // If we panicked, return an error to the renderer
859            Error::Undefined.into()
860        }
861    }
862}
863
864#[unsafe(no_mangle)]
865extern "C" fn image_progress(
866    _image_handle_ptr: ndspy_sys::PtDspyImageHandle,
867    _progress: f32,
868) -> ndspy_sys::PtDspyError {
869    // Progress logging disabled to reduce spam
870    Error::None.into()
871}
872
873/// Helper for users who want the complete accumulated image at finish time.
874///
875/// The core API passes buckets directly to callbacks without accumulation.
876/// This helper provides the common use case of receiving the complete image
877/// when rendering finishes.
878///
879/// # Example
880///
881/// ```ignore
882/// use std::sync::{Arc, Mutex};
883///
884/// // Create accumulating callbacks that deliver the full image at finish
885/// let (write, finish) = nsi::output::AccumulatingCallbacks::<f32>::new(
886///     |name, width, height, format, pixels| {
887///         // Called once at end with complete accumulated image
888///         save_image(&name, width, height, &pixels);
889///         nsi::output::Error::None
890///     },
891/// );
892///
893/// ctx.set_attribute(
894///     "driver",
895///     &[
896///         nsi::string!("drivername", nsi::output::FERRIS_F32),
897///         nsi::callback!("callback.write", write),
898///         nsi::callback!("callback.finish", finish),
899///     ],
900/// );
901/// ```
902pub struct AccumulatingCallbacks<T: PixelType> {
903    _phantom: std::marker::PhantomData<T>,
904}
905
906impl<T: PixelType> AccumulatingCallbacks<T> {
907    /// Create a pair of callbacks that accumulate pixels and deliver the
908    /// complete image to the finish callback.
909    ///
910    /// Returns `(WriteCallback, FinishCallback)` where:
911    /// - The write callback accumulates buckets into an internal buffer
912    /// - The finish callback delivers the complete buffer to your closure
913    pub fn new<'a, F>(
914        mut on_finish: F,
915    ) -> (WriteCallback<'a, T>, FinishCallback<'a>)
916    where
917        F: FnMut(String, usize, usize, PixelFormat, Vec<T>) -> Error + 'a,
918    {
919        use std::sync::{Arc, Mutex};
920
921        // Shared state between write and finish callbacks
922        struct AccumState<T: PixelType> {
923            buffer: Vec<T>,
924            width: usize,
925            height: usize,
926            channels: usize,
927            initialized: bool,
928        }
929
930        let state = Arc::new(Mutex::new(AccumState::<T> {
931            buffer: Vec::new(),
932            width: 0,
933            height: 0,
934            channels: 0,
935            initialized: false,
936        }));
937
938        let write_state = state.clone();
939        let finish_state = state;
940
941        let write = WriteCallback::new(
942            move |_name: &str,
943                  width: usize,
944                  height: usize,
945                  x_min: usize,
946                  x_max_plus_one: usize,
947                  y_min: usize,
948                  y_max_plus_one: usize,
949                  format: &PixelFormat,
950                  bucket_data: &[T]| {
951                let mut state = write_state.lock().unwrap();
952
953                // Initialize on first bucket
954                if !state.initialized {
955                    state.width = width;
956                    state.height = height;
957                    state.channels = format.channels();
958                    state.buffer =
959                        vec![T::default(); width * height * state.channels];
960                    state.initialized = true;
961                }
962
963                // Copy bucket into the full buffer
964                let bucket_width = x_max_plus_one - x_min;
965                let channels = state.channels;
966
967                for y in y_min..y_max_plus_one {
968                    let src_start = (y - y_min) * bucket_width * channels;
969                    let dst_start = (y * state.width + x_min) * channels;
970                    let row_len = bucket_width * channels;
971
972                    state.buffer[dst_start..dst_start + row_len]
973                        .copy_from_slice(
974                            &bucket_data[src_start..src_start + row_len],
975                        );
976                }
977
978                Error::None
979            },
980        );
981
982        let finish = FinishCallback::new(
983            move |name: String,
984                  width: usize,
985                  height: usize,
986                  format: PixelFormat| {
987                let mut state = finish_state.lock().unwrap();
988                let buffer = std::mem::take(&mut state.buffer);
989                drop(state); // Release lock before calling user callback
990
991                on_finish(name, width, height, format, buffer)
992            },
993        );
994
995        (write, finish)
996    }
997}