spatial-maker 0.3.2

Convert 2D images and videos to stereoscopic 3D spatial content for Apple Vision Pro using AI depth estimation
Documentation
use crate::error::{SpatialError, SpatialResult};
use image::DynamicImage;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputFormat {
	SideBySide,
	TopAndBottom,
	Separate,
}

impl OutputFormat {
	pub fn name(&self) -> &'static str {
		match self {
			OutputFormat::SideBySide => "side-by-side",
			OutputFormat::TopAndBottom => "top-and-bottom",
			OutputFormat::Separate => "separate",
		}
	}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImageEncoding {
	Jpeg { quality: u8 },
	Png,
}

impl ImageEncoding {
	pub fn extension(&self) -> &'static str {
		match self {
			ImageEncoding::Jpeg { .. } => "jpg",
			ImageEncoding::Png => "png",
		}
	}

	pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
		let ext = path
			.as_ref()
			.extension()
			.and_then(|e| e.to_str())
			.unwrap_or("")
			.to_lowercase();

		match ext.as_str() {
			"png" => ImageEncoding::Png,
			_ => ImageEncoding::Jpeg { quality: 95 },
		}
	}
}

#[derive(Clone, Debug)]
pub struct MVHEVCConfig {
	pub spatial_cli_path: Option<PathBuf>,
	pub enabled: bool,
	pub quality: u8,
	pub keep_intermediate: bool,
}

impl Default for MVHEVCConfig {
	fn default() -> Self {
		Self {
			spatial_cli_path: None,
			enabled: false,
			quality: 95,
			keep_intermediate: false,
		}
	}
}

#[derive(Clone, Debug)]
pub struct OutputOptions {
	pub layout: OutputFormat,
	pub image_format: ImageEncoding,
	pub mvhevc: Option<MVHEVCConfig>,
}

impl Default for OutputOptions {
	fn default() -> Self {
		Self {
			layout: OutputFormat::SideBySide,
			image_format: ImageEncoding::Jpeg { quality: 95 },
			mvhevc: None,
		}
	}
}

pub fn create_sbs_image(left: &DynamicImage, right: &DynamicImage) -> DynamicImage {
	let left_width = left.width();
	let left_height = left.height();

	let combined_width = left_width + right.width();
	let mut combined = DynamicImage::new_rgb8(combined_width, left_height);

	image::imageops::overlay(&mut combined, left, 0, 0);
	image::imageops::overlay(&mut combined, right, left_width as i64, 0);

	combined
}

pub fn save_stereo_image(
	left: &DynamicImage,
	right: &DynamicImage,
	output_path: impl AsRef<Path>,
	options: OutputOptions,
) -> SpatialResult<()> {
	let output_path = output_path.as_ref();

	if let Some(parent) = output_path.parent() {
		std::fs::create_dir_all(parent).map_err(|e| {
			SpatialError::ImageError(format!("Failed to create output directory: {}", e))
		})?;
	}

	match options.layout {
		OutputFormat::SideBySide => {
			save_side_by_side(left, right, output_path, options.image_format)?;
		}
		OutputFormat::TopAndBottom => {
			save_top_and_bottom(left, right, output_path, options.image_format)?;
		}
		OutputFormat::Separate => {
			save_separate(left, right, output_path, options.image_format)?;
		}
	}

	if let Some(mvhevc_config) = options.mvhevc {
		if mvhevc_config.enabled {
			encode_mvhevc(output_path, &mvhevc_config)?;
			if !mvhevc_config.keep_intermediate {
				let _ = std::fs::remove_file(output_path);
			}
		}
	}

	Ok(())
}

fn save_side_by_side(
	left: &DynamicImage,
	right: &DynamicImage,
	output_path: &Path,
	encoding: ImageEncoding,
) -> SpatialResult<()> {
	if left.height() != right.height() {
		return Err(SpatialError::ImageError(format!(
			"Left and right images must have the same height: {} != {}",
			left.height(),
			right.height()
		)));
	}

	let combined = create_sbs_image(left, right);
	save_image(&combined, output_path, encoding)
}

