1use 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
32const CACHE: usize = 12;
38
39#[derive(Debug, Clone)]
41pub struct PreviewRequest {
42 pub input: PathBuf,
44 pub encoding: InputEncoding,
46 pub film: String,
48 pub paper: String,
50 pub temp_c: f64,
52 pub width: u32,
54 pub spectral: bool,
56 pub frame_mm: f64,
58 pub grain: bool,
60 pub halation: bool,
62 pub couplers: bool,
64 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 spectral_print: self.spectral,
79 spectral_scan: self.spectral,
80 ..StockChoice::default()
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PreviewFrame {
88 pub width: u32,
90 pub height: u32,
92 pub rgba: Vec<u8>,
94}
95
96#[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#[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#[derive(Debug)]
153pub struct PreviewSession {
154 choice: StockChoice,
155 image: Image,
156 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 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 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 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 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 #[cfg(test)]
268 fn held(&self) -> usize {
269 self.cache().frames.len()
270 }
271
272 fn cache(&self) -> MutexGuard<'_, Cache> {
273 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 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}