use crate::{Result, VirtualProductionError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExtensionType {
SkyReplacement,
FloorExtension,
ArchitectureExtension,
ForegroundElement,
BackgroundPlate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeBlendMode {
Hard,
Feather,
SmartEdge,
MatteBased,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetExtensionElement {
pub id: String,
pub extension_type: ExtensionType,
pub blend_mode: EdgeBlendMode,
pub feather_px: f32,
pub split_y: f32,
pub split_x: f32,
pub active: bool,
pub opacity: f32,
}
impl SetExtensionElement {
#[must_use]
pub fn sky(id: &str, horizon_y: f32) -> Self {
Self {
id: id.to_string(),
extension_type: ExtensionType::SkyReplacement,
blend_mode: EdgeBlendMode::Feather,
feather_px: 20.0,
split_y: horizon_y.clamp(0.0, 1.0),
split_x: 0.5,
active: true,
opacity: 1.0,
}
}
#[must_use]
pub fn floor(id: &str, horizon_y: f32) -> Self {
Self {
id: id.to_string(),
extension_type: ExtensionType::FloorExtension,
blend_mode: EdgeBlendMode::SmartEdge,
feather_px: 15.0,
split_y: horizon_y.clamp(0.0, 1.0),
split_x: 0.5,
active: true,
opacity: 1.0,
}
}
#[must_use]
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_blend_mode(mut self, mode: EdgeBlendMode) -> Self {
self.blend_mode = mode;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerspectiveParams {
pub fov_deg: f64,
pub tilt_deg: f64,
pub pan_deg: f64,
pub roll_deg: f64,
pub height_m: f64,
}
impl Default for PerspectiveParams {
fn default() -> Self {
Self {
fov_deg: 50.0,
tilt_deg: 0.0,
pan_deg: 0.0,
roll_deg: 0.0,
height_m: 1.5,
}
}
}
impl PerspectiveParams {
#[must_use]
pub fn horizon_y_normalized(&self, image_height: usize) -> f32 {
let vfov_half_rad = (self.fov_deg / 2.0).to_radians();
let tilt_rad = self.tilt_deg.to_radians();
let frac = (tilt_rad / vfov_half_rad).clamp(-1.0, 1.0);
let _ = image_height; (0.5 + frac as f32 / 2.0).clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone)]
pub struct ExtendedFrame {
pub pixels: Vec<u8>,
pub width: usize,
pub height: usize,
pub extensions_applied: usize,
}
impl ExtendedFrame {
#[must_use]
pub fn get_pixel(&self, x: usize, y: usize) -> Option<[u8; 3]> {
if x >= self.width || y >= self.height {
return None;
}
let idx = (y * self.width + x) * 3;
Some([self.pixels[idx], self.pixels[idx + 1], self.pixels[idx + 2]])
}
}
pub struct SetExtensionCompositor {
elements: Vec<SetExtensionElement>,
perspective: PerspectiveParams,
}
impl SetExtensionCompositor {
#[must_use]
pub fn new() -> Self {
Self {
elements: Vec::new(),
perspective: PerspectiveParams::default(),
}
}
pub fn add_element(&mut self, element: SetExtensionElement) {
self.elements.push(element);
}
pub fn remove_element(&mut self, id: &str) {
self.elements.retain(|e| e.id != id);
}
pub fn set_perspective(&mut self, params: PerspectiveParams) {
self.perspective = params;
}
#[must_use]
pub fn element_count(&self) -> usize {
self.elements.len()
}
#[must_use]
pub fn active_element_count(&self) -> usize {
self.elements.iter().filter(|e| e.active).count()
}
pub fn apply(
&self,
physical: &[u8],
virtual_bg: &[u8],
width: usize,
height: usize,
) -> Result<ExtendedFrame> {
let expected = width * height * 3;
if physical.len() != expected {
return Err(VirtualProductionError::Compositing(format!(
"Physical frame size mismatch: expected {expected}, got {}",
physical.len()
)));
}
if virtual_bg.len() != expected {
return Err(VirtualProductionError::Compositing(format!(
"Virtual frame size mismatch: expected {expected}, got {}",
virtual_bg.len()
)));
}
let mut alpha = vec![0.0f32; width * height];
let mut applied = 0usize;
for element in self.elements.iter().filter(|e| e.active) {
self.apply_element_mask(&mut alpha, element, width, height);
applied += 1;
}
let mut out = Vec::with_capacity(expected);
for i in 0..(width * height) {
let a = alpha[i].clamp(0.0, 1.0);
let p_r = physical[i * 3] as f32;
let p_g = physical[i * 3 + 1] as f32;
let p_b = physical[i * 3 + 2] as f32;
let v_r = virtual_bg[i * 3] as f32;
let v_g = virtual_bg[i * 3 + 1] as f32;
let v_b = virtual_bg[i * 3 + 2] as f32;
out.push((p_r * (1.0 - a) + v_r * a) as u8);
out.push((p_g * (1.0 - a) + v_g * a) as u8);
out.push((p_b * (1.0 - a) + v_b * a) as u8);
}
Ok(ExtendedFrame {
pixels: out,
width,
height,
extensions_applied: applied,
})
}
fn apply_element_mask(
&self,
alpha: &mut [f32],
element: &SetExtensionElement,
width: usize,
height: usize,
) {
let opacity = element.opacity;
let split_y_px = (element.split_y * height as f32) as i32;
let feather = element.feather_px.max(1.0);
match element.extension_type {
ExtensionType::SkyReplacement => {
for y in 0..height {
let dist = split_y_px - y as i32;
let a = match element.blend_mode {
EdgeBlendMode::Hard => {
if dist > 0 {
1.0f32
} else {
0.0
}
}
EdgeBlendMode::Feather
| EdgeBlendMode::SmartEdge
| EdgeBlendMode::MatteBased => {
let t = (dist as f32 / feather).clamp(-1.0, 1.0);
(t * 0.5 + 0.5).clamp(0.0, 1.0)
}
} * opacity;
for x in 0..width {
let i = y * width + x;
alpha[i] = alpha[i].max(a);
}
}
}
ExtensionType::FloorExtension | ExtensionType::BackgroundPlate => {
for y in 0..height {
let dist = y as i32 - split_y_px;
let a = match element.blend_mode {
EdgeBlendMode::Hard => {
if dist >= 0 {
1.0f32
} else {
0.0
}
}
_ => {
let t = (dist as f32 / feather).clamp(-1.0, 1.0);
(t * 0.5 + 0.5).clamp(0.0, 1.0)
}
} * opacity;
for x in 0..width {
let i = y * width + x;
alpha[i] = alpha[i].max(a);
}
}
}
ExtensionType::ArchitectureExtension | ExtensionType::ForegroundElement => {
for i in 0..(width * height) {
alpha[i] = alpha[i].max(opacity);
}
}
}
}
#[must_use]
pub fn perspective(&self) -> &PerspectiveParams {
&self.perspective
}
#[must_use]
pub fn elements(&self) -> &[SetExtensionElement] {
&self.elements
}
}
impl Default for SetExtensionCompositor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn solid_rgb(w: usize, h: usize, r: u8, g: u8, b: u8) -> Vec<u8> {
let mut buf = Vec::with_capacity(w * h * 3);
for _ in 0..(w * h) {
buf.push(r);
buf.push(g);
buf.push(b);
}
buf
}
#[test]
fn test_compositor_creation() {
let c = SetExtensionCompositor::new();
assert_eq!(c.element_count(), 0);
}
#[test]
fn test_add_remove_element() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement::sky("sky1", 0.4));
c.add_element(SetExtensionElement::floor("floor1", 0.6));
assert_eq!(c.element_count(), 2);
c.remove_element("sky1");
assert_eq!(c.element_count(), 1);
}
#[test]
fn test_active_element_count() {
let mut c = SetExtensionCompositor::new();
let mut el = SetExtensionElement::sky("sky", 0.5);
el.active = false;
c.add_element(el);
c.add_element(SetExtensionElement::floor("floor", 0.6));
assert_eq!(c.active_element_count(), 1);
}
#[test]
fn test_apply_no_elements_returns_physical() {
let c = SetExtensionCompositor::new();
let physical = solid_rgb(8, 8, 255, 0, 0);
let virtual_bg = solid_rgb(8, 8, 0, 0, 255);
let result = c.apply(&physical, &virtual_bg, 8, 8);
assert!(result.is_ok());
let frame = result.expect("ok");
assert_eq!(frame.get_pixel(0, 0), Some([255, 0, 0]));
}
#[test]
fn test_apply_sky_replacement_top_region() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement::sky("sky", 0.5).with_blend_mode(EdgeBlendMode::Hard));
let physical = solid_rgb(8, 8, 255, 0, 0);
let virtual_bg = solid_rgb(8, 8, 0, 255, 0);
let result = c.apply(&physical, &virtual_bg, 8, 8).expect("ok");
let top = result.get_pixel(4, 0).expect("ok");
assert_eq!(top, [0, 255, 0], "top should be virtual sky");
let bot = result.get_pixel(4, 7).expect("ok");
assert_eq!(bot, [255, 0, 0], "bottom should be physical");
}
#[test]
fn test_apply_floor_extension_bottom_region() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement::floor("fl", 0.5).with_blend_mode(EdgeBlendMode::Hard));
let physical = solid_rgb(8, 8, 255, 0, 0);
let virtual_bg = solid_rgb(8, 8, 0, 0, 255);
let result = c.apply(&physical, &virtual_bg, 8, 8).expect("ok");
let bot = result.get_pixel(4, 7).expect("ok");
assert_eq!(bot, [0, 0, 255], "bottom should be virtual floor");
}
#[test]
fn test_apply_wrong_size() {
let c = SetExtensionCompositor::new();
let physical = vec![0u8; 10];
let virtual_bg = solid_rgb(8, 8, 0, 0, 0);
let result = c.apply(&physical, &virtual_bg, 8, 8);
assert!(result.is_err());
}
#[test]
fn test_apply_extensions_applied_count() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement::sky("s1", 0.4));
c.add_element(SetExtensionElement::floor("f1", 0.6));
let physical = solid_rgb(8, 8, 100, 100, 100);
let virtual_bg = solid_rgb(8, 8, 200, 200, 200);
let result = c.apply(&physical, &virtual_bg, 8, 8).expect("ok");
assert_eq!(result.extensions_applied, 2);
}
#[test]
fn test_apply_opacity_zero_no_blend() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement::sky("sky", 0.5).with_opacity(0.0));
let physical = solid_rgb(8, 8, 255, 0, 0);
let virtual_bg = solid_rgb(8, 8, 0, 255, 0);
let result = c.apply(&physical, &virtual_bg, 8, 8).expect("ok");
let px = result.get_pixel(4, 0).expect("ok");
assert_eq!(px, [255, 0, 0], "zero opacity: should stay physical");
}
#[test]
fn test_perspective_horizon_level_camera() {
let params = PerspectiveParams::default(); let hy = params.horizon_y_normalized(1080);
assert!(
(hy - 0.5).abs() < 0.05,
"level camera horizon at midpoint: {hy}"
);
}
#[test]
fn test_perspective_horizon_tilted_up() {
let params = PerspectiveParams {
tilt_deg: 10.0, ..PerspectiveParams::default()
};
let hy = params.horizon_y_normalized(1080);
assert!(hy > 0.5, "tilt up should move horizon below center: {hy}");
}
#[test]
fn test_perspective_horizon_tilted_down() {
let params = PerspectiveParams {
tilt_deg: -10.0,
..PerspectiveParams::default()
};
let hy = params.horizon_y_normalized(1080);
assert!(hy < 0.5, "tilt down should move horizon above center: {hy}");
}
#[test]
fn test_set_extension_element_builder() {
let el = SetExtensionElement::sky("test", 0.4)
.with_opacity(0.8)
.with_blend_mode(EdgeBlendMode::Hard);
assert_eq!(el.id, "test");
assert!((el.opacity - 0.8).abs() < 1e-5);
assert_eq!(el.blend_mode, EdgeBlendMode::Hard);
}
#[test]
fn test_background_plate_covers_all() {
let mut c = SetExtensionCompositor::new();
c.add_element(SetExtensionElement {
id: "bg".to_string(),
extension_type: ExtensionType::BackgroundPlate,
blend_mode: EdgeBlendMode::Hard,
feather_px: 1.0,
split_y: 0.0,
split_x: 0.5,
active: true,
opacity: 1.0,
});
let physical = solid_rgb(4, 4, 255, 0, 0);
let virtual_bg = solid_rgb(4, 4, 0, 255, 0);
let result = c.apply(&physical, &virtual_bg, 4, 4).expect("ok");
for y in 0..4 {
for x in 0..4 {
let px = result.get_pixel(x, y).expect("ok");
assert_eq!(px, [0, 255, 0], "all should be virtual at ({x},{y})");
}
}
}
}