fn save_top_and_bottom(
	left: &DynamicImage,
	right: &DynamicImage,
	output_path: &Path,
	encoding: ImageEncoding,
) -> SpatialResult<()> {
	if left.width() != right.width() {
		return Err(SpatialError::ImageError(format!(
			"Left and right images must have the same width: {} != {}",
			left.width(),
			right.width()
		)));
	}

	let combined_height = left.height() + right.height();
	let mut combined = DynamicImage::new_rgb8(left.width(), combined_height);

	image::imageops::overlay(&mut combined, left, 0, 0);
	image::imageops::overlay(&mut combined, right, 0, left.height() as i64);

	save_image(&combined, output_path, encoding)
}

fn save_separate(
	left: &DynamicImage,
	right: &DynamicImage,
	output_path: &Path,
	encoding: ImageEncoding,
) -> SpatialResult<()> {
	let stem = output_path
		.file_stem()
		.and_then(|s| s.to_str())
		.ok_or_else(|| SpatialError::ImageError("Invalid output path".to_string()))?;

	let parent = output_path.parent().unwrap_or_else(|| Path::new("."));
	let ext = encoding.extension();

	let left_path = parent.join(format!("{}_L.{}", stem, ext));
	let right_path = parent.join(format!("{}_R.{}", stem, ext));

	save_image(left, &left_path, encoding)?;
	save_image(right, &right_path, encoding)?;

	Ok(())
}

fn save_image(image: &DynamicImage, path: &Path, encoding: ImageEncoding) -> SpatialResult<()> {
	match encoding {
		ImageEncoding::Jpeg { quality } => {
			let rgb_image = image.to_rgb8();
			let file = std::fs::File::create(path).map_err(|e| {
				SpatialError::ImageError(format!("Failed to create output file: {}", e))
			})?;

			let mut jpeg_encoder =
				image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality);
			jpeg_encoder
				.encode(
					rgb_image.as_ref(),
					rgb_image.width(),
					rgb_image.height(),
					image::ExtendedColorType::Rgb8,
				)
				.map_err(|e| SpatialError::ImageError(format!("Failed to encode JPEG: {}", e)))?;
		}
		ImageEncoding::Png => {
			image
				.save(path)
				.map_err(|e| SpatialError::ImageError(format!("Failed to save PNG: {}", e)))?;
		}
	}

	Ok(())
}

fn encode_mvhevc(stereo_path: &Path, config: &MVHEVCConfig) -> SpatialResult<()> {
	let spatial_path = config
		.spatial_cli_path
		.as_ref()
		.map(|p| p.as_path())
		.unwrap_or_else(|| Path::new("spatial"));

	let hevc_path = stereo_path.with_extension("heic");

	let format = if stereo_path.to_string_lossy().contains("top-bottom")
		|| stereo_path.to_string_lossy().contains("_tb_")
	{
		"hou"
	} else {
		"sbs"
	};

	let quality_normalized = (config.quality as f32 / 100.0).clamp(0.0, 1.0);

	let mut cmd = Command::new(spatial_path);
	cmd.arg("make")
		.arg("--input")
		.arg(stereo_path)
		.arg("--output")
		.arg(&hevc_path)
		.arg("--format")
		.arg(format)
		.arg("--quality")
		.arg(quality_normalized.to_string())
		.arg("--overwrite");

	let output = cmd.output().map_err(|e| {
		SpatialError::ImageError(format!(
			"Failed to run `spatial` CLI: {}. Ensure the `spatial` tool is installed and in PATH.",
			e
		))
	})?;

	if !output.status.success() {
		let stderr = String::from_utf8_lossy(&output.stderr);
		return Err(SpatialError::ImageError(format!(
			"MV-HEVC encoding failed: {}",
			stderr
		)));
	}

	Ok(())
}