use crate::{
bridge::export_thread::{
AccumulationCarry, ExportParams, ExportProgress, RemoteSelection, RenderedImage,
SceneSnapshot, render_image_rgba,
},
settings::LocalComputeTarget,
};
use indicatrix::{color::ColorSpace, renderer::gpu_backend::GpuBackend};
use std::sync::atomic::AtomicBool;
pub(super) struct VideoComputeConfig {
pub(super) remote: RemoteSelection,
pub(super) local_compute: LocalComputeTarget,
}
pub(super) enum FrameOutcome {
Rendered(Vec<u8>),
Cancelled,
Failed(String),
}
#[expect(
clippy::too_many_arguments,
reason = "every argument is a distinct piece of one frame's own render request \
(scene/pose, dimensions/samples, colour space, the whole-video compute \
config/GPU backend/calibration carry, cancellation, and progress \
reporting) -- bundling them into a struct would just move the same \
count into field access, not reduce it, matching \
`bridge::export_thread::render_image_rgba`'s own identical shape"
)]
pub(super) fn render_frame_rgba(
scene: &SceneSnapshot,
width: u32,
height: u32,
samples_per_pixel: u32,
cam_yaw: f32,
cam_pitch: f32,
color_space: ColorSpace,
config: &VideoComputeConfig,
gpu: &GpuBackend,
carry: &mut AccumulationCarry,
cancel: &AtomicBool,
report_progress: impl FnMut(ExportProgress),
) -> FrameOutcome {
let params = ExportParams {
width,
height,
samples_per_pixel: samples_per_pixel.max(1),
max_bounces: scene.max_bounces,
};
match render_image_rgba(
scene,
cam_yaw,
cam_pitch,
params,
color_space,
&config.remote,
gpu,
config.local_compute,
carry,
cancel,
report_progress,
) {
RenderedImage::Cancelled => FrameOutcome::Cancelled,
RenderedImage::Failed(message) => FrameOutcome::Failed(message),
RenderedImage::Rgba(rgba) => FrameOutcome::Rendered(rgba),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::render_thread::RenderContext;
use std::sync::Mutex;
#[test]
fn render_frame_rgba_produces_a_full_opaque_buffer() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()))
.expect("Diamond resolves");
let config = VideoComputeConfig {
remote: RemoteSelection::local_only(),
local_compute: LocalComputeTarget::Cpu,
};
let gpu = GpuBackend::disabled();
let mut carry = AccumulationCarry::default();
let cancel = AtomicBool::new(false);
let outcome = render_frame_rgba(
&scene,
8,
8,
1,
0.6,
0.5,
ColorSpace::Srgb,
&config,
&gpu,
&mut carry,
&cancel,
|_| {},
);
let FrameOutcome::Rendered(rgba) = outcome else {
panic!("expected a rendered frame");
};
assert_eq!(rgba.len(), 8 * 8 * 4);
assert!(
rgba.chunks(4).all(|px| px[3] == 255),
"every pixel must be fully opaque"
);
}
#[test]
fn render_frame_rgba_accepts_wide_gamut_color_spaces_too() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()))
.expect("Diamond resolves");
let config = VideoComputeConfig {
remote: RemoteSelection::local_only(),
local_compute: LocalComputeTarget::Cpu,
};
let gpu = GpuBackend::disabled();
let mut carry = AccumulationCarry::default();
let cancel = AtomicBool::new(false);
let outcome = render_frame_rgba(
&scene,
4,
4,
1,
0.0,
1.4,
ColorSpace::DisplayP3,
&config,
&gpu,
&mut carry,
&cancel,
|_| {},
);
let FrameOutcome::Rendered(rgba) = outcome else {
panic!("expected a rendered frame");
};
assert_eq!(rgba.len(), 4 * 4 * 4);
}
#[test]
fn a_no_worker_configured_note_is_surfaced_once_not_once_per_frame() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()))
.expect("Diamond resolves");
let config = VideoComputeConfig {
remote: RemoteSelection {
compute_target: crate::bridge::export_thread::ComputeTarget::Both,
..RemoteSelection::local_only()
},
local_compute: LocalComputeTarget::Cpu,
};
let gpu = GpuBackend::disabled();
let mut carry = AccumulationCarry::default();
let cancel = AtomicBool::new(false);
let mut notes = Vec::new();
for (cam_yaw, cam_pitch) in [(0.1f32, 0.1f32), (0.2f32, 0.2f32), (0.3f32, 0.3f32)] {
let notes = &mut notes;
let outcome = render_frame_rgba(
&scene,
4,
4,
1,
cam_yaw,
cam_pitch,
ColorSpace::Srgb,
&config,
&gpu,
&mut carry,
&cancel,
|progress| {
if let Some(note) = progress.note {
notes.push(note);
}
},
);
assert!(matches!(outcome, FrameOutcome::Rendered(_)));
}
assert_eq!(
notes.len(),
1,
"the no-worker-configured note must surface once for the whole carry, not \
once per frame: {notes:?}"
);
}
#[test]
fn a_failing_final_picture_falls_back_to_full_data_once_per_sweep() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()))
.expect("Diamond resolves");
let config = VideoComputeConfig {
remote: RemoteSelection {
compute_target: crate::bridge::export_thread::ComputeTarget::Both,
worker: Some(crate::settings::WorkerSettings {
address: "127.0.0.1:1".to_string(),
cert_dir: std::env::temp_dir()
.join("no-such-final-picture-bundle")
.display()
.to_string(),
..crate::settings::WorkerSettings::default()
}),
transfer: crate::settings::ExportTransfer::FinalPicture,
contribute_local: false,
},
local_compute: LocalComputeTarget::Cpu,
};
let gpu = GpuBackend::disabled();
let mut carry = AccumulationCarry::default();
let cancel = AtomicBool::new(false);
let mut notes = Vec::new();
for cam_yaw in [0.1_f32, 0.2, 0.3] {
let notes = &mut notes;
let outcome = render_frame_rgba(
&scene,
4,
4,
1,
cam_yaw,
0.2,
ColorSpace::Srgb,
&config,
&gpu,
&mut carry,
&cancel,
|progress| notes.extend(progress.note),
);
assert!(matches!(outcome, FrameOutcome::Rendered(ref rgba) if rgba.len() == 64));
}
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(notes[0].contains("final-picture"), "{notes:?}");
}
}