//! Bounded orthographic production rendering. GPU work runs on the native //! current context, one request per UI update; sampling/filtering/PNG I/O run //! on the worker. The worker never waits for a preview acknowledgment. use crate::{ equipment, preview::{self, Mat4, PreviewCharacter, PreviewControls}, project::CameraFraming, render_job::{ImageBuf, RenderEvent}, }; use glow::HasContext; use std::{ path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, thread, }; pub const SCALE: u32 = 8; pub const MAX_HIGHRES: u32 = 2048; pub const PASSES: [&str; 6] = [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]; pub const NEAR: f32 = 0.05; pub const FAR: f32 = 40.; #[derive(Clone)] pub struct Draw { pub positions: Arc>, pub normals: Arc>, pub uvs: Arc>, pub vertex_colors: Arc>, pub indices: Vec, pub color: [f32; 4], pub albedo: Option>, pub shiny: Option>, pub specular: f32, pub cutoff: f32, pub flat_normals: bool, } #[derive(Clone)] pub struct Scene { pub draws: Vec, pub view: Mat4, pub projection: Mat4, pub width: u32, pub height: u32, } #[derive(Debug)] pub struct Buffers { pub width: u32, pub height: u32, pub diffuse: Vec, pub normal: Vec, pub specular: Vec, pub depth: Vec, } #[derive(Debug)] pub struct Target { pub diffuse: ImageBuf, pub normal: ImageBuf, pub specular: ImageBuf, pub depth: Vec, pub depth_preview: ImageBuf, } /// Content fingerprint of a source file used for a production job. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceFingerprint { pub path: String, pub sha256: String, } #[derive(Clone)] pub struct RenderJob { pub character: PreviewCharacter, pub sex: String, pub preset: Option, pub target_width: u32, pub target_height: u32, pub fps: u32, pub framing: CameraFraming, pub output: PathBuf, pub project_id: String, pub project_name: String, pub body_model: SourceFingerprint, /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body. pub preset_sha256: Option, } #[derive(Debug, Clone, PartialEq)] pub struct ClipPlan { pub source_name: String, pub token: String, pub frames: u32, } #[derive(Debug, Clone, PartialEq)] pub struct Plan { pub clips: Vec, pub images: u32, pub passes: u32, pub files: u32, } pub fn plan(body: &PreviewCharacter, fps: u32) -> Result { if fps == 0 || fps > 240 { return Err("fps moet 1–240 zijn".into()); } let clips = if body.clips.is_empty() { vec![ClipPlan { source_name: String::new(), token: "clip000-bind".into(), frames: 1, }] } else { body.clips .iter() .enumerate() .map(|(i, c)| { let duration = c.duration; if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. { return Err("ongeldige of te lange animatie".into()); } // Exporter float timestamps commonly put a nominal 1-second // endpoint one ULP above 1. Do not duplicate that terminal pose. let samples = f64::from(duration) * f64::from(fps); let nearest = samples.round(); let samples = if (samples - nearest).abs() <= 4. * f64::from(f32::EPSILON) * samples.max(1.) { nearest } else { samples }; Ok(ClipPlan { source_name: c.name.clone(), token: clip_token(i, &c.name), frames: samples.ceil().max(1.) as u32, }) }) .collect::, String>>()? }; let frames = clips .iter() .try_fold(0u32, |n, c| n.checked_add(c.frames)) .ok_or("te veel frames")?; let images = frames.checked_mul(36).ok_or("te veel beelden")?; let passes = images.checked_mul(6).ok_or("te veel passes")?; let files = images.checked_mul(4).ok_or("te veel bestanden")?; if files > 500000 { return Err("batch groter dan 500000 uitvoerbestanden".into()); } Ok(Plan { clips, images, passes, files, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Sample { pub clip: usize, pub frame: u32, pub pitch: i32, pub yaw: u32, } pub fn samples(plan: &Plan) -> impl Iterator + '_ { plan.clips.iter().enumerate().flat_map(|(clip, c)| { (0..c.frames).flat_map(move |frame| { [-30, 0, 30].into_iter().flat_map(move |pitch| { (0..12).map(move |y| Sample { clip, frame, pitch, yaw: y * 30, }) }) }) }) } pub fn clip_token(index: usize, name: &str) -> String { let slug: String = name .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .take(48) .collect(); format!( "clip{index:03}-{}", if slug.is_empty() { "animation" } else { &slug } ) } pub fn output_path( sex: &str, set: &str, clip: &str, frame: u32, pitch: i32, yaw: u32, map: &str, ) -> Result { if !["male", "female"].contains(&sex) || !(set == "body" || equipment::valid_name(set)) || !equipment::valid_name(clip) || ![-30, 0, 30].contains(&pitch) || yaw >= 360 || !yaw.is_multiple_of(30) || !["diffuse", "normal", "depth", "specular"].contains(&map) { return Err("ongeldig uitvoerpad/token".into()); } Ok(PathBuf::from(sex) .join(set) .join(clip) .join(format!("f{frame:06}")) .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png"))) } pub use crate::camera::matrices as camera; fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] { std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12]) } fn normalize(v: [f32; 3]) -> [f32; 3] { let l = v.iter().map(|x| x * x).sum::().sqrt(); if l > 1e-10 { v.map(|x| x / l) } else { [0., 0., 1.] } } fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] { let a = [m[0], m[1], m[2]]; let b = [m[4], m[5], m[6]]; let c = [m[8], m[9], m[10]]; let cross = |a: [f32; 3], b: [f32; 3]| { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ] }; let x = cross(b, c); let y = cross(c, a); let z = cross(a, b); let det = a.iter().zip(x).map(|(a, b)| a * b).sum::(); if det.abs() < 1e-12 { return normalize(n); } normalize(std::array::from_fn(|i| { (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det })) } pub fn scene( job: &RenderJob, clip: &str, time: f32, pitch: f32, yaw: f32, cache: &mut equipment::TextureCache, ) -> Result { let body = &job.character; let mut draws = Vec::new(); if let Some(preset) = &job.preset { let controls = PreviewControls { clip_name: clip.into(), time, ..Default::default() }; for layer in equipment::layers_cached( preset, usize::from(job.sex == "female"), body, &controls, cache, )? { let m = layer.model; draws.push(Draw { positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()), normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()), uvs: Arc::new( layer .mesh .uvs .into_iter() .map(|[u, v]| [u, 1. - v]) .collect(), ), vertex_colors: Arc::new(Vec::new()), indices: layer.mesh.indices, color: layer.mesh.color, albedo: layer.albedo, shiny: layer.shiny, specular: layer.specular, cutoff: 0.5, flat_normals: false, }); } } else { let bones = preview::sample_skin_matrices(body, clip, time); let mut positions = Vec::with_capacity(body.positions.len()); let mut normals = Vec::with_capacity(body.positions.len()); for (i, p) in body.positions.iter().enumerate() { let mut m = preview::identity(); if !bones.is_empty() && body.weights.len() == body.positions.len() { m = [0.; 16]; let joints = body.joints.get(i).copied().unwrap_or([0; 4]); for (j, w) in joints.into_iter().zip(body.weights[i]) { let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?; for k in 0..16 { m[k] += b[k] * w; } } } positions.push(point(&m, *p)); normals.push(normal( &m, body.normals.get(i).copied().unwrap_or([0., 1., 0.]), )); } let positions = Arc::new(positions); let normals = Arc::new(normals); let uvs = Arc::new(body.uvs.clone()); let colors = Arc::new(body.colors.clone()); if body.surfaces.is_empty() { draws.push(Draw { positions, normals, uvs, vertex_colors: colors, indices: body.indices.clone(), color: body.base_color, albedo: None, shiny: None, specular: 0., cutoff: 0.001, flat_normals: !body.source_normals_complete, }); } else { for surface in &body.surfaces { draws.push(Draw { positions: positions.clone(), normals: normals.clone(), uvs: uvs.clone(), vertex_colors: colors.clone(), indices: body.indices[surface.first..surface.first + surface.count].to_vec(), color: surface.color, albedo: surface.albedo.clone(), shiny: None, specular: 0., cutoff: surface.cutoff, flat_normals: surface.flat_normals, }); } } } let (view, projection) = camera( crate::camera::resolve(body, job.framing).framing, yaw, pitch, job.target_width as f32 / job.target_height as f32, ); Ok(Scene { draws, view, projection, width: job.target_width * SCALE, height: job.target_height * SCALE, }) } impl Scene { pub fn validate(&self) -> Result<(), String> { if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.draws.len() > 4096 || self .view .iter() .chain(&self.projection) .any(|v| !v.is_finite()) { return Err("ongeldige GPU-scène/afmetingen".into()); } for d in &self.draws { if d.positions.len() > 1_000_000 || d.positions.len() != d.normals.len() || d.indices.len() > 3_000_000 || !d.indices.len().is_multiple_of(3) || d.indices.iter().any(|i| *i as usize >= d.positions.len()) || d.positions .iter() .chain(d.normals.iter()) .flatten() .chain(d.uvs.iter().flatten()) .chain(d.vertex_colors.iter().flatten()) .chain(d.color.iter()) .any(|v| !v.is_finite()) || !d.specular.is_finite() || !d.cutoff.is_finite() { return Err("ongeldige of te grote GPU-geometrie".into()); } for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() { if t.width == 0 || t.height == 0 || t.width > 2048 || t.height > 2048 || t.rgba.len() != t.width as usize * t.height as usize * 4 { return Err("ongeldige GPU-texture".into()); } } } Ok(()) } } #[derive(Default)] pub struct GpuRenderer { program: Option, vao: Option, vbo: Option, ebo: Option, fbo: Option, colors: Vec, depth: Option, extent: [u32; 2], } impl GpuRenderer { /// # Safety /// A native OpenGL 3.3 context must be current on this thread. pub unsafe fn destroy(&mut self, gl: &glow::Context) { self.destroy_target(gl); if let Some(x) = self.program.take() { gl.delete_program(x); } if let Some(x) = self.vao.take() { gl.delete_vertex_array(x); } if let Some(x) = self.vbo.take() { gl.delete_buffer(x); } if let Some(x) = self.ebo.take() { gl.delete_buffer(x); } } unsafe fn destroy_target(&mut self, gl: &glow::Context) { for x in self.colors.drain(..) { gl.delete_texture(x); } if let Some(x) = self.depth.take() { gl.delete_renderbuffer(x); } if let Some(x) = self.fbo.take() { gl.delete_framebuffer(x); } self.extent = [0, 0]; } unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> { if self.program.is_some() { return Ok(()); } let p = gl.create_program()?; let mut shaders = Vec::new(); for (kind, source) in [ (glow::VERTEX_SHADER, VERTEX), (glow::FRAGMENT_SHADER, FRAGMENT), ] { let shader = gl.create_shader(kind)?; gl.shader_source(shader, source); gl.compile_shader(shader); if !gl.get_shader_compile_status(shader) { let error = gl.get_shader_info_log(shader); gl.delete_shader(shader); for s in shaders { gl.delete_shader(s); } gl.delete_program(p); return Err(error); } gl.attach_shader(p, shader); shaders.push(shader); } gl.link_program(p); let result = if gl.get_program_link_status(p) { Ok(()) } else { Err(gl.get_program_info_log(p)) }; for s in shaders { gl.detach_shader(p, s); gl.delete_shader(s); } if let Err(e) = result { gl.delete_program(p); return Err(e); } self.program = Some(p); self.vao = Some(gl.create_vertex_array()?); self.vbo = Some(gl.create_buffer()?); self.ebo = Some(gl.create_buffer()?); Ok(()) } unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> { if self.extent == [w, h] { return Ok(()); } self.destroy_target(gl); self.fbo = Some(gl.create_framebuffer()?); gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); for slot in 0..3 { let t = gl.create_texture()?; self.colors.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, glow::RGBA8 as i32, w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, None, ); gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0 + slot, glow::TEXTURE_2D, Some(t), 0, ); } let d = gl.create_renderbuffer()?; self.depth = Some(d); gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d)); gl.renderbuffer_storage( glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, w as i32, h as i32, ); gl.framebuffer_renderbuffer( glow::FRAMEBUFFER, glow::DEPTH_ATTACHMENT, glow::RENDERBUFFER, Some(d), ); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE { return Err("productie-FBO onvolledig".into()); } self.extent = [w, h]; Ok(()) } /// Render and read back unlit color, view-space normals, scalar specular and /// the actual depth attachment. All four use the same winning surface. /// # Safety /// `gl` is current on the UI thread. GL state is reset for egui painting. pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result { if scene.width == 0 || scene.height == 0 || scene.width > MAX_HIGHRES || scene.height > MAX_HIGHRES { return Err("highres moet 1–2048 pixels zijn (8× target)".into()); } scene.validate()?; let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING); let mut vp = [0; 4]; gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp); let result = (|| { self.init(gl)?; self.target(gl, scene.width, scene.height)?; gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); gl.viewport(0, 0, scene.width as i32, scene.height as i32); gl.disable(glow::SCISSOR_TEST); gl.disable(glow::CULL_FACE); gl.disable(glow::BLEND); gl.enable(glow::DEPTH_TEST); gl.depth_mask(true); gl.depth_func(glow::LESS); gl.clear_depth_f32(1.); gl.clear_color(0., 0., 0., 0.); gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); let p = self.program.ok_or("shader ontbreekt")?; gl.use_program(Some(p)); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_view").as_ref(), false, &scene.view, ); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_projection").as_ref(), false, &scene.projection, ); gl.bind_vertex_array(self.vao); gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo); gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo); for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] { gl.enable_vertex_attrib_array(slot); gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4); } for draw in &scene.draws { if draw.positions.len() != draw.normals.len() || draw .indices .iter() .any(|i| *i as usize >= draw.positions.len()) || draw.indices.len() > i32::MAX as usize { return Err("ongeldige GPU-geometrie".into()); } let mut data = Vec::with_capacity(draw.positions.len() * 12); for (i, pos) in draw.positions.iter().enumerate() { data.extend_from_slice(pos); data.extend_from_slice(&draw.normals[i]); data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2])); data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4])); } gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4), glow::STREAM_DRAW, ); gl.buffer_data_u8_slice( glow::ELEMENT_ARRAY_BUFFER, std::slice::from_raw_parts( draw.indices.as_ptr().cast(), draw.indices.len() * 4, ), glow::STREAM_DRAW, ); gl.uniform_4_f32( gl.get_uniform_location(p, "u_color").as_ref(), draw.color[0], draw.color[1], draw.color[2], draw.color[3], ); gl.uniform_1_f32( gl.get_uniform_location(p, "u_specular").as_ref(), draw.specular, ); gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff); gl.uniform_1_i32( gl.get_uniform_location(p, "u_flat").as_ref(), i32::from(draw.flat_normals), ); let mut temp = Vec::new(); for (unit, name, texture) in [ (0, "u_albedo", draw.albedo.as_ref()), (1, "u_shiny", draw.shiny.as_ref()), ] { gl.active_texture(glow::TEXTURE0 + unit); gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32); gl.uniform_1_i32( gl.get_uniform_location(p, &format!("{name}_enabled")) .as_ref(), i32::from(texture.is_some()), ); if let Some(image) = texture { let t = gl.create_texture()?; temp.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, if unit == 0 { glow::SRGB8_ALPHA8 as i32 } else { glow::RGBA8 as i32 }, image.width as i32, image.height as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, Some(&image.rgba), ); } else { gl.bind_texture(glow::TEXTURE_2D, None); } } gl.draw_elements( glow::TRIANGLES, draw.indices.len() as i32, glow::UNSIGNED_INT, 0, ); for t in temp { gl.delete_texture(t); } } let pixels = scene.width as usize * scene.height as usize; let mut maps = Vec::new(); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1); for slot in 0..3 { let mut data = vec![0; pixels * 4]; gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot); gl.read_pixels( 0, 0, scene.width as i32, scene.height as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::Slice(&mut data), ); flip_rows(&mut data, scene.width as usize * 4, scene.height as usize); maps.push(data); } let mut depth = vec![1f32; pixels]; let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4); gl.read_pixels( 0, 0, scene.width as i32, scene.height as i32, glow::DEPTH_COMPONENT, glow::FLOAT, glow::PixelPackData::Slice(bytes), ); flip_rows(&mut depth, scene.width as usize, scene.height as usize); let error = gl.get_error(); if error != glow::NO_ERROR { return Err(format!("productie-GL fout 0x{error:x}")); } let specular = maps.pop().unwrap(); let normal = maps.pop().unwrap(); let diffuse = maps.pop().unwrap(); Ok(Buffers { width: scene.width, height: scene.height, diffuse, normal, specular, depth, }) })(); gl.bind_framebuffer( glow::FRAMEBUFFER, std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer), ); gl.viewport(vp[0], vp[1], vp[2], vp[3]); gl.bind_vertex_array(None); gl.use_program(None); gl.active_texture(glow::TEXTURE0); gl.disable(glow::DEPTH_TEST); gl.disable(glow::SCISSOR_TEST); gl.enable(glow::BLEND); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4); result } } fn flip_rows(data: &mut [T], stride: usize, height: usize) { for y in 0..height / 2 { let split = (height - 1 - y) * stride; let (a, b) = data.split_at_mut(split); a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]); } } const VERTEX: &str = r#"#version 330 layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color; uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color; void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;} "#; const FRAGMENT: &str = r#"#version 330 in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit uniform float u_specular; uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled; layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular; vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));} void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a) -> ImageBuf { ImageBuf { width: w, height: h, rgba, } } fn linear(x: u8) -> f32 { static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new(); LUT.get_or_init(|| { std::array::from_fn(|i| { let v = i as f32 / 255.; if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }) })[x as usize] } fn srgb(x: f32) -> u8 { let v = if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf(1. / 2.4) - 0.055 }; (v * 255.).round().clamp(0., 255.) as u8 } impl Buffers { pub fn validate(&self) -> Result<(), String> { let pixels = (self.width as usize) .checked_mul(self.height as usize) .ok_or("te veel pixels")?; if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.diffuse.len() != pixels * 4 || self.normal.len() != pixels * 4 || self.specular.len() != pixels * 4 || self.depth.len() != pixels || self .depth .iter() .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d)) { return Err("ongeldige GPU-mapafmetingen/data".into()); } Ok(()) } } pub fn detail_map(raw: &Buffers) -> Result, String> { raw.validate()?; let w = raw.width as usize; let h = raw.height as usize; let mut out = vec![0; w * h]; for y in 0..h { for x in 0..w { let i = y * w + x; let mut edge = 0f32; for (nx, ny) in [ (x.saturating_sub(1), y), ((x + 1).min(w - 1), y), (x, y.saturating_sub(1)), (x, (y + 1).min(h - 1)), ] { let j = ny * w + nx; let a = raw.diffuse[i * 4 + 3] as f32 / 255.; let b = raw.diffuse[j * 4 + 3] as f32 / 255.; edge = edge.max((a - b).abs()); if a > 0. && b > 0. { for c in 0..3 { edge = edge.max( (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(), ); edge = edge.max( (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs() / 255., ); } edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.)); } } out[i] = (edge * 255.).round().clamp(0., 255.) as u8; } } Ok(out) } pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf { let mut rgba = Vec::with_capacity(detail.len() * 4); for (i, v) in detail.iter().enumerate() { rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]); } image(raw.width, raw.height, rgba) } pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result { raw.validate()?; if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE { return Err("ongeldige target-afmetingen".into()); } if raw.width != w * SCALE || raw.height != h * SCALE || detail.len() != raw.width as usize * raw.height as usize { return Err("ongeldige 8× resolve-afmetingen".into()); } let count = w as usize * h as usize; let mut diffuse = vec![0; count * 4]; let mut normals = diffuse.clone(); let mut specular = diffuse.clone(); let mut depth = vec![0u16; count * 2]; let mut depth_preview = diffuse.clone(); for y in 0..h { for x in 0..w { let i = (y * w + x) as usize; let mut closest = 1f32; let mut coverage = 0f32; for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; coverage += a; if a > 0. && raw.depth[j].is_finite() { closest = closest.min(raw.depth[j]); } } } let alpha = (coverage / (SCALE * SCALE) as f32 * 255.) .round() .clamp(0., 255.) as u8; if alpha == 0 { continue; } let mut color = [0.; 3]; let mut n = [0.; 3]; let mut shiny = 0.; let mut sum = 0.; // One coherent front-surface cluster; never average foreground with a rear // surface. Edges get extra weight, and any thin covered feature survives. for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; if a == 0. || raw.depth[j] > closest + 0.0025 { continue; } let weight = a * (1. + 4. * detail[j] as f32 / 255.); sum += weight; for c in 0..3 { color[c] += linear(raw.diffuse[j * 4 + c]) * weight; n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight; } shiny += raw.specular[j * 4] as f32 * weight; } } if sum <= 0. { return Err("ongeldige depth/coverage in GPU-resultaat".into()); } n = normalize(n); let d = (closest.clamp(0., 1.) * 65535.).round() as u16; for c in 0..3 { diffuse[i * 4 + c] = srgb(color[c] / sum); normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8; specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8; depth_preview[i * 4 + c] = (d >> 8) as u8; } for map in [ &mut diffuse, &mut normals, &mut specular, &mut depth_preview, ] { map[i * 4 + 3] = alpha; } depth[i * 2] = d; depth[i * 2 + 1] = u16::from(alpha) * 257; } } Ok(Target { diffuse: image(w, h, diffuse), normal: image(w, h, normals), specular: image(w, h, specular), depth, depth_preview: image(w, h, depth_preview), }) } fn safe_parent(root: &Path, relative: &Path) -> Result { let relative = relative .to_str() .ok_or("ongeldig uitvoerpad")? .replace('\\', "/"); let relative = crate::project::portable_relative(&relative)?; let relative = relative.as_str(); let canonical = root.canonicalize().map_err(|e| e.to_string())?; let path = root.join(relative); let parent = path.parent().ok_or("uitvoermap ontbreekt")?; let mut prefix = root.to_path_buf(); for part in Path::new(relative) .parent() .unwrap_or(Path::new("")) .components() { prefix.push(part); if !prefix.exists() { std::fs::create_dir(&prefix).map_err(|e| e.to_string())?; } let real = prefix.canonicalize().map_err(|e| e.to_string())?; if !real.starts_with(&canonical) { return Err("uitvoer verlaat projectmap".into()); } } if !parent .canonicalize() .map_err(|e| e.to_string())? .starts_with(canonical) { return Err("onveilig uitvoerpad".into()); } Ok(path) } fn atomic_file( root: &Path, relative: &Path, write: impl FnOnce(&mut std::fs::File) -> Result<(), String>, ) -> Result<(), String> { let path = safe_parent(root, relative)?; static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let temp = path.with_extension(format!( "part-{}-{}", std::process::id(), TEMP_SEQ.fetch_add(1, Ordering::Relaxed) )); let mut owned = false; let result = (|| { let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&temp) .map_err(|e| e.to_string())?; owned = true; write(&mut file)?; file.sync_all().map_err(|e| e.to_string())?; std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?; Ok(()) })(); if owned { let _ = std::fs::remove_file(temp); } result } fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result, String> { if img.width == 0 || img.height == 0 || img.width > MAX_HIGHRES / SCALE || img.height > MAX_HIGHRES / SCALE || img.rgba.len() != img.width as usize * img.height as usize * 4 { return Err("ongeldige PNG-afmetingen".into()); } let mut cursor = std::io::Cursor::new(Vec::new()); { let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); if diffuse_srgb { encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); } let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&img.rgba) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; } Ok(cursor.into_inner()) } fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result, String> { if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE || depth.len() != w as usize * h as usize * 2 { return Err("ongeldige 16-bit depthafmetingen".into()); } let samples: Vec = depth.iter().flat_map(|x| x.to_be_bytes()).collect(); let mut cursor = std::io::Cursor::new(Vec::new()); { let mut encoder = png::Encoder::new(&mut cursor, w, h); encoder.set_color(png::ColorType::GrayscaleAlpha); encoder.set_depth(png::BitDepth::Sixteen); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&samples) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; } Ok(cursor.into_inner()) } /// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes. pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result { let diffuse_srgb = relative .file_name() .and_then(|p| p.to_str()) .map(|p| p.ends_with("_diffuse.png")) .unwrap_or(false); let bytes = encode_rgba_png(img, diffuse_srgb)?; let digest = crate::digest::sha256_hex(&bytes); atomic_file(root, relative, |file| { use std::io::Write; file.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(digest) } /// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex. pub fn write_depth( root: &Path, relative: &Path, w: u32, h: u32, depth: &[u16], ) -> Result { let bytes = encode_depth_png(w, h, depth)?; let digest = crate::digest::sha256_hex(&bytes); atomic_file(root, relative, |file| { use std::io::Write; file.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(digest) } /// Atomic no-clobber publication of already encoded bytes under the run root. pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> { atomic_file(root, relative, |file| { use std::io::Write; file.write_all(bytes).map_err(|e| e.to_string()) }) } fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> { use std::io::Write; let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; atomic_file(root, Path::new(name), |f| { f.write_all(&data).map_err(|e| e.to_string()) }) } pub struct GpuRequest { pub scene: Scene, reply: mpsc::SyncSender>, } impl GpuRequest { pub fn fail(self, error: String) { let _ = self.reply.send(Err(error)); } /// # Safety /// `gl` must be current on this UI thread. pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) { let result = renderer.render(gl, &self.scene); let _ = self.reply.send(result); } } #[derive(Default)] struct Pending { pass: Option, progress: Option, terminal: Option, } #[derive(Clone, Default)] pub struct Mailbox(Arc>); impl Mailbox { pub fn publish(&self, event: RenderEvent) { if let Ok(mut state) = self.0.lock() { match event { RenderEvent::Pass { .. } => { state.pass = Some(event); state.progress = None; } RenderEvent::Step { .. } => state.progress = Some(event), _ => state.terminal = Some(event), } } } pub fn drain(&self) -> Vec { let mut events = Vec::new(); if let Ok(mut s) = self.0.lock() { events.extend(s.pass.take()); events.extend(s.progress.take()); events.extend(s.terminal.take()); } events } } pub struct RenderThread { pub mailbox: Mailbox, pub gpu: mpsc::Receiver, pub cancel: Arc, pub handle: Option>, } impl Drop for RenderThread { fn drop(&mut self) { self.cancel.store(true, Ordering::Relaxed); } } /// Where a production batch writes: a fresh run directory, or an existing /// incomplete run that is recovered after contract and checksum verification. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RunTarget { Fresh, Resume(PathBuf), } pub fn spawn(job: RenderJob, ctx: Option) -> RenderThread { spawn_target(job, RunTarget::Fresh, ctx) } /// Resume a selected incomplete run (see `resume`). pub fn spawn_resume( job: RenderJob, run_dir: PathBuf, ctx: Option, ) -> RenderThread { spawn_target(job, RunTarget::Resume(run_dir), ctx) } fn spawn_target( job: RenderJob, target: RunTarget, ctx: Option, ) -> RenderThread { let mailbox = Mailbox::default(); let output = mailbox.clone(); let cancel = Arc::new(AtomicBool::new(false)); let flag = cancel.clone(); let (tx, gpu) = mpsc::channel(); let handle = thread::spawn(move || { let mut emit = |event| { output.publish(event); if let Some(ctx) = &ctx { ctx.request_repaint(); } }; let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit); if let Err(error) = result { if flag.load(Ordering::Relaxed) { emit(RenderEvent::Cancelled); } else { emit(RenderEvent::Failed(error)); } } }); RenderThread { mailbox, gpu, cancel, handle: Some(handle), } } /// The immutable export contract of a job: validated/resolved job, plan, /// set token and the exact `render.json` document. pub struct Contract { pub job: RenderJob, pub plan: Plan, pub set: String, pub render_doc: serde_json::Value, } /// Validate a job and build its `render.json` contract without touching disk. pub fn contract(mut job: RenderJob) -> Result { job.framing.validate()?; let pivot = crate::camera::resolve(&job.character, job.framing); job.framing = pivot.framing; if job.target_width == 0 || job.target_height == 0 || job.target_width > MAX_HIGHRES / SCALE || job.target_height > MAX_HIGHRES / SCALE { return Err("8× highres vereist targetafmetingen 1–256".into()); } if !["male", "female"].contains(&job.sex.as_str()) { return Err("ongeldig geslacht".into()); } if let Some(p) = &job.preset { if job.character.is_static() { return Err( "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(), ); } p.validate()?; if !p.applies[usize::from(job.sex == "female")] { return Err("equipment niet geldig voor dit geslacht".into()); } if p.attachments.is_empty() { return Err("equipment heeft geen onderdelen".into()); } } let plan = plan(&job.character, job.fps)?; let output_bytes = u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16; if output_bytes > 16 * 1024 * 1024 * 1024 { return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into()); } let set = job .preset .as_ref() .map(|p| p.name.as_str()) .unwrap_or("body") .to_string(); if job.project_id.is_empty() || job.project_id.len() > 128 { return Err("project.id ongeldig voor export-identiteit".into()); } if job.project_name.len() > 256 { return Err("project.name te lang voor export-identiteit".into()); } if !crate::digest::is_sha256_hex(&job.body_model.sha256) { return Err("body_model.sha256 ongeldig".into()); } crate::project::portable_relative(&job.body_model.path)?; match (&job.preset, &job.preset_sha256) { (None, None) => {} (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {} _ => return Err("equipment fingerprint past niet bij de job".into()), } let equipment_preset = job.preset.as_ref().map(|p| { serde_json::json!({ "name": p.name, "sha256": job.preset_sha256.as_ref().expect("checked"), }) }); let clips_json = plan .clips .iter() .enumerate() .map(|(i, c)| { serde_json::json!({ "name": c.source_name, "token": c.token, "frames": c.frames, "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.), }) }) .collect::>(); let render_doc = serde_json::json!({ "format_version": crate::export_set::OUTPUT_META_VERSION, "status": "incomplete", "project": {"id": job.project_id, "name": job.project_name}, "sex": job.sex, "set": set, "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256}, "equipment_preset": equipment_preset, "target": [job.target_width, job.target_height], "fps": job.fps, "highres_scale": SCALE, "framing": job.framing, "pivot_source": pivot.source, "near": NEAR, "far": FAR, "normal_space": "view (+Y up), PNG rows top-down", "camera_distance": 10, "yaw_zero": "camera on -Z", "pitches": [-30, 0, 30], "yaw_step": 30, "sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained", "alpha": "straight, opaque/cutout, coverage resolve", "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve", "depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)", "specular": "linear scalar; body defaults zero", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": clips_json, "images": plan.images, "passes": plan.passes, "png_files": plan.files, }); Ok(Contract { job, plan, set, render_doc, }) } /// Saved map tokens in per-image publication order. pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"]; /// Every planned target PNG (relative, `/`-separated) in render order, four per image. pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result, String> { let mut out = Vec::with_capacity(plan.files as usize); for s in samples(plan) { for map in SAVED_ORDER { let path = output_path( sex, set, &plan.clips[s.clip].token, s.frame, s.pitch, s.yaw, map, )?; out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/")); } } Ok(out) } #[cfg(test)] fn execute( job: RenderJob, gpu: &mpsc::Sender, cancel: &AtomicBool, ctx: Option<&eframe::egui::Context>, emit: &mut dyn FnMut(RenderEvent), ) -> Result<(), String> { execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit) } fn execute_target( job: RenderJob, target: RunTarget, gpu: &mpsc::Sender, cancel: &AtomicBool, ctx: Option<&eframe::egui::Context>, emit: &mut dyn FnMut(RenderEvent), ) -> Result<(), String> { let Contract { mut job, plan, set, render_doc, } = contract(job)?; let planned = planned_paths(&plan, &job.sex, &set)?; std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?; let mut session = match target { RunTarget::Fresh => { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| e.to_string())? .as_nanos(); let root = job .output .join(format!("run-{stamp}-{}", std::process::id())); std::fs::create_dir(&root).map_err(|e| e.to_string())?; let session = crate::resume::RunSession::fresh(&root)?; write_json(&root, "render.json", render_doc.clone())?; session } RunTarget::Resume(dir) => { let total = plan.passes; crate::resume::RunSession::resume( &job.output, &dir, &render_doc, &planned, &mut |done, all| { emit(RenderEvent::Step { index: 0, total, label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"), }) }, )? } }; let root = session.root.clone(); // Internal unique names select every source clip, even duplicate display names. for (i, c) in job.character.clips.iter_mut().enumerate() { c.name = format!("render-clip-{i}"); } let mut completed = 0u32; let mut png_sha256 = std::collections::BTreeMap::::new(); let mut cache = equipment::TextureCache::default(); let result = (|| { for (index, sample) in samples(&plan).enumerate() { let clip = &plan.clips[sample.clip]; let frame = sample.frame; let pitch = sample.pitch; let yaw = sample.yaw; let paths = &planned[index * 4..index * 4 + 4]; if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } let pose = format!( "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}", job.sex, clip.source_name, clip.frames ); if paths.iter().all(|p| session.verified.contains_key(p)) { for p in paths { png_sha256.insert(p.clone(), session.verified[p].clone()); } completed += PASSES.len() as u32; emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · al aanwezig, checksums kloppen"), }); continue; } let key = if job.character.clips.is_empty() { String::new() } else { format!("render-clip-{}", sample.clip) }; emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · highres"), }); let scene = scene( &job, &key, frame as f32 / job.fps as f32, pitch as f32, yaw as f32, &mut cache, )?; let (tx, rx) = mpsc::sync_channel(1); gpu.send(GpuRequest { scene, reply: tx }) .map_err(|_| "GPU-service gesloten")?; if let Some(ctx) = ctx { ctx.request_repaint(); } let raw = loop { match rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(result) => break result?, Err(mpsc::RecvTimeoutError::Timeout) => { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } } Err(_) => return Err("GPU-service gestopt".into()), } }; completed += 1; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: format!("{pose} · highres"), image: Some(image(raw.width, raw.height, raw.diffuse.clone())), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · detail"), }); let detail = detail_map(&raw)?; completed += 1; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: format!("{pose} · detail"), image: Some(detail_image(&raw, &detail)), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · target-res / diffuse"), }); let target = resolve(&raw, &detail, job.target_width, job.target_height)?; for ((map, preview), rel) in [ ("diffuse", target.diffuse), ("depth", target.depth_preview), ("normal", target.normal), ("specular", target.specular), ] .into_iter() .zip(paths) { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · {map}"), }); debug_assert!(rel.ends_with(&format!("_{map}.png"))); let kept = session.verified.contains_key(rel); let digest = if kept { session.verified[rel].clone() } else { let bytes = if map == "depth" { encode_depth_png(job.target_width, job.target_height, &target.depth)? } else { encode_rgba_png(&preview, map == "diffuse")? }; session.publish(rel, &bytes)? }; png_sha256.insert(rel.clone(), digest); completed += 1; let pass = if map == "diffuse" { "target-res / diffuse" } else { map }; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: if kept { format!("{pose} · {pass} (bestaand bestand behouden)") } else { format!("{pose} · {pass}") }, image: Some(preview), }); } } Ok(()) })(); match result { Ok(()) => { if png_sha256.len() as u32 != plan.files { return Err(format!( "checksumtelling {} wijkt af van geplande {} PNGs", png_sha256.len(), plan.files )); } let identity = crate::export_set::complete_identity_json(&render_doc)?; write_json( &root, "complete.json", serde_json::json!({ "format_version": crate::export_set::OUTPUT_META_VERSION, "status": "complete", "identity": identity, "passes": completed, "png_files": plan.files, "png_sha256": png_sha256, }), )?; emit(RenderEvent::Finished { saved: Some(root.display().to_string()), }); Ok(()) } Err(error) => { let name = if cancel.load(Ordering::Relaxed) { "cancelled.json" } else { "failed.json" }; let _ = write_json( &root, name, serde_json::json!({ "error": error, "completed_passes": completed, "total_passes": plan.passes, "png_files_done": png_sha256.len(), "png_files": plan.files, "attempt": session.attempt, }), ); Err(error) } } } /// Real readback regression: nearest-surface depth/normal/specular, preserved /// texture × vertex albedo/alpha and independence from opposed light directions. /// # Safety /// Run on a current disposable native context. pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> { let triangle = |z, normal, color, specular| Draw { positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]), normals: Arc::new(vec![normal; 3]), uvs: Arc::new(vec![[0.; 2]; 3]), vertex_colors: Arc::new(Vec::new()), indices: vec![0, 1, 2], color, albedo: None, shiny: None, specular, cutoff: 0.001, flat_normals: false, }; let mut renderer = GpuRenderer::default(); let mut scene = Scene { draws: vec![ triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75), triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.), ], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; let result = (|| { let raw = renderer.render(gl, &scene)?; let i = 16 * 32 + 16; if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255] || (raw.depth[i] - 0.25).abs() > 1e-5 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255] || raw.specular[i * 4].abs_diff(191) > 1 { return Err(format!( "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}", &raw.diffuse[i * 4..i * 4 + 4], raw.depth[i], &raw.normal[i * 4..i * 4 + 4], raw.specular[i * 4] )); } scene.draws.reverse(); let reverse = renderer.render(gl, &scene)?; if reverse.diffuse != raw.diffuse || reverse.normal != raw.normal || reverse.depth != raw.depth { return Err("GPU map winner depends on submission order".into()); } scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)]; scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]); scene.draws[0].albedo = Some(Arc::new(equipment::Texture { content_hash: 99, width: 1, height: 1, rgba: vec![128, 255, 64, 192], })); let expected = [ srgb(linear(128) * 0.25), srgb(0.75), srgb(linear(64) * 0.5), 192, ]; // Production intentionally has no lighting input. This optional conventional // uniform is optimized out now; a future accidental lighting term using it // must fail these actual GPU readbacks under opposed light directions. for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] { let p = renderer.program.ok_or("missing probe program")?; gl.use_program(Some(p)); gl.uniform_3_f32( gl.get_uniform_location(p, "u_light_direction").as_ref(), light[0], light[1], light[2], ); let output = renderer.render(gl, &scene)?; let pixel = &output.diffuse[i * 4..i * 4 + 4]; if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) { return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}")); } } let detail = detail_map(&raw)?; let target = resolve(&raw, &detail, 4, 4)?; if target.diffuse.width != 4 || target.depth.len() != 32 || target .normal .rgba .chunks_exact(4) .filter(|p| p[3] > 0) .any(|p| { let n = p[..3] .iter() .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); (n - 1.).abs() > 0.03 }) { return Err("target map dimensions/normals invalid".into()); } let mut flat = Scene { draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; flat.draws[0].flat_normals = true; let derived = renderer.render(gl, &flat)?; if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] { return Err(format!( "flat normals are not geometry-derived: {:?}", &derived.normal[i * 4..i * 4 + 4] )); } println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives"); Ok(()) })(); renderer.destroy(gl); result } #[cfg(test)] mod tests { use super::*; struct Temp(PathBuf); impl Temp { fn new() -> Self { let p = std::env::temp_dir().join(format!( "paperdoll-production-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir(&p).unwrap(); Self(p) } } impl Drop for Temp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn body() -> PreviewCharacter { preview::load_gltf_file( &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap() } fn raw(w: u32, h: u32) -> Buffers { let n = (w * h) as usize; Buffers { width: w, height: h, diffuse: [255, 0, 0, 255].repeat(n), normal: [128, 128, 255, 255].repeat(n), specular: [191, 191, 191, 255].repeat(n), depth: vec![0.25; n], } } #[test] fn job_counts_every_clip_frame_angle_and_exact_order() { let mut b = body(); let mut c = b.clips[0].clone(); c.name = "../A \u{2603}".into(); c.duration = 0.5; b.clips = vec![c.clone(), c]; let p = plan(&b, 3).unwrap(); assert_eq!((p.images, p.passes, p.files), (144, 864, 576)); let s = samples(&p).collect::>(); assert_eq!(s.len(), 144); assert_eq!( s[0], Sample { clip: 0, frame: 0, pitch: -30, yaw: 0 } ); assert_eq!(s[11].yaw, 330); assert_eq!(s[12].pitch, 0); assert_eq!(s[35].pitch, 30); assert_eq!(s[36].frame, 1); assert_eq!(s[72].clip, 1); assert_ne!(p.clips[0].token, p.clips[1].token); assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token))); assert_eq!( PASSES, [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular" ] ); b.clips.clear(); let p = plan(&b, 12).unwrap(); assert_eq!((p.images, p.files), (36, 144)); } #[test] fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() { let mut b = body(); b.clips.truncate(1); b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1); assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2); b.clips[0].duration = 1.00001; assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3); } #[test] fn planner_rejects_invalid_or_unbounded_inputs() { let mut b = body(); assert!(plan(&b, 0).is_err()); assert!(plan(&b, 241).is_err()); for duration in [f32::NAN, f32::INFINITY, -1., 10000.] { b.clips[0].duration = duration; assert!(plan(&b, 12).is_err()); } } #[test] fn output_tokens_only_four_target_maps() { let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png"); assert_eq!( output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(), expected ); for map in ["diffuse", "normal", "depth", "specular"] { assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok()); } for map in ["highres", "detail", "shiny", "../x"] { assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err()); } for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] { assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err()); } assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err()); assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err()); } #[test] fn resolve_preserves_thin_foreground_and_matching_maps() { let mut r = raw(8, 8); r.diffuse = [0, 255, 0, 255].repeat(64); r.depth.fill(0.75); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); r.depth[0] = 0.25; let d = detail_map(&r).unwrap(); assert!(d.iter().any(|x| *x > 0)); let t = resolve(&r, &d, 1, 1).unwrap(); assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]); assert_eq!(t.specular.rgba, [191, 191, 191, 255]); assert!((t.depth[0] as i32 - 16384).abs() <= 1); let n = &t.normal.rgba; let len = (0..3) .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); assert!((len - 1.).abs() < 0.01); r.diffuse.fill(0); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap(); assert_eq!(t.diffuse.rgba[3], 4); assert_eq!(t.depth[1], 1028); assert_eq!(t.normal.rgba[3], 4); assert_eq!(t.specular.rgba[3], 4); } #[test] fn invalid_map_dimensions_and_depth_fail_without_panics() { let mut r = raw(8, 8); r.normal.pop(); assert!(detail_map(&r).is_err()); assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let mut r = raw(8, 8); r.depth[0] = f32::NAN; assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let r = raw(8, 8); assert!(resolve(&r, &[0; 63], 1, 1).is_err()); assert!(resolve(&r, &[0; 64], 0, 1).is_err()); assert!(resolve(&r, &[0; 64], 257, 1).is_err()); } #[test] fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() { let t = Temp::new(); let p = Path::new("map_diffuse.png"); write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap(); let original = std::fs::read(t.0.join(p)).unwrap(); assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err()); assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original); let mut reader = png::Decoder::new(std::io::Cursor::new(original)) .read_info() .unwrap(); assert!(reader.info().srgb.is_some()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [128, 64, 32, 192]); write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap(); let mut reader = png::Decoder::new(std::io::BufReader::new( std::fs::File::open(t.0.join("depth.png")).unwrap(), )) .read_info() .unwrap(); assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen); assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha); assert!(reader.info().srgb.is_none()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [64, 0, 128, 128]); let tmp = t.0.join("blocked.png") .with_extension(format!("part-{}", std::process::id())); std::fs::write(&tmp, b"keep").unwrap(); // A foreign/stale temp (e.g. left by a crashed process with a reused PID) // is never deleted or reused; per-write unique temp names still publish. write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap(); assert_eq!(std::fs::read(tmp).unwrap(), b"keep"); let temps = std::fs::read_dir(&t.0) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-")) .count(); assert_eq!(temps, 1, "own temp files are removed, foreign ones kept"); assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err()); assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err()); assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err()); } #[cfg(unix)] #[test] fn output_rejects_escaping_symlinks() { let root = Temp::new(); let outside = Temp::new(); std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap(); assert!(write_rgba( &root.0, Path::new("link/sub/x.png"), &image(1, 1, vec![0; 4]) ) .is_err()); assert!(!outside.0.join("sub").exists()); } #[test] fn latest_mailbox_is_bounded_and_retains_terminal() { let m = Mailbox::default(); for i in 0..1000 { m.publish(RenderEvent::Pass { index: i, total: 1000, label: i.to_string(), image: Some(image(1, 1, vec![0; 4])), }); } m.publish(RenderEvent::Step { index: 999, total: 1000, label: "working".into(), }); m.publish(RenderEvent::Cancelled); let events = m.drain(); assert_eq!(events.len(), 3); assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. })); assert!(matches!(&events[2], RenderEvent::Cancelled)); assert!(m.drain().is_empty()); } #[test] fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() { let temp = Temp::new(); let mut job = RenderJob { character: body(), sex: "male".into(), preset: None, target_width: 64, target_height: 96, fps: 12, framing: CameraFraming { height_milli: 3100, center_y_milli: 1200, pivot_y_milli: Some(1200), }, output: temp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; let mut cache = equipment::TextureCache::default(); let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); let mut preset = equipment::Preset::new("sword".into()); let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj"); equipment::import_obj(&mut preset, &fixture).unwrap(); let joint = job .character .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); preset.bind(0, &job.character, 0, joint).unwrap(); let count = preset .attachments .iter() .map(|a| a.meshes.len()) .sum::(); job.preset = Some(preset); let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(e.draws.len(), count); assert_eq!(b.view, e.view); assert_eq!(b.projection, e.projection); assert_eq!((e.width, e.height), (512, 768)); assert_eq!(e.projection[5], 2. / 3.1); let center = point(&e.view, [0., 1.2, 0.]); assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001); assert!((center[2] + 10.).abs() < 0.0001); let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap(); let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(early.view, late.view); assert_eq!(early.projection, late.projection); assert_ne!(early.draws[0].positions, late.draws[0].positions); let mut invalid = e.clone(); invalid.view[0] = f32::NAN; assert!(invalid.validate().is_err()); } #[test] fn body_import_retains_texture_material_and_linear_vertex_color() { let temp = Temp::new(); let source = std::fs::read( Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let glb = gltf::binary::Glb::from_slice(&source).unwrap(); let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap(); let mut bin = glb.bin.unwrap().into_owned(); while !bin.len().is_multiple_of(4) { bin.push(0); } let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"] .as_u64() .unwrap() as usize; let count = json["accessors"][position]["count"].as_u64().unwrap() as usize; let offset = bin.len(); for _ in 0..count { for value in [0.25f32, 0.75, 0.5, 0.5] { bin.extend(value.to_le_bytes()); } } let view = json["bufferViews"].as_array().unwrap().len(); json["bufferViews"] .as_array_mut() .unwrap() .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16})); let accessor = json["accessors"].as_array().unwrap().len(); json["accessors"].as_array_mut().unwrap().push( serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}), ); json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor); json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()}); json["images"] = serde_json::json!([{"uri":"albedo.png"}]); json["textures"] = serde_json::json!([{"source":0}]); let material = json["meshes"][0]["primitives"][0]["material"] .as_u64() .unwrap() as usize; json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] = serde_json::json!({"index":0}); json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] = serde_json::json!([0.5, 0.75, 1., 1.]); std::fs::write(temp.0.join("geometry.bin"), bin).unwrap(); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); write_rgba( &temp.0, Path::new("albedo.png"), &image(1, 1, vec![128, 255, 64, 192]), ) .unwrap(); let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]); assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]); assert_eq!( body.surfaces[0].albedo.as_ref().unwrap().rgba, [128, 255, 64, 192] ); assert_eq!(body.colors.len(), body.positions.len()); assert_eq!(body.uvs.len(), body.positions.len()); assert!(body.surfaces[0].flat_normals); json["meshes"][0]["primitives"][0]["attributes"] .as_object_mut() .unwrap() .remove("NORMAL"); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert!(!missing.source_normals_complete); let job = RenderJob { character: missing, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: temp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; assert!( scene( &job, "", 0., 0., 0., &mut equipment::TextureCache::default() ) .unwrap() .draws[0] .flat_normals ); } #[test] fn complete_and_cancelled_batches_have_truthful_atomic_outputs() { for stop in [false, true] { let tmp = Temp::new(); let mut b = body(); b.clips.clear(); let job = RenderJob { character: b, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: tmp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; let flag = AtomicBool::new(false); let (tx, rx) = mpsc::channel::(); let gpu = std::thread::spawn(move || { for request in rx { let _ = request .reply .send(Ok(raw(request.scene.width, request.scene.height))); } }); let mut labels = Vec::new(); let result = execute(job, &tx, &flag, None, &mut |event| { if let RenderEvent::Pass { label, .. } = event { labels.push(label); if stop && labels.len() == 3 { flag.store(true, Ordering::Relaxed); } } }); drop(tx); gpu.join().unwrap(); let run = std::fs::read_dir(&tmp.0) .unwrap() .next() .unwrap() .unwrap() .path(); assert!(run.join("render.json").exists()); assert_eq!(run.join("complete.json").exists(), !stop); assert_eq!(run.join("cancelled.json").exists(), stop); assert_eq!(result.is_ok(), !stop); let meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap()) .unwrap(); assert_eq!(meta["format_version"], 3); assert_eq!(meta["project"]["id"], "test-project"); assert_eq!( meta["body_model"]["sha256"], crate::digest::sha256_hex(b"test-body") ); assert_eq!(meta["framing"]["pivot_y_milli"], 1460); assert_eq!(meta["pivot_source"]["kind"], "neck"); assert_eq!(meta["pivot_source"]["joint_name"], "Neck"); assert_eq!( meta["saved_maps"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect::>(), ["diffuse", "depth", "normal", "specular"] ); if !stop { let complete: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(run.join("complete.json")).unwrap(), ) .unwrap(); assert_eq!(complete["format_version"], 3); assert_eq!(complete["status"], "complete"); assert_eq!(complete["png_files"], 144); let checksums = complete["png_sha256"].as_object().unwrap(); assert_eq!(checksums.len(), 144); for (rel, digest) in checksums { assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap())); let bytes = std::fs::read(run.join(rel)).unwrap(); assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes)); } let opened = crate::export_set::ExportRun::open(&run).unwrap(); let sample = opened .load_map( "clip000-bind", 0, -30, 0, crate::export_set::MapKind::Diffuse, ) .unwrap(); assert_eq!(sample.width(), 1); } let frame = run.join("male/body/clip000-bind/f000000"); assert!(frame.join("p-30_y000_diffuse.png").exists()); assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop); assert!(!frame.join("p-30_y000_highres.png").exists()); assert!(!frame.join("p-30_y000_detail.png").exists()); if !stop { assert_eq!(labels.len(), 216); for labels in labels.chunks_exact(6) { for (label, pass) in labels.iter().zip([ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]) { assert!(label.ends_with(pass)); } } } } } // ---- Resume (design §7/§12.6) ---- fn resume_job(out: &Path) -> RenderJob { let mut b = body(); b.clips.clear(); RenderJob { character: b, sex: "male".into(), preset: None, target_width: 2, target_height: 2, fps: 12, framing: CameraFraming::default(), output: out.to_path_buf(), project_id: "resume-project".into(), project_name: "Resume".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"resume-body"), }, preset_sha256: None, } } /// Deterministic pose-dependent fake GPU output (view matrix drives the colour). fn fake_raw(scene: &Scene) -> Buffers { let mut b = raw(scene.width, scene.height); let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.) as u32 % 200; for px in b.diffuse.chunks_exact_mut(4) { px[1] = v as u8; px[2] = (255 - v) as u8; } b } /// Run a job; cancel after `stop` completed Pass events (None = run to the end). fn drive( job: RenderJob, target: RunTarget, stop: Option, ) -> (Result<(), String>, Vec) { let flag = AtomicBool::new(false); let (tx, rx) = mpsc::channel::(); let gpu = std::thread::spawn(move || { for request in rx { let reply = fake_raw(&request.scene); let _ = request.reply.send(Ok(reply)); } }); let mut events = Vec::new(); let mut passes = 0; let result = execute_target(job, target, &tx, &flag, None, &mut |event| { if matches!(event, RenderEvent::Pass { .. }) { passes += 1; if stop == Some(passes) { flag.store(true, Ordering::Relaxed); } } events.push(event); }); drop(tx); gpu.join().unwrap(); (result, events) } fn runs(out: &Path) -> Vec { let mut v = std::fs::read_dir(out) .unwrap() .map(|e| e.unwrap().path()) .collect::>(); v.sort(); v } fn checksums(run: &Path) -> serde_json::Value { let c: serde_json::Value = serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap(); c["png_sha256"].clone() } fn tree(run: &Path) -> std::collections::BTreeMap { let mut out = std::collections::BTreeMap::new(); let mut stack = vec![run.to_path_buf()]; while let Some(dir) = stack.pop() { for e in std::fs::read_dir(dir).unwrap() { let p = e.unwrap().path(); if p.is_dir() { stack.push(p); } else { out.insert( p.clone(), crate::digest::sha256_hex(&std::fs::read(&p).unwrap()), ); } } } out } #[cfg(unix)] fn inode(p: &Path) -> u64 { use std::os::unix::fs::MetadataExt; std::fs::metadata(p).unwrap().ino() } #[test] #[cfg(unix)] fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() { let reference_dir = Temp::new(); let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None); ok.unwrap(); let reference = checksums(&runs(&reference_dir.0)[0]); assert_eq!(reference.as_object().unwrap().len(), 144); let tmp = Temp::new(); // An earlier completed run in the same output must stay byte-identical. drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); let earlier = runs(&tmp.0)[0].clone(); let earlier_tree = tree(&earlier); std::thread::sleep(std::time::Duration::from_millis(2)); let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40)); assert!(first.is_err()); let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap(); let cancelled: serde_json::Value = serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap(); let done_first = cancelled["png_files_done"].as_u64().unwrap(); assert!(done_first > 0 && done_first < 144); assert_eq!(cancelled["attempt"], 0); let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png"); let first_inode = inode(&first_file); assert!(crate::export_set::ExportRun::open(&run).is_err()); let found = crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc) .unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].state, "geannuleerd"); assert_eq!(found[0].journaled as u64, done_first); assert!(found[0].blocked.is_none()); // Second attempt: cancelled again after more work. let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60)); assert!(second.is_err()); // Progress resumes at the verified work, not at zero, and skipped images are labelled. let first_step = events .iter() .find_map(|e| match e { RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => { Some(*index) } _ => None, }) .unwrap(); assert_eq!(first_step, 6); let cancelled2: serde_json::Value = serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap(); assert_eq!(cancelled2["attempt"], 1); assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first); assert!(run.join("attempt-000-cancelled.json").exists()); assert_eq!( inode(&first_file), first_inode, "verified PNG was rewritten" ); // Third attempt completes. let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None); third.unwrap(); assert!(!run.join("cancelled.json").exists()); assert!(run.join("attempt-001-cancelled.json").exists()); assert_eq!(inode(&first_file), first_inode); assert_eq!(checksums(&run), reference); crate::export_set::ExportRun::open(&run).unwrap(); let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap(); assert_eq!(log.lines().count(), 2); assert_eq!(tree(&earlier), earlier_tree); // A completed run is no longer offered and cannot be resumed. let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty()); let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None); assert!(again.unwrap_err().contains("al voltooid")); } #[test] #[cfg(unix)] fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() { let reference_dir = Temp::new(); drive(resume_job(&reference_dir.0), RunTarget::Fresh, None) .0 .unwrap(); let reference = checksums(&runs(&reference_dir.0)[0]); let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); // Simulate a hard crash: no terminal marker, torn last journal line. std::fs::remove_file(run.join("cancelled.json")).unwrap(); let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap(); let mut lines = journal.lines().map(str::to_string).collect::>(); let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); let torn_path = run.join(torn["path"].as_str().unwrap()); let last = lines.pop().unwrap(); let keep = lines.join("\n") + "\n" + &last[..last.len() / 2]; std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap(); let torn_inode = inode(&torn_path); // Corrupt one journaled PNG, delete another, leave a foreign temp file. let frame = run.join("male/body/clip000-bind/f000000"); let corrupt = frame.join("p-30_y030_normal.png"); std::fs::write(&corrupt, b"not a png").unwrap(); let missing = frame.join("p-30_y060_depth.png"); std::fs::remove_file(&missing).unwrap(); let stale = frame.join("p-30_y090_diffuse.part-999999-0"); std::fs::write(&stale, b"partial").unwrap(); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; let found = crate::resume::candidates(&tmp.0, &doc).unwrap(); assert_eq!(found[0].state, "afgebroken"); drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap(); assert_eq!(checksums(&run), reference); crate::export_set::ExportRun::open(&run).unwrap(); assert!(missing.exists()); assert_eq!( std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(), b"not a png" ); assert_eq!(std::fs::read(&stale).unwrap(), b"partial"); assert_eq!( inode(&torn_path), torn_inode, "identical unjournaled file was replaced" ); let log: serde_json::Value = serde_json::from_str( std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)) .unwrap() .lines() .next() .unwrap(), ) .unwrap(); assert_eq!(log["previous_marker"], serde_json::Value::Null); assert_eq!(log["damaged_journal_lines"], 1); assert_eq!(log["unjournaled_existing"], 1); assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1); assert_eq!(log["quarantined"].as_array().unwrap().len(), 1); } #[test] fn unjournaled_different_file_is_quarantined_not_overwritten() { let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); // A planned PNG appears that the journal never recorded and that differs. let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png"); std::fs::create_dir_all(foreign.parent().unwrap()).unwrap(); std::fs::write(&foreign, b"foreign").unwrap(); drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap(); crate::export_set::ExportRun::open(&run).unwrap(); assert_eq!( std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(), b"foreign" ); } #[test] fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() { let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); let before = std::fs::read(run.join("cancelled.json")).unwrap(); // Contract drift: FPS, body bytes, target size. let mut job = resume_job(&tmp.0); job.fps = 24; let err = drive(job, RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("fps"), "{err}"); let mut job = resume_job(&tmp.0); job.body_model.sha256 = crate::digest::sha256_hex(b"edited body"); let err = drive(job, RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("body_model"), "{err}"); let mut job = resume_job(&tmp.0); job.target_width = 3; let doc = contract(job.clone()).unwrap().render_doc; let found = crate::resume::candidates(&tmp.0, &doc).unwrap(); assert!(found[0].blocked.as_ref().unwrap().contains("target")); assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err()); // Refusals leave the run and its marker untouched. assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before); assert!(!run.join(crate::resume::RESUME_LOG).exists()); // Directory not directly inside the output directory. let other = Temp::new(); let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("uitvoermap"), "{err}"); // Locked by a running worker. let lock = std::fs::OpenOptions::new() .write(true) .open(run.join(crate::resume::LOCK)) .unwrap(); lock.lock().unwrap(); let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("run.lock"), "{err}"); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0] .blocked .is_some()); drop(lock); // Journal naming a path outside the plan. use std::io::Write; let mut j = std::fs::OpenOptions::new() .append(true) .open(run.join(crate::resume::JOURNAL)) .unwrap(); writeln!( j, "{}", serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")}) ) .unwrap(); let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("onbekend pad"), "{err}"); } }