Skip to main content

kallo_api/
preview.rs

1//! The bench's session: one decode, many previews of it.
2//!
3//! A darkroom does not re-read the photograph every time the timer moves. The
4//! app used to: each preview decoded the camera file, resized it, rendered and
5//! wrote a PNG, and the window decoded the PNG again. The decode was the cost —
6//! a 26-megapixel file takes longer to read than a bench-sized render takes to
7//! draw — and it bought nothing, because every preview of one strip is the same
8//! photograph.
9//!
10//! A session is that knowledge held for as long as the strip is on the bench.
11//! It decodes once, meters once, and hands back the pixels of any development,
12//! print exposure, filter pack or heat glass. It also keeps the last few frames:
13//! dragging the ruler past a time and back again is a thing people do, and the
14//! frame they saw a moment ago is already made.
15//!
16//! What a session returns is the *print* the model would write, not an
17//! approximation of it: [`crate::render_image`] runs the same pipeline, and
18//! [`kallo_io::to_rgba8`] is the encoding [`kallo_io::save`] uses.
19
20use std::collections::{HashMap, VecDeque};
21use std::path::PathBuf;
22use std::sync::{Mutex, MutexGuard};
23
24use kallo_core::Metering;
25use kallo_io::{Image, InputEncoding, resize_to_width, to_rgba8};
26
27use crate::error::Error;
28use crate::input::read_input;
29use crate::render::{Exposure, RenderOptions, render_image};
30use crate::selection::{Selection, StockChoice, resolve};
31
32/// How many frames a session remembers.
33///
34/// A strip is a handful of times, and the print step adds a few exposure
35/// settings to the same handful. Twelve is enough for a strip and its dialled
36/// print without holding a hundred megabytes of textures.
37const CACHE: usize = 12;
38
39/// What a preview session is opened on.
40#[derive(Debug, Clone)]
41pub struct PreviewRequest {
42    /// The photograph.
43    pub input: PathBuf,
44    /// How to read an ordinary image.
45    pub encoding: InputEncoding,
46    /// Film stock id.
47    pub film: String,
48    /// Paper stock id.
49    pub paper: String,
50    /// Developer temperature in degrees Celsius.
51    pub temp_c: f64,
52    /// The width the bench shows the print at.
53    pub width: u32,
54    /// Expose through the spectral path.
55    pub spectral: bool,
56    /// Width of the film frame the image represents, in millimetres.
57    pub frame_mm: f64,
58    /// Add grain.
59    pub grain: bool,
60    /// Add halation.
61    pub halation: bool,
62    /// Add DIR couplers.
63    pub couplers: bool,
64    /// Render on the device when there is one.
65    pub gpu: bool,
66}
67
68impl PreviewRequest {
69    fn choice(&self) -> StockChoice {
70        StockChoice {
71            film: self.film.clone(),
72            paper: self.paper.clone(),
73            temp: Some(self.temp_c),
74            spectral: self.spectral,
75            // The app's spectral choice is the whole path, not only the
76            // exposure: the print and the scan are the other two thirds of the
77            // same decision.
78            spectral_print: self.spectral,
79            spectral_scan: self.spectral,
80            ..StockChoice::default()
81        }
82    }
83}
84
85/// One bench frame, already encoded the way a window wants it.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PreviewFrame {
88    /// Pixels across.
89    pub width: u32,
90    /// Pixels down.
91    pub height: u32,
92    /// RGBA, eight bits per channel, sRGB, row-major.
93    pub rgba: Vec<u8>,
94}
95
96/// The frame cache's key: everything that changes the picture.
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98struct Key {
99    minutes: u64,
100    print_ev: u64,
101    balance_from: Option<u64>,
102    filters: String,
103    heat_glass: Option<String>,
104}
105
106impl Key {
107    fn new(
108        minutes: f64,
109        print_ev: f64,
110        balance_from: Option<f64>,
111        filters: &str,
112        heat_glass: Option<&str>,
113    ) -> Self {
114        Self {
115            minutes: minutes.to_bits(),
116            print_ev: print_ev.to_bits(),
117            balance_from: balance_from.map(f64::to_bits),
118            filters: filters.to_owned(),
119            heat_glass: heat_glass.map(str::to_owned),
120        }
121    }
122}
123
124/// The frames a session has already made, oldest first.
125#[derive(Debug, Default)]
126struct Cache {
127    frames: HashMap<Key, PreviewFrame>,
128    order: VecDeque<Key>,
129}
130
131impl Cache {
132    fn get(&self, key: &Key) -> Option<PreviewFrame> {
133        self.frames.get(key).cloned()
134    }
135
136    fn put(&mut self, key: Key, frame: PreviewFrame) {
137        if let Some(slot) = self.frames.get_mut(&key) {
138            *slot = frame;
139            return;
140        }
141        if self.order.len() >= CACHE {
142            if let Some(oldest) = self.order.pop_front() {
143                self.frames.remove(&oldest);
144            }
145        }
146        self.order.push_back(key.clone());
147        self.frames.insert(key, frame);
148    }
149}
150
151/// A photograph held on the bench, ready to be printed at any setting.
152#[derive(Debug)]
153pub struct PreviewSession {
154    choice: StockChoice,
155    image: Image,
156    /// The frame's metered offset, solved once on the image the bench shows.
157    metered_ev: f64,
158    frame_mm: f64,
159    grain: bool,
160    halation: bool,
161    couplers: bool,
162    gpu: bool,
163    cache: Mutex<Cache>,
164}
165
166impl PreviewSession {
167    /// Read the photograph and meter it, once.
168    ///
169    /// The width is the bench's: the model's grain and blur belong to the
170    /// sampling, so a session is opened at the size it will be looked at, just
171    /// as a print is rendered at the size it will be exported.
172    pub fn open(request: PreviewRequest) -> Result<Self, Error> {
173        if request.width == 0 {
174            return Err(Error::InvalidNumber {
175                flag: "--size",
176                value: 0.0,
177                expected: "a positive width in pixels",
178            });
179        }
180        // Resolve before the decode: an unknown film should be an error before
181        // a camera file is read, not after.
182        let choice = request.choice();
183        resolve(&choice)?;
184
185        let source = read_input(&request.input, request.encoding)?;
186        let image = resize_to_width(&source, request.width);
187        let metered_ev = Metering::of(&image.pixels).ev;
188
189        Ok(Self {
190            choice,
191            image,
192            metered_ev,
193            frame_mm: request.frame_mm,
194            grain: request.grain,
195            halation: request.halation,
196            couplers: request.couplers,
197            gpu: request.gpu,
198            cache: Mutex::new(Cache::default()),
199        })
200    }
201
202    /// The frame this session would print at one setting.
203    ///
204    /// `balance_from` is the strip's one exposure: when set, the enlarger is
205    /// borrowed from that development and only the negative is this frame's.
206    pub fn frame(
207        &self,
208        minutes: f64,
209        print_ev: f64,
210        balance_from: Option<f64>,
211        filters: &str,
212        heat_glass: Option<&str>,
213    ) -> Result<PreviewFrame, Error> {
214        if !(minutes.is_finite() && minutes > 0.0) {
215            return Err(Error::InvalidNumber {
216                flag: "--dev",
217                value: minutes,
218                expected: "a positive number of minutes",
219            });
220        }
221        if !print_ev.is_finite() {
222            return Err(Error::InvalidNumber {
223                flag: "--print-ev",
224                value: print_ev,
225                expected: "a finite number of stops",
226            });
227        }
228
229        let key = Key::new(minutes, print_ev, balance_from, filters, heat_glass);
230        if let Some(frame) = self.cache().get(&key) {
231            return Ok(frame);
232        }
233
234        let mut choice = self.choice.clone();
235        choice.dev_time = Some(minutes);
236        choice.filters = filters.to_owned();
237        choice.heat_glass = heat_glass.map(str::to_owned);
238        let selection: Selection = resolve(&choice)?;
239
240        // The source is the session's, and a render is in place. Cloning it is
241        // what keeps the next frame independent; the public API hands the
242        // model a decoded image and expects to lose it.
243        let mut image = self.image.clone();
244        let options = RenderOptions {
245            ev: Exposure::Stops(self.metered_ev),
246            print_ev,
247            balance_from,
248            frame_mm: self.frame_mm,
249            grain: self.grain,
250            halation: self.halation,
251            couplers: self.couplers,
252            gpu: self.gpu,
253            ..RenderOptions::default()
254        };
255        render_image(&mut image, None, &selection, &options)?;
256
257        let frame = PreviewFrame {
258            width: image.width,
259            height: image.height,
260            rgba: to_rgba8(&image),
261        };
262        self.cache().put(key, frame.clone());
263        Ok(frame)
264    }
265
266    /// How many frames are held, for the tests that care that the cache works.
267    #[cfg(test)]
268    fn held(&self) -> usize {
269        self.cache().frames.len()
270    }
271
272    fn cache(&self) -> MutexGuard<'_, Cache> {
273        // The cache is a scratchpad, not a lock around the model: a panic
274        // while holding it cannot leave a half-rendered frame, only a frame
275        // that was never inserted. Recovering the guard is the honest response.
276        self.cache.lock().unwrap_or_else(|error| error.into_inner())
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use std::path::{Path, PathBuf};
283    use std::sync::atomic::{AtomicU64, Ordering};
284
285    use kallo_io::{Image, save};
286
287    use super::{PreviewRequest, PreviewSession};
288    use crate::render::{Exposure, RenderOptions, render_image};
289    use crate::selection::resolve;
290
291    static NEXT: AtomicU64 = AtomicU64::new(0);
292
293    /// A tiny photograph on disk, and the path to it.
294    fn photograph() -> (PathBuf, Image) {
295        let image = Image {
296            width: 5,
297            height: 4,
298            pixels: (0..20)
299                .map(|index| {
300                    let value = 0.02 + f64::from(index) * 0.04;
301                    [value, value * 0.8, value * 0.6]
302                })
303                .collect(),
304        };
305        let path = std::env::temp_dir().join(format!(
306            "kallo-preview-{}-{}.png",
307            std::process::id(),
308            NEXT.fetch_add(1, Ordering::Relaxed)
309        ));
310        save(&path, &image).expect("the test photograph");
311        (path, image)
312    }
313
314    fn request(path: &Path) -> PreviewRequest {
315        PreviewRequest {
316            input: path.to_path_buf(),
317            encoding: kallo_io::InputEncoding::Srgb,
318            film: "vision3_500t".to_owned(),
319            paper: "portra_endura".to_owned(),
320            temp_c: 20.0,
321            width: 5,
322            spectral: false,
323            frame_mm: 36.0,
324            grain: false,
325            halation: false,
326            couplers: false,
327            gpu: false,
328        }
329    }
330
331    fn expected(
332        session: &PreviewSession,
333        minutes: f64,
334        print_ev: f64,
335        balance_from: Option<f64>,
336        filters: &str,
337    ) -> Image {
338        let mut choice = session.choice.clone();
339        choice.dev_time = Some(minutes);
340        choice.filters = filters.to_owned();
341        let selection = resolve(&choice).expect("the test selection");
342        let mut image = session.image.clone();
343        let options = RenderOptions {
344            ev: Exposure::Stops(session.metered_ev),
345            print_ev,
346            balance_from,
347            frame_mm: session.frame_mm,
348            grain: session.grain,
349            halation: session.halation,
350            couplers: session.couplers,
351            gpu: false,
352            ..RenderOptions::default()
353        };
354        render_image(&mut image, None, &selection, &options).expect("the expected render");
355        image
356    }
357
358    #[test]
359    fn a_session_frame_is_the_print_at_that_time() {
360        let (path, _) = photograph();
361        let session = PreviewSession::open(request(&path)).expect("the session");
362        let frame = session.frame(6.0, 0.0, None, "", None).expect("the frame");
363        let image = expected(&session, 6.0, 0.0, None, "");
364        assert_eq!(frame.width, image.width);
365        assert_eq!(frame.height, image.height);
366        assert_eq!(frame.rgba, kallo_io::to_rgba8(&image));
367        assert_eq!(
368            frame.rgba.len(),
369            (image.width * image.height * 4) as usize,
370            "four channels per pixel"
371        );
372        let _ = std::fs::remove_file(path);
373    }
374
375    #[test]
376    fn a_second_frame_at_the_same_setting_is_the_cached_one() {
377        let (path, _) = photograph();
378        let session = PreviewSession::open(request(&path)).expect("the session");
379        let first = session.frame(6.0, 0.0, None, "", None).expect("the frame");
380        let second = session
381            .frame(6.0, 0.0, None, "", None)
382            .expect("the cached frame");
383        assert_eq!(first, second);
384        assert_eq!(session.held(), 1, "one setting, one frame held");
385        let other = session
386            .frame(9.0, 0.0, None, "", None)
387            .expect("another frame");
388        assert_ne!(first, other, "nine minutes is not six");
389        assert_eq!(session.held(), 2);
390        let _ = std::fs::remove_file(path);
391    }
392
393    #[test]
394    fn a_strip_frame_borrows_the_first_prints_balance() {
395        let (path, _) = photograph();
396        let session = PreviewSession::open(request(&path)).expect("the session");
397        let frame = session
398            .frame(12.0, 0.0, Some(6.0), "", None)
399            .expect("the frame");
400        let image = expected(&session, 12.0, 0.0, Some(6.0), "");
401        assert_eq!(frame.rgba, kallo_io::to_rgba8(&image));
402        let _ = std::fs::remove_file(path);
403    }
404
405    #[test]
406    fn filters_are_part_of_the_frame() {
407        let (path, _) = photograph();
408        let session = PreviewSession::open(request(&path)).expect("the session");
409        let plain = session
410            .frame(6.0, 0.0, None, "", None)
411            .expect("the plain frame");
412        let filtered = session
413            .frame(6.0, 0.0, None, "30M", None)
414            .expect("the filtered frame");
415        assert_ne!(plain, filtered, "a colour pack changes the print");
416        assert_eq!(session.held(), 2);
417        let _ = std::fs::remove_file(path);
418    }
419}