1use std::path::{Path, PathBuf};
2
3use ad_core_rs::color::{NDColorMode, convert_rgb_layout};
4use ad_core_rs::error::{ADError, ADResult};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
8use ad_core_rs::plugin::file_controller::FilePluginController;
9use ad_core_rs::plugin::runtime::{
10 NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
11};
12
13use jpeg_encoder::{ColorType as JpegColorType, Encoder as JpegEncoder};
14use parking_lot::Mutex;
15
16pub struct JpegWriter {
18 current_path: Option<PathBuf>,
19 pub(crate) quality: u8,
20}
21
22impl JpegWriter {
23 pub fn new(quality: u8) -> Self {
24 Self {
25 current_path: None,
26 quality,
27 }
28 }
29
30 pub fn set_quality(&mut self, quality: u8) {
31 self.quality = quality;
32 }
33}
34
35impl NDFileWriter for JpegWriter {
36 fn open_file(&mut self, path: &Path, _mode: NDFileMode, array: &NDArray) -> ADResult<()> {
37 let dt = array.data.data_type();
38 if dt != NDDataType::UInt8 && dt != NDDataType::Int8 {
39 return Err(ADError::UnsupportedConversion(
40 "JPEG only supports UInt8/Int8 data".into(),
41 ));
42 }
43 self.current_path = Some(path.to_path_buf());
44 Ok(())
45 }
46
47 fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
48 let path = self
49 .current_path
50 .as_ref()
51 .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
52
53 let color_mode = array.info().color_mode;
58
59 let color_mode = match array.dims.as_slice() {
69 [_, _] => NDColorMode::Mono,
70 [c, _, _] if c.size == 3 && color_mode == NDColorMode::RGB1 => NDColorMode::RGB1,
71 [_, c, _] if c.size == 3 && color_mode == NDColorMode::RGB2 => NDColorMode::RGB2,
72 [_, _, c] if c.size == 3 && color_mode == NDColorMode::RGB3 => NDColorMode::RGB3,
73 _ => {
74 return Err(ADError::InvalidDimensions(
75 "unsupported array structure".into(),
76 ));
77 }
78 };
79
80 let is_rgb = matches!(
81 color_mode,
82 NDColorMode::RGB1 | NDColorMode::RGB2 | NDColorMode::RGB3
83 );
84 let src = if is_rgb && color_mode != NDColorMode::RGB1 {
85 &convert_rgb_layout(array, color_mode, NDColorMode::RGB1)?
86 } else {
87 array
88 };
89
90 let info = src.info();
91 let width = info.x_size;
92 let height = info.y_size;
93
94 let data: Vec<u8> = match &src.data {
95 NDDataBuffer::U8(v) => v.clone(),
96 NDDataBuffer::I8(v) => v.iter().map(|&b| b as u8).collect(),
97 _ => {
98 return Err(ADError::UnsupportedConversion(
99 "JPEG only supports UInt8/Int8".into(),
100 ));
101 }
102 };
103
104 let color_type = if info.color_size == 3 {
105 JpegColorType::Rgb
106 } else {
107 JpegColorType::Luma
108 };
109
110 let mut buf = Vec::new();
111 let encoder = JpegEncoder::new(&mut buf, self.quality);
112 encoder
113 .encode(&data, width as u16, height as u16, color_type)
114 .map_err(|e| ADError::UnsupportedConversion(format!("JPEG encode error: {}", e)))?;
115
116 std::fs::write(path, &buf)?;
117 Ok(())
118 }
119
120 fn read_file(&mut self) -> ADResult<NDArray> {
121 let path = self
122 .current_path
123 .as_ref()
124 .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
125
126 let file_data = std::fs::read(path)?;
127 let mut decoder = jpeg_decoder::Decoder::new(&file_data[..]);
128 let pixels = decoder
129 .decode()
130 .map_err(|e| ADError::UnsupportedConversion(format!("JPEG decode error: {}", e)))?;
131 let info = decoder.info().unwrap();
132
133 let (width, height) = (info.width as usize, info.height as usize);
134
135 let dims = match info.pixel_format {
136 jpeg_decoder::PixelFormat::L8 => {
137 vec![NDDimension::new(width), NDDimension::new(height)]
138 }
139 jpeg_decoder::PixelFormat::RGB24 => {
140 vec![
141 NDDimension::new(3),
142 NDDimension::new(width),
143 NDDimension::new(height),
144 ]
145 }
146 _ => {
147 return Err(ADError::UnsupportedConversion(
148 "unsupported JPEG pixel format".into(),
149 ));
150 }
151 };
152
153 let mut arr = NDArray::new(dims, NDDataType::UInt8);
154 arr.data = NDDataBuffer::U8(pixels);
155 Ok(arr)
156 }
157
158 fn close_file(&mut self) -> ADResult<()> {
159 self.current_path = None;
160 Ok(())
161 }
162
163 fn supports_multiple_arrays(&self) -> bool {
164 false
165 }
166}
167
168pub struct JpegFileProcessor {
170 ctrl: Mutex<FilePluginController<JpegWriter>>,
171 jpeg_quality_idx: Option<usize>,
172}
173
174impl JpegFileProcessor {
175 pub fn new(quality: u8) -> Self {
176 Self {
177 ctrl: Mutex::new(FilePluginController::new(JpegWriter::new(quality))),
178 jpeg_quality_idx: None,
179 }
180 }
181}
182
183impl Default for JpegFileProcessor {
184 fn default() -> Self {
185 Self::new(50)
186 }
187}
188
189impl NDPluginProcess for JpegFileProcessor {
190 fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
191 self.ctrl.lock().process_array(array)
192 }
193
194 fn plugin_type(&self) -> &str {
195 "NDFileJPEG"
196 }
197
198 fn does_array_callbacks(&self) -> bool {
201 false
202 }
203
204 fn register_params(
205 &mut self,
206 base: &mut asyn_rs::port::PortDriverBase,
207 ) -> asyn_rs::error::AsynResult<()> {
208 self.ctrl.lock().register_params(base)?;
209 use asyn_rs::param::ParamType;
210 let idx = base.create_param("JPEG_QUALITY", ParamType::Int32)?;
211 base.set_int32_param(idx, 0, i32::from(self.ctrl.lock().writer.quality))?;
215 self.jpeg_quality_idx = Some(idx);
216 Ok(())
217 }
218
219 fn on_param_change(&self, reason: usize, params: &PluginParamSnapshot) -> ParamChangeResult {
220 if Some(reason) == self.jpeg_quality_idx {
222 let q = params.value.as_i32().clamp(1, 100) as u8;
223 self.ctrl.lock().writer.set_quality(q);
224 return ParamChangeResult::empty();
225 }
226 self.ctrl.lock().on_param_change(reason, params)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
234 use std::sync::atomic::{AtomicU32, Ordering};
235
236 static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
237
238 fn temp_path(prefix: &str) -> PathBuf {
239 let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
240 std::env::temp_dir().join(format!(
241 "adcore_test_{}_{}_{}.jpg",
242 std::process::id(),
243 prefix,
244 n
245 ))
246 }
247
248 #[test]
255 fn test_r8_75_3d_without_colormode_attribute_is_an_error() {
256 use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
257 use ad_core_rs::color::NDColorMode;
258
259 let rgb1_dims = || {
260 vec![
261 NDDimension::new(3),
262 NDDimension::new(4),
263 NDDimension::new(4),
264 ]
265 };
266
267 let arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
268 let path = temp_path("jpeg_3d_no_colormode");
269 let mut writer = JpegWriter::new(90);
270 writer
271 .open_file(&path, NDFileMode::Single, &arr)
272 .expect("open");
273 let err = writer.write_file(&arr).unwrap_err();
274 assert!(
275 matches!(err, ADError::InvalidDimensions(_)),
276 "3-D without ColorMode must be rejected, got {err:?}"
277 );
278 assert!(
282 !path.exists(),
283 "a rejected array must leave no file: {}",
284 path.display()
285 );
286 std::fs::remove_file(&path).ok();
287
288 let arr = NDArray::new(vec![NDDimension::new(16)], NDDataType::UInt8);
290 let path = temp_path("jpeg_1d");
291 let mut writer = JpegWriter::new(90);
292 writer
293 .open_file(&path, NDFileMode::Single, &arr)
294 .expect("open");
295 assert!(matches!(
296 writer.write_file(&arr).unwrap_err(),
297 ADError::InvalidDimensions(_)
298 ));
299 std::fs::remove_file(&path).ok();
300
301 let mut arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
303 arr.attributes.add(NDAttribute::new_static(
304 "ColorMode",
305 "",
306 NDAttrSource::Driver,
307 NDAttrValue::Int32(NDColorMode::RGB1 as i32),
308 ));
309 let path = temp_path("jpeg_3d_rgb1");
310 let mut writer = JpegWriter::new(90);
311 writer
312 .open_file(&path, NDFileMode::Single, &arr)
313 .expect("open");
314 writer
315 .write_file(&arr)
316 .expect("3-D WITH ColorMode=RGB1 must still write");
317 assert!(path.exists());
318 std::fs::remove_file(&path).ok();
319 }
320
321 #[test]
322 fn test_write_u8() {
323 let path = temp_path("jpeg");
324 let mut writer = JpegWriter::new(90);
325
326 let mut arr = NDArray::new(
327 vec![NDDimension::new(8), NDDimension::new(8)],
328 NDDataType::UInt8,
329 );
330 if let NDDataBuffer::U8(ref mut v) = arr.data {
331 for i in 0..64 {
332 v[i] = (i * 4) as u8;
333 }
334 }
335
336 writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
337 writer.write_file(&arr).unwrap();
338 writer.close_file().unwrap();
339
340 let data = std::fs::read(&path).unwrap();
341 assert_eq!(&data[0..2], &[0xFF, 0xD8]);
343 assert_eq!(&data[data.len() - 2..], &[0xFF, 0xD9]);
345
346 std::fs::remove_file(&path).ok();
347 }
348
349 #[test]
350 fn test_rejects_non_u8() {
351 let path = temp_path("jpeg_u16");
352 let mut writer = JpegWriter::new(90);
353
354 let arr = NDArray::new(
355 vec![NDDimension::new(4), NDDimension::new(4)],
356 NDDataType::UInt16,
357 );
358
359 let result = writer.open_file(&path, NDFileMode::Single, &arr);
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn test_quality_affects_size() {
365 let path_high = temp_path("jpeg_hi");
366 let path_low = temp_path("jpeg_lo");
367
368 let mut arr = NDArray::new(
369 vec![NDDimension::new(32), NDDimension::new(32)],
370 NDDataType::UInt8,
371 );
372 if let NDDataBuffer::U8(ref mut v) = arr.data {
373 for i in 0..v.len() {
374 v[i] = (i % 256) as u8;
375 }
376 }
377
378 let mut writer_high = JpegWriter::new(95);
379 writer_high
380 .open_file(&path_high, NDFileMode::Single, &arr)
381 .unwrap();
382 writer_high.write_file(&arr).unwrap();
383 writer_high.close_file().unwrap();
384
385 let mut writer_low = JpegWriter::new(10);
386 writer_low
387 .open_file(&path_low, NDFileMode::Single, &arr)
388 .unwrap();
389 writer_low.write_file(&arr).unwrap();
390 writer_low.close_file().unwrap();
391
392 let size_high = std::fs::metadata(&path_high).unwrap().len();
393 let size_low = std::fs::metadata(&path_low).unwrap().len();
394 assert!(
395 size_high > size_low,
396 "high quality ({}) should be larger than low quality ({})",
397 size_high,
398 size_low
399 );
400
401 std::fs::remove_file(&path_high).ok();
402 std::fs::remove_file(&path_low).ok();
403 }
404
405 #[test]
406 fn test_default_quality_is_50() {
407 assert_eq!(JpegFileProcessor::default().ctrl.lock().writer.quality, 50);
409 assert_eq!(JpegWriter::new(50).quality, 50);
410 }
411
412 #[test]
413 fn test_register_params_seeds_quality_pv() {
414 use asyn_rs::port::{PortDriverBase, PortFlags};
415 let mut base = PortDriverBase::new("jpeg_param_test", 1, PortFlags::default());
416 let mut proc = JpegFileProcessor::new(50);
417 proc.register_params(&mut base).unwrap();
418 let idx = proc.jpeg_quality_idx.unwrap();
419 assert_eq!(base.get_int32_param(idx, 0).unwrap(), 50);
421 }
422
423 #[test]
424 fn test_roundtrip_luma() {
425 let path = temp_path("jpeg_rt");
426 let mut writer = JpegWriter::new(100);
427
428 let mut arr = NDArray::new(
429 vec![NDDimension::new(8), NDDimension::new(8)],
430 NDDataType::UInt8,
431 );
432 if let NDDataBuffer::U8(ref mut v) = arr.data {
433 for i in 0..64 {
435 v[i] = 128;
436 }
437 }
438
439 writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
440 writer.write_file(&arr).unwrap();
441
442 let read_back = writer.read_file().unwrap();
443 assert_eq!(read_back.data.data_type(), NDDataType::UInt8);
444 if let NDDataBuffer::U8(ref v) = read_back.data {
445 for &px in v.iter() {
447 assert!(
448 (px as i16 - 128).unsigned_abs() < 5,
449 "pixel {} too far from 128",
450 px
451 );
452 }
453 }
454
455 writer.close_file().unwrap();
456 std::fs::remove_file(&path).ok();
457 }
458
459 fn jpeg_roundtrip_dims(prefix: &str, mode: NDColorMode, dims: Vec<usize>) -> Vec<usize> {
461 use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
462 let path = temp_path(prefix);
463 let mut writer = JpegWriter::new(95);
464
465 let mut arr = NDArray::new(
466 dims.iter().map(|&d| NDDimension::new(d)).collect(),
467 NDDataType::UInt8,
468 );
469 arr.attributes.add(NDAttribute::new_static(
470 "ColorMode",
471 "Color mode",
472 NDAttrSource::Driver,
473 NDAttrValue::Int32(mode as i32),
474 ));
475 if let NDDataBuffer::U8(ref mut v) = arr.data {
476 for (i, x) in v.iter_mut().enumerate() {
477 *x = (i % 256) as u8;
478 }
479 }
480
481 writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
482 writer.write_file(&arr).unwrap();
483 let read_back = writer.read_file().unwrap();
484 writer.close_file().unwrap();
485 std::fs::remove_file(&path).ok();
486 read_back.dims.iter().map(|d| d.size).collect()
487 }
488
489 #[test]
490 fn test_adp12_rgb2_jpeg_written_as_rgb_not_grayscale() {
491 let dims = jpeg_roundtrip_dims("jpeg_rgb2", NDColorMode::RGB2, vec![5, 3, 4]);
497 assert_eq!(dims, vec![3, 5, 4]);
498 }
499
500 #[test]
501 fn test_adp12_rgb3_jpeg_written_as_rgb_not_grayscale() {
502 let dims = jpeg_roundtrip_dims("jpeg_rgb3", NDColorMode::RGB3, vec![5, 4, 3]);
505 assert_eq!(dims, vec![3, 5, 4]);
506 }
507}