1use super::{CameraId, MultiCameraState};
7use crate::math::{Point3, Vector3};
8use crate::{tracking::CameraPose, Result, VirtualProductionError};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MultiCameraConfig {
14 pub num_cameras: usize,
16 pub auto_switch: bool,
18}
19
20impl Default for MultiCameraConfig {
21 fn default() -> Self {
22 Self {
23 num_cameras: 1,
24 auto_switch: false,
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum AutoSwitchCriteria {
32 BestAngle,
36 NearestDistance,
38 WeightedScore,
42 CenteredFraming,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AutoSwitchConfig {
50 pub criteria: AutoSwitchCriteria,
52 pub min_switch_interval_ms: u64,
55 pub hysteresis: f64,
59 pub distance_weight: f64,
61 pub camera_fov_h: f64,
63}
64
65impl Default for AutoSwitchConfig {
66 fn default() -> Self {
67 Self {
68 criteria: AutoSwitchCriteria::BestAngle,
69 min_switch_interval_ms: 2000,
70 hysteresis: 0.15,
71 distance_weight: 0.3,
72 camera_fov_h: std::f64::consts::PI / 3.0, }
74 }
75}
76
77#[derive(Debug, Clone, Copy)]
79pub struct CameraScore {
80 pub camera_id: CameraId,
82 pub angle_to_talent: f64,
84 pub distance_to_talent: f64,
86 pub score: f64,
88 pub in_fov: bool,
90}
91
92pub struct MultiCameraManager {
94 config: MultiCameraConfig,
95 state: MultiCameraState,
96 auto_switch_config: AutoSwitchConfig,
98 last_switch_timestamp_ns: Option<u64>,
100 switch_history: Vec<SwitchEvent>,
102}
103
104#[derive(Debug, Clone)]
106pub struct SwitchEvent {
107 pub timestamp_ns: u64,
109 pub from: CameraId,
111 pub to: CameraId,
113 pub score: f64,
115 pub reason: String,
117}
118
119impl MultiCameraManager {
120 pub fn new(config: MultiCameraConfig) -> Result<Self> {
122 if config.num_cameras == 0 {
123 return Err(VirtualProductionError::MultiCamera(
124 "Number of cameras must be > 0".to_string(),
125 ));
126 }
127
128 Ok(Self {
129 config,
130 state: MultiCameraState::new(),
131 auto_switch_config: AutoSwitchConfig::default(),
132 last_switch_timestamp_ns: None,
133 switch_history: Vec::new(),
134 })
135 }
136
137 pub fn with_auto_switch(
139 config: MultiCameraConfig,
140 auto_switch_config: AutoSwitchConfig,
141 ) -> Result<Self> {
142 if config.num_cameras == 0 {
143 return Err(VirtualProductionError::MultiCamera(
144 "Number of cameras must be > 0".to_string(),
145 ));
146 }
147
148 Ok(Self {
149 config: MultiCameraConfig {
150 auto_switch: true,
151 ..config
152 },
153 state: MultiCameraState::new(),
154 auto_switch_config,
155 last_switch_timestamp_ns: None,
156 switch_history: Vec::new(),
157 })
158 }
159
160 pub fn update_camera(&mut self, camera_id: CameraId, pose: CameraPose) {
162 if let Some(entry) = self.state.poses.iter_mut().find(|(id, _)| *id == camera_id) {
163 entry.1 = pose;
164 } else {
165 self.state.poses.push((camera_id, pose));
166 }
167 }
168
169 pub fn set_active_camera(&mut self, camera_id: CameraId) {
171 self.state.active_camera = camera_id;
172 }
173
174 #[must_use]
176 pub fn active_camera(&self) -> CameraId {
177 self.state.active_camera
178 }
179
180 #[must_use]
182 pub fn active_pose(&self) -> Option<&CameraPose> {
183 self.state.active_pose()
184 }
185
186 #[must_use]
188 pub fn all_poses(&self) -> &[(CameraId, CameraPose)] {
189 &self.state.poses
190 }
191
192 #[must_use]
194 pub fn config(&self) -> &MultiCameraConfig {
195 &self.config
196 }
197
198 #[must_use]
200 pub fn auto_switch_config(&self) -> &AutoSwitchConfig {
201 &self.auto_switch_config
202 }
203
204 pub fn set_auto_switch_config(&mut self, config: AutoSwitchConfig) {
206 self.auto_switch_config = config;
207 }
208
209 #[must_use]
213 pub fn evaluate_cameras(&self, talent_position: &Point3<f64>) -> Vec<CameraScore> {
214 let mut scores: Vec<CameraScore> = self
215 .state
216 .poses
217 .iter()
218 .map(|(camera_id, pose)| self.score_camera(*camera_id, pose, talent_position))
219 .collect();
220
221 scores.sort_by(|a, b| {
222 a.score
223 .partial_cmp(&b.score)
224 .unwrap_or(std::cmp::Ordering::Equal)
225 });
226 scores
227 }
228
229 pub fn auto_select(
235 &mut self,
236 talent_position: &Point3<f64>,
237 current_timestamp_ns: u64,
238 ) -> Option<CameraId> {
239 if !self.config.auto_switch {
240 return None;
241 }
242
243 if self.state.poses.is_empty() {
244 return None;
245 }
246
247 if let Some(last_ts) = self.last_switch_timestamp_ns {
249 let elapsed_ns = current_timestamp_ns.saturating_sub(last_ts);
250 let min_interval_ns = self.auto_switch_config.min_switch_interval_ms * 1_000_000;
251 if elapsed_ns < min_interval_ns {
252 return None;
253 }
254 }
255
256 let scores = self.evaluate_cameras(talent_position);
257 if scores.is_empty() {
258 return None;
259 }
260
261 let best = &scores[0];
262 let current_id = self.state.active_camera;
263
264 if best.camera_id == current_id {
266 return None;
267 }
268
269 let current_score = scores
271 .iter()
272 .find(|s| s.camera_id == current_id)
273 .map(|s| s.score)
274 .unwrap_or(f64::MAX);
275
276 let improvement = if current_score > 1e-10 {
278 (current_score - best.score) / current_score
279 } else {
280 1.0
281 };
282
283 if improvement < self.auto_switch_config.hysteresis {
284 return None;
285 }
286
287 let previous_camera = self.state.active_camera;
289 self.state.active_camera = best.camera_id;
290 self.last_switch_timestamp_ns = Some(current_timestamp_ns);
291
292 self.switch_history.push(SwitchEvent {
293 timestamp_ns: current_timestamp_ns,
294 from: previous_camera,
295 to: best.camera_id,
296 score: best.score,
297 reason: format!(
298 "{:?}: improvement {:.1}%",
299 self.auto_switch_config.criteria,
300 improvement * 100.0
301 ),
302 });
303
304 Some(best.camera_id)
305 }
306
307 #[must_use]
309 pub fn switch_history(&self) -> &[SwitchEvent] {
310 &self.switch_history
311 }
312
313 pub fn clear_switch_history(&mut self) {
315 self.switch_history.clear();
316 }
317
318 fn score_camera(
323 &self,
324 camera_id: CameraId,
325 pose: &CameraPose,
326 talent_position: &Point3<f64>,
327 ) -> CameraScore {
328 let cam_pos = pose.position;
329 let direction_to_talent = Vector3::new(
330 talent_position.x - cam_pos.x,
331 talent_position.y - cam_pos.y,
332 talent_position.z - cam_pos.z,
333 );
334
335 let distance = direction_to_talent.norm();
336 let dir_normalized = if distance > 1e-10 {
337 Vector3::new(
338 direction_to_talent.x / distance,
339 direction_to_talent.y / distance,
340 direction_to_talent.z / distance,
341 )
342 } else {
343 Vector3::new(0.0, 0.0, -1.0)
344 };
345
346 let forward = pose.forward();
348
349 let cos_angle = forward.x * dir_normalized.x
351 + forward.y * dir_normalized.y
352 + forward.z * dir_normalized.z;
353 let angle = cos_angle.clamp(-1.0, 1.0).acos();
354
355 let in_fov = angle < self.auto_switch_config.camera_fov_h * 0.5;
356
357 let score = match self.auto_switch_config.criteria {
358 AutoSwitchCriteria::BestAngle => {
359 angle / std::f64::consts::PI
361 }
362 AutoSwitchCriteria::NearestDistance => {
363 (distance / 20.0).min(1.0)
365 }
366 AutoSwitchCriteria::WeightedScore => {
367 let w = self.auto_switch_config.distance_weight;
368 let angle_norm = angle / std::f64::consts::PI;
369 let dist_norm = (distance / 20.0).min(1.0);
370 (1.0 - w) * angle_norm + w * dist_norm
371 }
372 AutoSwitchCriteria::CenteredFraming => {
373 if !in_fov {
375 1.0 } else {
377 let half_fov = self.auto_switch_config.camera_fov_h * 0.5;
378 if half_fov > 1e-10 {
379 angle / half_fov
380 } else {
381 0.0
382 }
383 }
384 }
385 };
386
387 CameraScore {
388 camera_id,
389 angle_to_talent: angle,
390 distance_to_talent: distance,
391 score,
392 in_fov,
393 }
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::math::UnitQuaternion;
401
402 #[test]
403 fn test_multicam_manager() {
404 let config = MultiCameraConfig {
405 num_cameras: 4,
406 auto_switch: false,
407 };
408 let manager = MultiCameraManager::new(config);
409 assert!(manager.is_ok());
410 }
411
412 #[test]
413 fn test_multicam_update() {
414 let config = MultiCameraConfig::default();
415 let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
416
417 let pose = CameraPose::new(Point3::origin(), UnitQuaternion::identity(), 0);
418
419 manager.update_camera(CameraId(0), pose);
420 assert!(manager.active_pose().is_some());
421 }
422
423 #[test]
424 fn test_multicam_switch() {
425 let config = MultiCameraConfig {
426 num_cameras: 2,
427 auto_switch: false,
428 };
429 let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
430
431 manager.set_active_camera(CameraId(1));
432 assert_eq!(manager.active_camera(), CameraId(1));
433 }
434
435 fn make_camera_pose(x: f64, y: f64, z: f64, look_z: f64) -> CameraPose {
438 let _ = look_z; CameraPose::new(Point3::new(x, y, z), UnitQuaternion::identity(), 0)
441 }
442
443 #[test]
444 fn test_evaluate_cameras_best_angle() {
445 let config = MultiCameraConfig {
446 num_cameras: 3,
447 auto_switch: true,
448 };
449 let mut manager = MultiCameraManager::with_auto_switch(
450 config,
451 AutoSwitchConfig {
452 criteria: AutoSwitchCriteria::BestAngle,
453 ..AutoSwitchConfig::default()
454 },
455 )
456 .expect("should succeed in test");
457
458 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
460 manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
462 manager.update_camera(CameraId(2), make_camera_pose(10.0, 0.0, 0.0, -1.0));
464
465 let talent_pos = Point3::new(0.0, 0.0, -5.0);
467 let scores = manager.evaluate_cameras(&talent_pos);
468
469 assert_eq!(scores.len(), 3);
470 assert_eq!(scores[0].camera_id, CameraId(0));
472 assert!(
473 scores[0].score < scores[1].score,
474 "cam0 score {} should be < cam1 score {}",
475 scores[0].score,
476 scores[1].score
477 );
478 }
479
480 #[test]
481 fn test_evaluate_cameras_nearest_distance() {
482 let config = MultiCameraConfig {
483 num_cameras: 2,
484 auto_switch: true,
485 };
486 let mut manager = MultiCameraManager::with_auto_switch(
487 config,
488 AutoSwitchConfig {
489 criteria: AutoSwitchCriteria::NearestDistance,
490 ..AutoSwitchConfig::default()
491 },
492 )
493 .expect("should succeed in test");
494
495 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
496 manager.update_camera(CameraId(1), make_camera_pose(0.0, 0.0, -4.0, -1.0));
497
498 let talent_pos = Point3::new(0.0, 0.0, -5.0);
500 let scores = manager.evaluate_cameras(&talent_pos);
501
502 assert_eq!(scores[0].camera_id, CameraId(1));
503 assert!(
504 scores[0].distance_to_talent < scores[1].distance_to_talent,
505 "cam1 should be closer"
506 );
507 }
508
509 #[test]
510 fn test_auto_select_switches_camera() {
511 let config = MultiCameraConfig {
512 num_cameras: 2,
513 auto_switch: true,
514 };
515 let mut manager = MultiCameraManager::with_auto_switch(
516 config,
517 AutoSwitchConfig {
518 criteria: AutoSwitchCriteria::BestAngle,
519 min_switch_interval_ms: 0, hysteresis: 0.05,
521 ..AutoSwitchConfig::default()
522 },
523 )
524 .expect("should succeed in test");
525
526 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
527 manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
528
529 manager.set_active_camera(CameraId(0));
531
532 let talent_pos = Point3::new(5.0, 0.0, -5.0);
535 let result = manager.auto_select(&talent_pos, 1_000_000_000);
536
537 assert_eq!(result, Some(CameraId(1)));
539 assert_eq!(manager.active_camera(), CameraId(1));
540 }
541
542 #[test]
543 fn test_auto_select_respects_min_interval() {
544 let config = MultiCameraConfig {
545 num_cameras: 2,
546 auto_switch: true,
547 };
548 let mut manager = MultiCameraManager::with_auto_switch(
549 config,
550 AutoSwitchConfig {
551 criteria: AutoSwitchCriteria::NearestDistance,
552 min_switch_interval_ms: 2000,
553 hysteresis: 0.0,
554 ..AutoSwitchConfig::default()
555 },
556 )
557 .expect("should succeed in test");
558
559 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
561 manager.update_camera(CameraId(1), make_camera_pose(10.0, 0.0, 0.0, -1.0));
562 manager.set_active_camera(CameraId(0));
563
564 let talent_near_cam1 = Point3::new(10.0, 0.0, -1.0);
566 let r1 = manager.auto_select(&talent_near_cam1, 0);
567 assert!(r1.is_some(), "should switch to nearer camera");
568
569 let talent_near_cam0 = Point3::new(0.0, 0.0, -1.0);
571 let r2 = manager.auto_select(&talent_near_cam0, 500_000_000); assert!(r2.is_none(), "should respect min switch interval");
573
574 let r3 = manager.auto_select(&talent_near_cam0, 3_000_000_000); assert!(r3.is_some(), "should allow switch after interval");
577 }
578
579 #[test]
580 fn test_auto_select_hysteresis() {
581 let config = MultiCameraConfig {
582 num_cameras: 2,
583 auto_switch: true,
584 };
585 let mut manager = MultiCameraManager::with_auto_switch(
586 config,
587 AutoSwitchConfig {
588 criteria: AutoSwitchCriteria::BestAngle,
589 min_switch_interval_ms: 0,
590 hysteresis: 0.5, ..AutoSwitchConfig::default()
592 },
593 )
594 .expect("should succeed in test");
595
596 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
597 manager.update_camera(CameraId(1), make_camera_pose(1.0, 0.0, 0.0, -1.0));
598 manager.set_active_camera(CameraId(0));
599
600 let talent_pos = Point3::new(0.5, 0.0, -5.0);
602 let result = manager.auto_select(&talent_pos, 1_000_000_000);
603
604 assert!(result.is_none(), "hysteresis should prevent switch");
606 }
607
608 #[test]
609 fn test_auto_select_disabled() {
610 let config = MultiCameraConfig {
611 num_cameras: 2,
612 auto_switch: false,
613 };
614 let mut manager = MultiCameraManager::new(config).expect("should succeed in test");
615 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
616 manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
617
618 let talent_pos = Point3::new(5.0, 0.0, -5.0);
619 let result = manager.auto_select(&talent_pos, 1_000_000_000);
620 assert!(result.is_none(), "auto_select should be disabled");
621 }
622
623 #[test]
624 fn test_switch_history() {
625 let config = MultiCameraConfig {
626 num_cameras: 2,
627 auto_switch: true,
628 };
629 let mut manager = MultiCameraManager::with_auto_switch(
630 config,
631 AutoSwitchConfig {
632 criteria: AutoSwitchCriteria::BestAngle,
633 min_switch_interval_ms: 0,
634 hysteresis: 0.0,
635 ..AutoSwitchConfig::default()
636 },
637 )
638 .expect("should succeed in test");
639
640 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
641 manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
642 manager.set_active_camera(CameraId(0));
643
644 let talent_pos = Point3::new(5.0, 0.0, -5.0);
645 manager.auto_select(&talent_pos, 1_000_000_000);
646
647 assert_eq!(manager.switch_history().len(), 1);
648 assert_eq!(manager.switch_history()[0].from, CameraId(0));
649 assert_eq!(manager.switch_history()[0].to, CameraId(1));
650
651 manager.clear_switch_history();
652 assert!(manager.switch_history().is_empty());
653 }
654
655 #[test]
656 fn test_camera_score_in_fov() {
657 let config = MultiCameraConfig {
658 num_cameras: 1,
659 auto_switch: true,
660 };
661 let mut manager = MultiCameraManager::with_auto_switch(
662 config,
663 AutoSwitchConfig {
664 camera_fov_h: std::f64::consts::PI / 3.0, ..AutoSwitchConfig::default()
666 },
667 )
668 .expect("should succeed in test");
669
670 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
671
672 let scores_ahead = manager.evaluate_cameras(&Point3::new(0.0, 0.0, -5.0));
674 assert!(scores_ahead[0].in_fov, "talent ahead should be in FOV");
675
676 let scores_behind = manager.evaluate_cameras(&Point3::new(0.0, 0.0, 5.0));
678 assert!(
679 !scores_behind[0].in_fov,
680 "talent behind should not be in FOV"
681 );
682 }
683
684 #[test]
685 fn test_weighted_score_criteria() {
686 let config = MultiCameraConfig {
687 num_cameras: 2,
688 auto_switch: true,
689 };
690 let mut manager = MultiCameraManager::with_auto_switch(
691 config,
692 AutoSwitchConfig {
693 criteria: AutoSwitchCriteria::WeightedScore,
694 distance_weight: 0.5,
695 min_switch_interval_ms: 0,
696 hysteresis: 0.0,
697 ..AutoSwitchConfig::default()
698 },
699 )
700 .expect("should succeed in test");
701
702 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
703 manager.update_camera(CameraId(1), make_camera_pose(2.0, 0.0, 0.0, -1.0));
704
705 let scores = manager.evaluate_cameras(&Point3::new(1.0, 0.0, -3.0));
706 assert_eq!(scores.len(), 2);
708 for s in &scores {
709 assert!(
710 s.score >= 0.0 && s.score <= 1.0,
711 "score out of range: {}",
712 s.score
713 );
714 }
715 }
716
717 #[test]
718 fn test_centered_framing_criteria() {
719 let config = MultiCameraConfig {
720 num_cameras: 2,
721 auto_switch: true,
722 };
723 let mut manager = MultiCameraManager::with_auto_switch(
724 config,
725 AutoSwitchConfig {
726 criteria: AutoSwitchCriteria::CenteredFraming,
727 min_switch_interval_ms: 0,
728 hysteresis: 0.0,
729 camera_fov_h: std::f64::consts::PI / 3.0,
730 ..AutoSwitchConfig::default()
731 },
732 )
733 .expect("should succeed in test");
734
735 manager.update_camera(CameraId(0), make_camera_pose(0.0, 0.0, 0.0, -1.0));
736 manager.update_camera(CameraId(1), make_camera_pose(5.0, 0.0, 0.0, -1.0));
737
738 let scores = manager.evaluate_cameras(&Point3::new(0.0, 0.0, -5.0));
740 assert_eq!(scores[0].camera_id, CameraId(0));
741 assert!(
742 scores[0].score < 0.05,
743 "perfectly centered should have very low score: {}",
744 scores[0].score
745 );
746 }
747
748 #[test]
749 fn test_auto_select_no_cameras() {
750 let config = MultiCameraConfig {
751 num_cameras: 1,
752 auto_switch: true,
753 };
754 let mut manager = MultiCameraManager::with_auto_switch(config, AutoSwitchConfig::default())
755 .expect("should succeed in test");
756
757 let talent_pos = Point3::new(0.0, 0.0, -5.0);
759 let result = manager.auto_select(&talent_pos, 1_000_000_000);
760 assert!(result.is_none());
761 }
762}