Skip to main content

spatial_maker/
output.rs

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}