1use crate::error::{SpatialError, SpatialResult};
2use image::DynamicImage;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum OutputFormat {
8 SideBySide,
9 TopAndBottom,
10 Separate,
11}
12
13impl OutputFormat {
14 pub fn name(&self) -> &'static str {
15 match self {
16 OutputFormat::SideBySide => "side-by-side",
17 OutputFormat::TopAndBottom => "top-and-bottom",
18 OutputFormat::Separate => "separate",
19 }
20 }
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum ImageEncoding {
25 Jpeg { quality: u8 },
26 Png,
27}
28
29impl ImageEncoding {
30 pub fn extension(&self) -> &'static str {
31 match self {
32 ImageEncoding::Jpeg { .. } => "jpg",
33 ImageEncoding::Png => "png",
34 }
35 }
36
37 pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
38 let ext = path
39 .as_ref()
40 .extension()
41 .and_then(|e| e.to_str())
42 .unwrap_or("")
43 .to_lowercase();
44
45 match ext.as_str() {
46 "png" => ImageEncoding::Png,
47 _ => ImageEncoding::Jpeg { quality: 95 },
48 }
49 }
50}
51
52#[derive(Clone, Debug)]
53pub struct MVHEVCConfig {
54 pub spatial_cli_path: Option<PathBuf>,
55 pub enabled: bool,
56 pub quality: u8,
57 pub keep_intermediate: bool,
58}
59
60impl Default for MVHEVCConfig {
61 fn default() -> Self {
62 Self {
63 spatial_cli_path: None,
64 enabled: false,
65 quality: 95,
66 keep_intermediate: false,
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
72pub struct OutputOptions {
73 pub layout: OutputFormat,
74 pub image_format: ImageEncoding,
75 pub mvhevc: Option<MVHEVCConfig>,
76}
77
78impl Default for OutputOptions {
79 fn default() -> Self {
80 Self {
81 layout: OutputFormat::SideBySide,
82 image_format: ImageEncoding::Jpeg { quality: 95 },
83 mvhevc: None,
84 }
85 }
86}
87
88pub fn create_sbs_image(left: &DynamicImage, right: &DynamicImage) -> DynamicImage {
89 let left_width = left.width();
90 let left_height = left.height();
91
92 let combined_width = left_width + right.width();
93 let mut combined = DynamicImage::new_rgb8(combined_width, left_height);
94
95 image::imageops::overlay(&mut combined, left, 0, 0);
96 image::imageops::overlay(&mut combined, right, left_width as i64, 0);
97
98 combined
99}
100
101pub fn save_stereo_image(
102 left: &DynamicImage,
103 right: &DynamicImage,
104 output_path: impl AsRef<Path>,
105 options: OutputOptions,
106) -> SpatialResult<()> {
107 let output_path = output_path.as_ref();
108
109 if let Some(parent) = output_path.parent() {
110 std::fs::create_dir_all(parent).map_err(|e| {
111 SpatialError::ImageError(format!("Failed to create output directory: {}", e))
112 })?;
113 }
114
115 match options.layout {
116 OutputFormat::SideBySide => {
117 save_side_by_side(left, right, output_path, options.image_format)?;
118 }
119 OutputFormat::TopAndBottom => {
120 save_top_and_bottom(left, right, output_path, options.image_format)?;
121 }
122 OutputFormat::Separate => {
123 save_separate(left, right, output_path, options.image_format)?;
124 }
125 }
126
127 if let Some(mvhevc_config) = options.mvhevc {
128 if mvhevc_config.enabled {
129 encode_mvhevc(output_path, &mvhevc_config)?;
130 if !mvhevc_config.keep_intermediate {
131 let _ = std::fs::remove_file(output_path);
132 }
133 }
134 }
135
136 Ok(())
137}
138
139fn save_side_by_side(
140 left: &DynamicImage,
141 right: &DynamicImage,
142 output_path: &Path,
143 encoding: ImageEncoding,
144) -> SpatialResult<()> {
145 if left.height() != right.height() {
146 return Err(SpatialError::ImageError(format!(
147 "Left and right images must have the same height: {} != {}",
148 left.height(),
149 right.height()
150 )));
151 }
152
153 let combined = create_sbs_image(left, right);
154 save_image(&combined, output_path, encoding)
155}
156
157fn save_top_and_bottom(
158 left: &DynamicImage,
159 right: &DynamicImage,
160 output_path: &Path,
161 encoding: ImageEncoding,
162) -> SpatialResult<()> {
163 if left.width() != right.width() {
164 return Err(SpatialError::ImageError(format!(
165 "Left and right images must have the same width: {} != {}",
166 left.width(),
167 right.width()
168 )));
169 }
170
171 let combined_height = left.height() + right.height();
172 let mut combined = DynamicImage::new_rgb8(left.width(), combined_height);
173
174 image::imageops::overlay(&mut combined, left, 0, 0);
175 image::imageops::overlay(&mut combined, right, 0, left.height() as i64);
176
177 save_image(&combined, output_path, encoding)
178}
179
180fn save_separate(
181 left: &DynamicImage,
182 right: &DynamicImage,
183 output_path: &Path,
184 encoding: ImageEncoding,
185) -> SpatialResult<()> {
186 let stem = output_path
187 .file_stem()
188 .and_then(|s| s.to_str())
189 .ok_or_else(|| SpatialError::ImageError("Invalid output path".to_string()))?;
190
191 let parent = output_path.parent().unwrap_or_else(|| Path::new("."));
192 let ext = encoding.extension();
193
194 let left_path = parent.join(format!("{}_L.{}", stem, ext));
195 let right_path = parent.join(format!("{}_R.{}", stem, ext));
196
197 save_image(left, &left_path, encoding)?;
198 save_image(right, &right_path, encoding)?;
199
200 Ok(())
201}
202
203fn save_image(image: &DynamicImage, path: &Path, encoding: ImageEncoding) -> SpatialResult<()> {
204 match encoding {
205 ImageEncoding::Jpeg { quality } => {
206 let rgb_image = image.to_rgb8();
207 let file = std::fs::File::create(path).map_err(|e| {
208 SpatialError::ImageError(format!("Failed to create output file: {}", e))
209 })?;
210
211 let mut jpeg_encoder =
212 image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality);
213 jpeg_encoder
214 .encode(
215 rgb_image.as_ref(),
216 rgb_image.width(),
217 rgb_image.height(),
218 image::ExtendedColorType::Rgb8,
219 )
220 .map_err(|e| SpatialError::ImageError(format!("Failed to encode JPEG: {}", e)))?;
221 }
222 ImageEncoding::Png => {
223 image
224 .save(path)
225 .map_err(|e| SpatialError::ImageError(format!("Failed to save PNG: {}", e)))?;
226 }
227 }
228
229 Ok(())
230}
231
232fn encode_mvhevc(stereo_path: &Path, config: &MVHEVCConfig) -> SpatialResult<()> {
233 let spatial_path = config
234 .spatial_cli_path
235 .as_ref()
236 .map(|p| p.as_path())
237 .unwrap_or_else(|| Path::new("spatial"));
238
239 let hevc_path = stereo_path.with_extension("heic");
240
241 let format = if stereo_path.to_string_lossy().contains("top-bottom")
242 || stereo_path.to_string_lossy().contains("_tb_")
243 {
244 "hou"
245 } else {
246 "sbs"
247 };
248
249 let quality_normalized = (config.quality as f32 / 100.0).clamp(0.0, 1.0);
250
251 let mut cmd = Command::new(spatial_path);
252 cmd.arg("make")
253 .arg("--input")
254 .arg(stereo_path)
255 .arg("--output")
256 .arg(&hevc_path)
257 .arg("--format")
258 .arg(format)
259 .arg("--quality")
260 .arg(quality_normalized.to_string())
261 .arg("--overwrite");
262
263 let output = cmd.output().map_err(|e| {
264 SpatialError::ImageError(format!(
265 "Failed to run `spatial` CLI: {}. Ensure the `spatial` tool is installed and in PATH.",
266 e
267 ))
268 })?;
269
270 if !output.status.success() {
271 let stderr = String::from_utf8_lossy(&output.stderr);
272 return Err(SpatialError::ImageError(format!(
273 "MV-HEVC encoding failed: {}",
274 stderr
275 )));
276 }
277
278 Ok(())
279}