BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

production.rs 98.7 KiB Download Raw
1//! Bounded orthographic production rendering. GPU work runs on the native
2//! current context, one request per UI update; sampling/filtering/PNG I/O run
3//! on the worker. The worker never waits for a preview acknowledgment.
4use crate::{
5 equipment,
6 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 project::CameraFraming,
8 render_job::{ImageBuf, RenderEvent},
9};
10use glow::HasContext;
11use std::{
12 path::{Path, PathBuf},
13 sync::{
14 atomic::{AtomicBool, Ordering},
15 mpsc, Arc, Mutex,
16 },
17 thread,
18};
19
20pub const SCALE: u32 = 8;
21pub const MAX_HIGHRES: u32 = 2048;
22pub const PASSES: [&str; 6] = [
23 "highres",
24 "detail",
25 "target-res / diffuse",
26 "depth",
27 "normal",
28 "specular",
29];
30pub const NEAR: f32 = 0.05;
31pub const FAR: f32 = 40.;
32#[derive(Clone)]
33pub struct Draw {
34 pub positions: Arc<Vec<[f32; 3]>>,
35 pub normals: Arc<Vec<[f32; 3]>>,
36 pub uvs: Arc<Vec<[f32; 2]>>,
37 pub vertex_colors: Arc<Vec<[f32; 4]>>,
38 pub indices: Vec<u32>,
39 pub color: [f32; 4],
40 pub albedo: Option<Arc<equipment::Texture>>,
41 pub shiny: Option<Arc<equipment::Texture>>,
42 pub specular: f32,
43 pub cutoff: f32,
44 pub flat_normals: bool,
45}
46#[derive(Clone)]
47pub struct Scene {
48 pub draws: Vec<Draw>,
49 pub view: Mat4,
50 pub projection: Mat4,
51 pub width: u32,
52 pub height: u32,
53}
54#[derive(Debug)]
55pub struct Buffers {
56 pub width: u32,
57 pub height: u32,
58 pub diffuse: Vec<u8>,
59 pub normal: Vec<u8>,
60 pub specular: Vec<u8>,
61 pub depth: Vec<f32>,
62}
63#[derive(Debug)]
64pub struct Target {
65 pub diffuse: ImageBuf,
66 pub normal: ImageBuf,
67 pub specular: ImageBuf,
68 pub depth: Vec<u16>,
69 pub depth_preview: ImageBuf,
70}
71/// Content fingerprint of a source file used for a production job.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SourceFingerprint {
74 pub path: String,
75 pub sha256: String,
76}
77
78#[derive(Clone)]
79pub struct RenderJob {
80 pub character: PreviewCharacter,
81 pub sex: String,
82 pub preset: Option<equipment::Preset>,
83 pub target_width: u32,
84 pub target_height: u32,
85 pub fps: u32,
86 pub framing: CameraFraming,
87 pub output: PathBuf,
88 pub project_id: String,
89 pub project_name: String,
90 pub body_model: SourceFingerprint,
91 /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
92 pub preset_sha256: Option<String>,
93}
94#[derive(Debug, Clone, PartialEq)]
95pub struct ClipPlan {
96 pub source_name: String,
97 pub token: String,
98 pub frames: u32,
99}
100#[derive(Debug, Clone, PartialEq)]
101pub struct Plan {
102 pub clips: Vec<ClipPlan>,
103 pub images: u32,
104 pub passes: u32,
105 pub files: u32,
106}
107pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
108 if fps == 0 || fps > 240 {
109 return Err("fps moet 1–240 zijn".into());
110 }
111 let clips = if body.clips.is_empty() {
112 vec![ClipPlan {
113 source_name: String::new(),
114 token: "clip000-bind".into(),
115 frames: 1,
116 }]
117 } else {
118 body.clips
119 .iter()
120 .enumerate()
121 .map(|(i, c)| {
122 let duration = c.duration;
123 if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {
124 return Err("ongeldige of te lange animatie".into());
125 }
126 // Exporter float timestamps commonly put a nominal 1-second
127 // endpoint one ULP above 1. Do not duplicate that terminal pose.
128 let samples = f64::from(duration) * f64::from(fps);
129 let nearest = samples.round();
130 let samples = if (samples - nearest).abs()
131 <= 4. * f64::from(f32::EPSILON) * samples.max(1.)
132 {
133 nearest
134 } else {
135 samples
136 };
137 Ok(ClipPlan {
138 source_name: c.name.clone(),
139 token: clip_token(i, &c.name),
140 frames: samples.ceil().max(1.) as u32,
141 })
142 })
143 .collect::<Result<Vec<_>, String>>()?
144 };
145 let frames = clips
146 .iter()
147 .try_fold(0u32, |n, c| n.checked_add(c.frames))
148 .ok_or("te veel frames")?;
149 let images = frames.checked_mul(36).ok_or("te veel beelden")?;
150 let passes = images.checked_mul(6).ok_or("te veel passes")?;
151 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
152 if files > 500000 {
153 return Err("batch groter dan 500000 uitvoerbestanden".into());
154 }
155 Ok(Plan {
156 clips,
157 images,
158 passes,
159 files,
160 })
161}
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct Sample {
164 pub clip: usize,
165 pub frame: u32,
166 pub pitch: i32,
167 pub yaw: u32,
168}
169pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
170 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
171 (0..c.frames).flat_map(move |frame| {
172 [-30, 0, 30].into_iter().flat_map(move |pitch| {
173 (0..12).map(move |y| Sample {
174 clip,
175 frame,
176 pitch,
177 yaw: y * 30,
178 })
179 })
180 })
181 })
182}
183
184pub fn clip_token(index: usize, name: &str) -> String {
185 let slug: String = name
186 .chars()
187 .map(|c| {
188 if c.is_ascii_alphanumeric() {
189 c.to_ascii_lowercase()
190 } else {
191 '-'
192 }
193 })
194 .take(48)
195 .collect();
196 format!(
197 "clip{index:03}-{}",
198 if slug.is_empty() { "animation" } else { &slug }
199 )
200}
201pub fn output_path(
202 sex: &str,
203 set: &str,
204 clip: &str,
205 frame: u32,
206 pitch: i32,
207 yaw: u32,
208 map: &str,
209) -> Result<PathBuf, String> {
210 if !["male", "female"].contains(&sex)
211 || !(set == "body" || equipment::valid_name(set))
212 || !equipment::valid_name(clip)
213 || ![-30, 0, 30].contains(&pitch)
214 || yaw >= 360
215 || !yaw.is_multiple_of(30)
216 || !["diffuse", "normal", "depth", "specular"].contains(&map)
217 {
218 return Err("ongeldig uitvoerpad/token".into());
219 }
220 Ok(PathBuf::from(sex)
221 .join(set)
222 .join(clip)
223 .join(format!("f{frame:06}"))
224 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
225}
226pub use crate::camera::matrices as camera;
227
228fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
229 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
230}
231fn normalize(v: [f32; 3]) -> [f32; 3] {
232 let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();
233 if l > 1e-10 {
234 v.map(|x| x / l)
235 } else {
236 [0., 0., 1.]
237 }
238}
239fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {
240 let a = [m[0], m[1], m[2]];
241 let b = [m[4], m[5], m[6]];
242 let c = [m[8], m[9], m[10]];
243 let cross = |a: [f32; 3], b: [f32; 3]| {
244 [
245 a[1] * b[2] - a[2] * b[1],
246 a[2] * b[0] - a[0] * b[2],
247 a[0] * b[1] - a[1] * b[0],
248 ]
249 };
250 let x = cross(b, c);
251 let y = cross(c, a);
252 let z = cross(a, b);
253 let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();
254 if det.abs() < 1e-12 {
255 return normalize(n);
256 }
257 normalize(std::array::from_fn(|i| {
258 (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det
259 }))
260}
261pub fn scene(
262 job: &RenderJob,
263 clip: &str,
264 time: f32,
265 pitch: f32,
266 yaw: f32,
267 cache: &mut equipment::TextureCache,
268) -> Result<Scene, String> {
269 let body = &job.character;
270 let mut draws = Vec::new();
271 if let Some(preset) = &job.preset {
272 let controls = PreviewControls {
273 clip_name: clip.into(),
274 time,
275 ..Default::default()
276 };
277 for layer in equipment::layers_cached(
278 preset,
279 usize::from(job.sex == "female"),
280 body,
281 &controls,
282 cache,
283 )? {
284 let m = layer.model;
285 draws.push(Draw {
286 positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),
287 normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),
288 uvs: Arc::new(
289 layer
290 .mesh
291 .uvs
292 .into_iter()
293 .map(|[u, v]| [u, 1. - v])
294 .collect(),
295 ),
296 vertex_colors: Arc::new(Vec::new()),
297 indices: layer.mesh.indices,
298 color: layer.mesh.color,
299 albedo: layer.albedo,
300 shiny: layer.shiny,
301 specular: layer.specular,
302 cutoff: 0.5,
303 flat_normals: false,
304 });
305 }
306 } else {
307 let bones = preview::sample_skin_matrices(body, clip, time);
308 let mut positions = Vec::with_capacity(body.positions.len());
309 let mut normals = Vec::with_capacity(body.positions.len());
310 for (i, p) in body.positions.iter().enumerate() {
311 let mut m = preview::identity();
312 if !bones.is_empty() && body.weights.len() == body.positions.len() {
313 m = [0.; 16];
314 let joints = body.joints.get(i).copied().unwrap_or([0; 4]);
315 for (j, w) in joints.into_iter().zip(body.weights[i]) {
316 let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;
317 for k in 0..16 {
318 m[k] += b[k] * w;
319 }
320 }
321 }
322 positions.push(point(&m, *p));
323 normals.push(normal(
324 &m,
325 body.normals.get(i).copied().unwrap_or([0., 1., 0.]),
326 ));
327 }
328 let positions = Arc::new(positions);
329 let normals = Arc::new(normals);
330 let uvs = Arc::new(body.uvs.clone());
331 let colors = Arc::new(body.colors.clone());
332 if body.surfaces.is_empty() {
333 draws.push(Draw {
334 positions,
335 normals,
336 uvs,
337 vertex_colors: colors,
338 indices: body.indices.clone(),
339 color: body.base_color,
340 albedo: None,
341 shiny: None,
342 specular: 0.,
343 cutoff: 0.001,
344 flat_normals: !body.source_normals_complete,
345 });
346 } else {
347 for surface in &body.surfaces {
348 draws.push(Draw {
349 positions: positions.clone(),
350 normals: normals.clone(),
351 uvs: uvs.clone(),
352 vertex_colors: colors.clone(),
353 indices: body.indices[surface.first..surface.first + surface.count].to_vec(),
354 color: surface.color,
355 albedo: surface.albedo.clone(),
356 shiny: None,
357 specular: 0.,
358 cutoff: surface.cutoff,
359 flat_normals: surface.flat_normals,
360 });
361 }
362 }
363 }
364 let (view, projection) = camera(
365 crate::camera::resolve(body, job.framing).framing,
366 yaw,
367 pitch,
368 job.target_width as f32 / job.target_height as f32,
369 );
370 Ok(Scene {
371 draws,
372 view,
373 projection,
374 width: job.target_width * SCALE,
375 height: job.target_height * SCALE,
376 })
377}
378
379impl Scene {
380 pub fn validate(&self) -> Result<(), String> {
381 if self.width == 0
382 || self.height == 0
383 || self.width > MAX_HIGHRES
384 || self.height > MAX_HIGHRES
385 || self.draws.len() > 4096
386 || self
387 .view
388 .iter()
389 .chain(&self.projection)
390 .any(|v| !v.is_finite())
391 {
392 return Err("ongeldige GPU-scène/afmetingen".into());
393 }
394 for d in &self.draws {
395 if d.positions.len() > 1_000_000
396 || d.positions.len() != d.normals.len()
397 || d.indices.len() > 3_000_000
398 || !d.indices.len().is_multiple_of(3)
399 || d.indices.iter().any(|i| *i as usize >= d.positions.len())
400 || d.positions
401 .iter()
402 .chain(d.normals.iter())
403 .flatten()
404 .chain(d.uvs.iter().flatten())
405 .chain(d.vertex_colors.iter().flatten())
406 .chain(d.color.iter())
407 .any(|v| !v.is_finite())
408 || !d.specular.is_finite()
409 || !d.cutoff.is_finite()
410 {
411 return Err("ongeldige of te grote GPU-geometrie".into());
412 }
413 for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {
414 if t.width == 0
415 || t.height == 0
416 || t.width > 2048
417 || t.height > 2048
418 || t.rgba.len() != t.width as usize * t.height as usize * 4
419 {
420 return Err("ongeldige GPU-texture".into());
421 }
422 }
423 }
424 Ok(())
425 }
426}
427
428#[derive(Default)]
429pub struct GpuRenderer {
430 program: Option<glow::Program>,
431 vao: Option<glow::VertexArray>,
432 vbo: Option<glow::Buffer>,
433 ebo: Option<glow::Buffer>,
434 fbo: Option<glow::Framebuffer>,
435 colors: Vec<glow::Texture>,
436 depth: Option<glow::Renderbuffer>,
437 extent: [u32; 2],
438}
439impl GpuRenderer {
440 /// # Safety
441 /// A native OpenGL 3.3 context must be current on this thread.
442 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
443 self.destroy_target(gl);
444 if let Some(x) = self.program.take() {
445 gl.delete_program(x);
446 }
447 if let Some(x) = self.vao.take() {
448 gl.delete_vertex_array(x);
449 }
450 if let Some(x) = self.vbo.take() {
451 gl.delete_buffer(x);
452 }
453 if let Some(x) = self.ebo.take() {
454 gl.delete_buffer(x);
455 }
456 }
457 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
458 for x in self.colors.drain(..) {
459 gl.delete_texture(x);
460 }
461 if let Some(x) = self.depth.take() {
462 gl.delete_renderbuffer(x);
463 }
464 if let Some(x) = self.fbo.take() {
465 gl.delete_framebuffer(x);
466 }
467 self.extent = [0, 0];
468 }
469 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
470 if self.program.is_some() {
471 return Ok(());
472 }
473 let p = gl.create_program()?;
474 let mut shaders = Vec::new();
475 for (kind, source) in [
476 (glow::VERTEX_SHADER, VERTEX),
477 (glow::FRAGMENT_SHADER, FRAGMENT),
478 ] {
479 let shader = gl.create_shader(kind)?;
480 gl.shader_source(shader, source);
481 gl.compile_shader(shader);
482 if !gl.get_shader_compile_status(shader) {
483 let error = gl.get_shader_info_log(shader);
484 gl.delete_shader(shader);
485 for s in shaders {
486 gl.delete_shader(s);
487 }
488 gl.delete_program(p);
489 return Err(error);
490 }
491 gl.attach_shader(p, shader);
492 shaders.push(shader);
493 }
494 gl.link_program(p);
495 let result = if gl.get_program_link_status(p) {
496 Ok(())
497 } else {
498 Err(gl.get_program_info_log(p))
499 };
500 for s in shaders {
501 gl.detach_shader(p, s);
502 gl.delete_shader(s);
503 }
504 if let Err(e) = result {
505 gl.delete_program(p);
506 return Err(e);
507 }
508 self.program = Some(p);
509 self.vao = Some(gl.create_vertex_array()?);
510 self.vbo = Some(gl.create_buffer()?);
511 self.ebo = Some(gl.create_buffer()?);
512 Ok(())
513 }
514 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
515 if self.extent == [w, h] {
516 return Ok(());
517 }
518 self.destroy_target(gl);
519 self.fbo = Some(gl.create_framebuffer()?);
520 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
521 for slot in 0..3 {
522 let t = gl.create_texture()?;
523 self.colors.push(t);
524 gl.bind_texture(glow::TEXTURE_2D, Some(t));
525 gl.tex_parameter_i32(
526 glow::TEXTURE_2D,
527 glow::TEXTURE_MIN_FILTER,
528 glow::NEAREST as i32,
529 );
530 gl.tex_parameter_i32(
531 glow::TEXTURE_2D,
532 glow::TEXTURE_MAG_FILTER,
533 glow::NEAREST as i32,
534 );
535 gl.tex_image_2d(
536 glow::TEXTURE_2D,
537 0,
538 glow::RGBA8 as i32,
539 w as i32,
540 h as i32,
541 0,
542 glow::RGBA,
543 glow::UNSIGNED_BYTE,
544 None,
545 );
546 gl.framebuffer_texture_2d(
547 glow::FRAMEBUFFER,
548 glow::COLOR_ATTACHMENT0 + slot,
549 glow::TEXTURE_2D,
550 Some(t),
551 0,
552 );
553 }
554 let d = gl.create_renderbuffer()?;
555 self.depth = Some(d);
556 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));
557 gl.renderbuffer_storage(
558 glow::RENDERBUFFER,
559 glow::DEPTH_COMPONENT24,
560 w as i32,
561 h as i32,
562 );
563 gl.framebuffer_renderbuffer(
564 glow::FRAMEBUFFER,
565 glow::DEPTH_ATTACHMENT,
566 glow::RENDERBUFFER,
567 Some(d),
568 );
569 gl.draw_buffers(&[
570 glow::COLOR_ATTACHMENT0,
571 glow::COLOR_ATTACHMENT1,
572 glow::COLOR_ATTACHMENT2,
573 ]);
574 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
575 return Err("productie-FBO onvolledig".into());
576 }
577 self.extent = [w, h];
578 Ok(())
579 }
580 /// Render and read back unlit color, view-space normals, scalar specular and
581 /// the actual depth attachment. All four use the same winning surface.
582 /// # Safety
583 /// `gl` is current on the UI thread. GL state is reset for egui painting.
584 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
585 if scene.width == 0
586 || scene.height == 0
587 || scene.width > MAX_HIGHRES
588 || scene.height > MAX_HIGHRES
589 {
590 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
591 }
592 scene.validate()?;
593 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
594 let mut vp = [0; 4];
595 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
596 let result = (|| {
597 self.init(gl)?;
598 self.target(gl, scene.width, scene.height)?;
599 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
600 gl.draw_buffers(&[
601 glow::COLOR_ATTACHMENT0,
602 glow::COLOR_ATTACHMENT1,
603 glow::COLOR_ATTACHMENT2,
604 ]);
605 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
606 gl.disable(glow::SCISSOR_TEST);
607 gl.disable(glow::CULL_FACE);
608 gl.disable(glow::BLEND);
609 gl.enable(glow::DEPTH_TEST);
610 gl.depth_mask(true);
611 gl.depth_func(glow::LESS);
612 gl.clear_depth_f32(1.);
613 gl.clear_color(0., 0., 0., 0.);
614 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
615 let p = self.program.ok_or("shader ontbreekt")?;
616 gl.use_program(Some(p));
617 gl.uniform_matrix_4_f32_slice(
618 gl.get_uniform_location(p, "u_view").as_ref(),
619 false,
620 &scene.view,
621 );
622 gl.uniform_matrix_4_f32_slice(
623 gl.get_uniform_location(p, "u_projection").as_ref(),
624 false,
625 &scene.projection,
626 );
627 gl.bind_vertex_array(self.vao);
628 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
629 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
630 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
631 gl.enable_vertex_attrib_array(slot);
632 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
633 }
634 for draw in &scene.draws {
635 if draw.positions.len() != draw.normals.len()
636 || draw
637 .indices
638 .iter()
639 .any(|i| *i as usize >= draw.positions.len())
640 || draw.indices.len() > i32::MAX as usize
641 {
642 return Err("ongeldige GPU-geometrie".into());
643 }
644 let mut data = Vec::with_capacity(draw.positions.len() * 12);
645 for (i, pos) in draw.positions.iter().enumerate() {
646 data.extend_from_slice(pos);
647 data.extend_from_slice(&draw.normals[i]);
648 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
649 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
650 }
651 gl.buffer_data_u8_slice(
652 glow::ARRAY_BUFFER,
653 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
654 glow::STREAM_DRAW,
655 );
656 gl.buffer_data_u8_slice(
657 glow::ELEMENT_ARRAY_BUFFER,
658 std::slice::from_raw_parts(
659 draw.indices.as_ptr().cast(),
660 draw.indices.len() * 4,
661 ),
662 glow::STREAM_DRAW,
663 );
664 gl.uniform_4_f32(
665 gl.get_uniform_location(p, "u_color").as_ref(),
666 draw.color[0],
667 draw.color[1],
668 draw.color[2],
669 draw.color[3],
670 );
671 gl.uniform_1_f32(
672 gl.get_uniform_location(p, "u_specular").as_ref(),
673 draw.specular,
674 );
675 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
676 gl.uniform_1_i32(
677 gl.get_uniform_location(p, "u_flat").as_ref(),
678 i32::from(draw.flat_normals),
679 );
680 let mut temp = Vec::new();
681 for (unit, name, texture) in [
682 (0, "u_albedo", draw.albedo.as_ref()),
683 (1, "u_shiny", draw.shiny.as_ref()),
684 ] {
685 gl.active_texture(glow::TEXTURE0 + unit);
686 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
687 gl.uniform_1_i32(
688 gl.get_uniform_location(p, &format!("{name}_enabled"))
689 .as_ref(),
690 i32::from(texture.is_some()),
691 );
692 if let Some(image) = texture {
693 let t = gl.create_texture()?;
694 temp.push(t);
695 gl.bind_texture(glow::TEXTURE_2D, Some(t));
696 gl.tex_parameter_i32(
697 glow::TEXTURE_2D,
698 glow::TEXTURE_MIN_FILTER,
699 glow::NEAREST as i32,
700 );
701 gl.tex_parameter_i32(
702 glow::TEXTURE_2D,
703 glow::TEXTURE_MAG_FILTER,
704 glow::NEAREST as i32,
705 );
706 gl.tex_parameter_i32(
707 glow::TEXTURE_2D,
708 glow::TEXTURE_WRAP_S,
709 glow::REPEAT as i32,
710 );
711 gl.tex_parameter_i32(
712 glow::TEXTURE_2D,
713 glow::TEXTURE_WRAP_T,
714 glow::REPEAT as i32,
715 );
716 gl.tex_image_2d(
717 glow::TEXTURE_2D,
718 0,
719 if unit == 0 {
720 glow::SRGB8_ALPHA8 as i32
721 } else {
722 glow::RGBA8 as i32
723 },
724 image.width as i32,
725 image.height as i32,
726 0,
727 glow::RGBA,
728 glow::UNSIGNED_BYTE,
729 Some(&image.rgba),
730 );
731 } else {
732 gl.bind_texture(glow::TEXTURE_2D, None);
733 }
734 }
735 gl.draw_elements(
736 glow::TRIANGLES,
737 draw.indices.len() as i32,
738 glow::UNSIGNED_INT,
739 0,
740 );
741 for t in temp {
742 gl.delete_texture(t);
743 }
744 }
745 let pixels = scene.width as usize * scene.height as usize;
746 let mut maps = Vec::new();
747 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
748 for slot in 0..3 {
749 let mut data = vec![0; pixels * 4];
750 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
751 gl.read_pixels(
752 0,
753 0,
754 scene.width as i32,
755 scene.height as i32,
756 glow::RGBA,
757 glow::UNSIGNED_BYTE,
758 glow::PixelPackData::Slice(&mut data),
759 );
760 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
761 maps.push(data);
762 }
763 let mut depth = vec![1f32; pixels];
764 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
765 gl.read_pixels(
766 0,
767 0,
768 scene.width as i32,
769 scene.height as i32,
770 glow::DEPTH_COMPONENT,
771 glow::FLOAT,
772 glow::PixelPackData::Slice(bytes),
773 );
774 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
775 let error = gl.get_error();
776 if error != glow::NO_ERROR {
777 return Err(format!("productie-GL fout 0x{error:x}"));
778 }
779 let specular = maps.pop().unwrap();
780 let normal = maps.pop().unwrap();
781 let diffuse = maps.pop().unwrap();
782 Ok(Buffers {
783 width: scene.width,
784 height: scene.height,
785 diffuse,
786 normal,
787 specular,
788 depth,
789 })
790 })();
791 gl.bind_framebuffer(
792 glow::FRAMEBUFFER,
793 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
794 );
795 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
796 gl.bind_vertex_array(None);
797 gl.use_program(None);
798 gl.active_texture(glow::TEXTURE0);
799 gl.disable(glow::DEPTH_TEST);
800 gl.disable(glow::SCISSOR_TEST);
801 gl.enable(glow::BLEND);
802 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
803 result
804 }
805}
806fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {
807 for y in 0..height / 2 {
808 let split = (height - 1 - y) * stride;
809 let (a, b) = data.split_at_mut(split);
810 a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);
811 }
812}
813const VERTEX: &str = r#"#version 330
814layout(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;
815uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
816void 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;}
817"#;
818const FRAGMENT: &str = r#"#version 330
819in 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
820uniform float u_specular;
821uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
822layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
823vec3 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));}
824void 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<u_cutoff)discard;float s=u_specular;if(u_shiny_enabled)s*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));diffuse=vec4(encode_srgb(clamp(c.rgb,0.0,1.0)),c.a);normal=vec4(n*0.5+0.5,c.a);specular=vec4(vec3(clamp(s,0.0,1.0)),c.a);}
825"#;
826
827fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
828 ImageBuf {
829 width: w,
830 height: h,
831 rgba,
832 }
833}
834fn linear(x: u8) -> f32 {
835 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
836 LUT.get_or_init(|| {
837 std::array::from_fn(|i| {
838 let v = i as f32 / 255.;
839 if v <= 0.04045 {
840 v / 12.92
841 } else {
842 ((v + 0.055) / 1.055).powf(2.4)
843 }
844 })
845 })[x as usize]
846}
847
848fn srgb(x: f32) -> u8 {
849 let v = if x <= 0.0031308 {
850 x * 12.92
851 } else {
852 1.055 * x.powf(1. / 2.4) - 0.055
853 };
854 (v * 255.).round().clamp(0., 255.) as u8
855}
856impl Buffers {
857 pub fn validate(&self) -> Result<(), String> {
858 let pixels = (self.width as usize)
859 .checked_mul(self.height as usize)
860 .ok_or("te veel pixels")?;
861 if self.width == 0
862 || self.height == 0
863 || self.width > MAX_HIGHRES
864 || self.height > MAX_HIGHRES
865 || self.diffuse.len() != pixels * 4
866 || self.normal.len() != pixels * 4
867 || self.specular.len() != pixels * 4
868 || self.depth.len() != pixels
869 || self
870 .depth
871 .iter()
872 .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))
873 {
874 return Err("ongeldige GPU-mapafmetingen/data".into());
875 }
876 Ok(())
877 }
878}
879pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {
880 raw.validate()?;
881 let w = raw.width as usize;
882 let h = raw.height as usize;
883 let mut out = vec![0; w * h];
884 for y in 0..h {
885 for x in 0..w {
886 let i = y * w + x;
887 let mut edge = 0f32;
888 for (nx, ny) in [
889 (x.saturating_sub(1), y),
890 ((x + 1).min(w - 1), y),
891 (x, y.saturating_sub(1)),
892 (x, (y + 1).min(h - 1)),
893 ] {
894 let j = ny * w + nx;
895 let a = raw.diffuse[i * 4 + 3] as f32 / 255.;
896 let b = raw.diffuse[j * 4 + 3] as f32 / 255.;
897 edge = edge.max((a - b).abs());
898 if a > 0. && b > 0. {
899 for c in 0..3 {
900 edge = edge.max(
901 (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),
902 );
903 edge = edge.max(
904 (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()
905 / 255.,
906 );
907 }
908 edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));
909 }
910 }
911 out[i] = (edge * 255.).round().clamp(0., 255.) as u8;
912 }
913 }
914 Ok(out)
915}
916pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {
917 let mut rgba = Vec::with_capacity(detail.len() * 4);
918 for (i, v) in detail.iter().enumerate() {
919 rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);
920 }
921 image(raw.width, raw.height, rgba)
922}
923pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {
924 raw.validate()?;
925 if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {
926 return Err("ongeldige target-afmetingen".into());
927 }
928 if raw.width != w * SCALE
929 || raw.height != h * SCALE
930 || detail.len() != raw.width as usize * raw.height as usize
931 {
932 return Err("ongeldige 8× resolve-afmetingen".into());
933 }
934 let count = w as usize * h as usize;
935 let mut diffuse = vec![0; count * 4];
936 let mut normals = diffuse.clone();
937 let mut specular = diffuse.clone();
938 let mut depth = vec![0u16; count * 2];
939 let mut depth_preview = diffuse.clone();
940 for y in 0..h {
941 for x in 0..w {
942 let i = (y * w + x) as usize;
943 let mut closest = 1f32;
944 let mut coverage = 0f32;
945 for dy in 0..SCALE {
946 for dx in 0..SCALE {
947 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
948 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
949 coverage += a;
950 if a > 0. && raw.depth[j].is_finite() {
951 closest = closest.min(raw.depth[j]);
952 }
953 }
954 }
955 let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)
956 .round()
957 .clamp(0., 255.) as u8;
958 if alpha == 0 {
959 continue;
960 }
961 let mut color = [0.; 3];
962 let mut n = [0.; 3];
963 let mut shiny = 0.;
964 let mut sum = 0.;
965 // One coherent front-surface cluster; never average foreground with a rear
966 // surface. Edges get extra weight, and any thin covered feature survives.
967 for dy in 0..SCALE {
968 for dx in 0..SCALE {
969 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
970 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
971 if a == 0. || raw.depth[j] > closest + 0.0025 {
972 continue;
973 }
974 let weight = a * (1. + 4. * detail[j] as f32 / 255.);
975 sum += weight;
976 for c in 0..3 {
977 color[c] += linear(raw.diffuse[j * 4 + c]) * weight;
978 n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;
979 }
980 shiny += raw.specular[j * 4] as f32 * weight;
981 }
982 }
983 if sum <= 0. {
984 return Err("ongeldige depth/coverage in GPU-resultaat".into());
985 }
986 n = normalize(n);
987 let d = (closest.clamp(0., 1.) * 65535.).round() as u16;
988 for c in 0..3 {
989 diffuse[i * 4 + c] = srgb(color[c] / sum);
990 normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;
991 specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;
992 depth_preview[i * 4 + c] = (d >> 8) as u8;
993 }
994 for map in [
995 &mut diffuse,
996 &mut normals,
997 &mut specular,
998 &mut depth_preview,
999 ] {
1000 map[i * 4 + 3] = alpha;
1001 }
1002 depth[i * 2] = d;
1003 depth[i * 2 + 1] = u16::from(alpha) * 257;
1004 }
1005 }
1006 Ok(Target {
1007 diffuse: image(w, h, diffuse),
1008 normal: image(w, h, normals),
1009 specular: image(w, h, specular),
1010 depth,
1011 depth_preview: image(w, h, depth_preview),
1012 })
1013}
1014
1015fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1016 let relative = relative
1017 .to_str()
1018 .ok_or("ongeldig uitvoerpad")?
1019 .replace('\\', "/");
1020 let relative = crate::project::portable_relative(&relative)?;
1021 let relative = relative.as_str();
1022 let canonical = root.canonicalize().map_err(|e| e.to_string())?;
1023 let path = root.join(relative);
1024 let parent = path.parent().ok_or("uitvoermap ontbreekt")?;
1025 let mut prefix = root.to_path_buf();
1026 for part in Path::new(relative)
1027 .parent()
1028 .unwrap_or(Path::new(""))
1029 .components()
1030 {
1031 prefix.push(part);
1032 if !prefix.exists() {
1033 std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;
1034 }
1035 let real = prefix.canonicalize().map_err(|e| e.to_string())?;
1036 if !real.starts_with(&canonical) {
1037 return Err("uitvoer verlaat projectmap".into());
1038 }
1039 }
1040 if !parent
1041 .canonicalize()
1042 .map_err(|e| e.to_string())?
1043 .starts_with(canonical)
1044 {
1045 return Err("onveilig uitvoerpad".into());
1046 }
1047 Ok(path)
1048}
1049fn atomic_file(
1050 root: &Path,
1051 relative: &Path,
1052 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1053) -> Result<(), String> {
1054 let path = safe_parent(root, relative)?;
1055 static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1056 let temp = path.with_extension(format!(
1057 "part-{}-{}",
1058 std::process::id(),
1059 TEMP_SEQ.fetch_add(1, Ordering::Relaxed)
1060 ));
1061 let mut owned = false;
1062 let result = (|| {
1063 let mut file = std::fs::OpenOptions::new()
1064 .write(true)
1065 .create_new(true)
1066 .open(&temp)
1067 .map_err(|e| e.to_string())?;
1068 owned = true;
1069 write(&mut file)?;
1070 file.sync_all().map_err(|e| e.to_string())?;
1071 std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
1072 Ok(())
1073 })();
1074 if owned {
1075 let _ = std::fs::remove_file(temp);
1076 }
1077 result
1078}
1079fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1080 if img.width == 0
1081 || img.height == 0
1082 || img.width > MAX_HIGHRES / SCALE
1083 || img.height > MAX_HIGHRES / SCALE
1084 || img.rgba.len() != img.width as usize * img.height as usize * 4
1085 {
1086 return Err("ongeldige PNG-afmetingen".into());
1087 }
1088 let mut cursor = std::io::Cursor::new(Vec::new());
1089 {
1090 let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);
1091 encoder.set_color(png::ColorType::Rgba);
1092 encoder.set_depth(png::BitDepth::Eight);
1093 if diffuse_srgb {
1094 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1095 }
1096 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1097 writer
1098 .write_image_data(&img.rgba)
1099 .map_err(|e| e.to_string())?;
1100 writer.finish().map_err(|e| e.to_string())?;
1101 }
1102 Ok(cursor.into_inner())
1103}
1104
1105fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {
1106 if w == 0
1107 || h == 0
1108 || w > MAX_HIGHRES / SCALE
1109 || h > MAX_HIGHRES / SCALE
1110 || depth.len() != w as usize * h as usize * 2
1111 {
1112 return Err("ongeldige 16-bit depthafmetingen".into());
1113 }
1114 let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1115 let mut cursor = std::io::Cursor::new(Vec::new());
1116 {
1117 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1118 encoder.set_color(png::ColorType::GrayscaleAlpha);
1119 encoder.set_depth(png::BitDepth::Sixteen);
1120 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1121 writer
1122 .write_image_data(&samples)
1123 .map_err(|e| e.to_string())?;
1124 writer.finish().map_err(|e| e.to_string())?;
1125 }
1126 Ok(cursor.into_inner())
1127}
1128
1129/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.
1130pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {
1131 let diffuse_srgb = relative
1132 .file_name()
1133 .and_then(|p| p.to_str())
1134 .map(|p| p.ends_with("_diffuse.png"))
1135 .unwrap_or(false);
1136 let bytes = encode_rgba_png(img, diffuse_srgb)?;
1137 let digest = crate::digest::sha256_hex(&bytes);
1138 atomic_file(root, relative, |file| {
1139 use std::io::Write;
1140 file.write_all(&bytes).map_err(|e| e.to_string())
1141 })?;
1142 Ok(digest)
1143}
1144
1145/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1146pub fn write_depth(
1147 root: &Path,
1148 relative: &Path,
1149 w: u32,
1150 h: u32,
1151 depth: &[u16],
1152) -> Result<String, String> {
1153 let bytes = encode_depth_png(w, h, depth)?;
1154 let digest = crate::digest::sha256_hex(&bytes);
1155 atomic_file(root, relative, |file| {
1156 use std::io::Write;
1157 file.write_all(&bytes).map_err(|e| e.to_string())
1158 })?;
1159 Ok(digest)
1160}
1161
1162/// Atomic no-clobber publication of already encoded bytes under the run root.
1163pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1164 atomic_file(root, relative, |file| {
1165 use std::io::Write;
1166 file.write_all(bytes).map_err(|e| e.to_string())
1167 })
1168}
1169
1170fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1171 use std::io::Write;
1172 let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
1173 atomic_file(root, Path::new(name), |f| {
1174 f.write_all(&data).map_err(|e| e.to_string())
1175 })
1176}
1177
1178pub struct GpuRequest {
1179 pub scene: Scene,
1180 reply: mpsc::SyncSender<Result<Buffers, String>>,
1181}
1182impl GpuRequest {
1183 pub fn fail(self, error: String) {
1184 let _ = self.reply.send(Err(error));
1185 }
1186 /// # Safety
1187 /// `gl` must be current on this UI thread.
1188 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1189 let result = renderer.render(gl, &self.scene);
1190 let _ = self.reply.send(result);
1191 }
1192}
1193#[derive(Default)]
1194struct Pending {
1195 pass: Option<RenderEvent>,
1196 progress: Option<RenderEvent>,
1197 terminal: Option<RenderEvent>,
1198}
1199#[derive(Clone, Default)]
1200pub struct Mailbox(Arc<Mutex<Pending>>);
1201impl Mailbox {
1202 pub fn publish(&self, event: RenderEvent) {
1203 if let Ok(mut state) = self.0.lock() {
1204 match event {
1205 RenderEvent::Pass { .. } => {
1206 state.pass = Some(event);
1207 state.progress = None;
1208 }
1209 RenderEvent::Step { .. } => state.progress = Some(event),
1210 _ => state.terminal = Some(event),
1211 }
1212 }
1213 }
1214 pub fn drain(&self) -> Vec<RenderEvent> {
1215 let mut events = Vec::new();
1216 if let Ok(mut s) = self.0.lock() {
1217 events.extend(s.pass.take());
1218 events.extend(s.progress.take());
1219 events.extend(s.terminal.take());
1220 }
1221 events
1222 }
1223}
1224pub struct RenderThread {
1225 pub mailbox: Mailbox,
1226 pub gpu: mpsc::Receiver<GpuRequest>,
1227 pub cancel: Arc<AtomicBool>,
1228 pub handle: Option<thread::JoinHandle<()>>,
1229}
1230impl Drop for RenderThread {
1231 fn drop(&mut self) {
1232 self.cancel.store(true, Ordering::Relaxed);
1233 }
1234}
1235/// Where a production batch writes: a fresh run directory, or an existing
1236/// incomplete run that is recovered after contract and checksum verification.
1237#[derive(Debug, Clone, PartialEq, Eq)]
1238pub enum RunTarget {
1239 Fresh,
1240 Resume(PathBuf),
1241}
1242
1243pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1244 spawn_target(job, RunTarget::Fresh, ctx)
1245}
1246
1247/// Resume a selected incomplete run (see `resume`).
1248pub fn spawn_resume(
1249 job: RenderJob,
1250 run_dir: PathBuf,
1251 ctx: Option<eframe::egui::Context>,
1252) -> RenderThread {
1253 spawn_target(job, RunTarget::Resume(run_dir), ctx)
1254}
1255
1256fn spawn_target(
1257 job: RenderJob,
1258 target: RunTarget,
1259 ctx: Option<eframe::egui::Context>,
1260) -> RenderThread {
1261 let mailbox = Mailbox::default();
1262 let output = mailbox.clone();
1263 let cancel = Arc::new(AtomicBool::new(false));
1264 let flag = cancel.clone();
1265 let (tx, gpu) = mpsc::channel();
1266 let handle = thread::spawn(move || {
1267 let mut emit = |event| {
1268 output.publish(event);
1269 if let Some(ctx) = &ctx {
1270 ctx.request_repaint();
1271 }
1272 };
1273 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
1274 if let Err(error) = result {
1275 if flag.load(Ordering::Relaxed) {
1276 emit(RenderEvent::Cancelled);
1277 } else {
1278 emit(RenderEvent::Failed(error));
1279 }
1280 }
1281 });
1282 RenderThread {
1283 mailbox,
1284 gpu,
1285 cancel,
1286 handle: Some(handle),
1287 }
1288}
1289
1290/// The immutable export contract of a job: validated/resolved job, plan,
1291/// set token and the exact `render.json` document.
1292pub struct Contract {
1293 pub job: RenderJob,
1294 pub plan: Plan,
1295 pub set: String,
1296 pub render_doc: serde_json::Value,
1297}
1298
1299/// Validate a job and build its `render.json` contract without touching disk.
1300pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1301 job.framing.validate()?;
1302 let pivot = crate::camera::resolve(&job.character, job.framing);
1303 job.framing = pivot.framing;
1304 if job.target_width == 0
1305 || job.target_height == 0
1306 || job.target_width > MAX_HIGHRES / SCALE
1307 || job.target_height > MAX_HIGHRES / SCALE
1308 {
1309 return Err("8× highres vereist targetafmetingen 1–256".into());
1310 }
1311 if !["male", "female"].contains(&job.sex.as_str()) {
1312 return Err("ongeldig geslacht".into());
1313 }
1314 if let Some(p) = &job.preset {
1315 if job.character.is_static() {
1316 return Err(
1317 "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(),
1318 );
1319 }
1320 p.validate()?;
1321 if !p.applies[usize::from(job.sex == "female")] {
1322 return Err("equipment niet geldig voor dit geslacht".into());
1323 }
1324 if p.attachments.is_empty() {
1325 return Err("equipment heeft geen onderdelen".into());
1326 }
1327 }
1328 let plan = plan(&job.character, job.fps)?;
1329 let output_bytes =
1330 u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;
1331 if output_bytes > 16 * 1024 * 1024 * 1024 {
1332 return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1333 }
1334 let set = job
1335 .preset
1336 .as_ref()
1337 .map(|p| p.name.as_str())
1338 .unwrap_or("body")
1339 .to_string();
1340 if job.project_id.is_empty() || job.project_id.len() > 128 {
1341 return Err("project.id ongeldig voor export-identiteit".into());
1342 }
1343 if job.project_name.len() > 256 {
1344 return Err("project.name te lang voor export-identiteit".into());
1345 }
1346 if !crate::digest::is_sha256_hex(&job.body_model.sha256) {
1347 return Err("body_model.sha256 ongeldig".into());
1348 }
1349 crate::project::portable_relative(&job.body_model.path)?;
1350 match (&job.preset, &job.preset_sha256) {
1351 (None, None) => {}
1352 (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}
1353 _ => return Err("equipment fingerprint past niet bij de job".into()),
1354 }
1355 let equipment_preset = job.preset.as_ref().map(|p| {
1356 serde_json::json!({
1357 "name": p.name,
1358 "sha256": job.preset_sha256.as_ref().expect("checked"),
1359 })
1360 });
1361 let clips_json = plan
1362 .clips
1363 .iter()
1364 .enumerate()
1365 .map(|(i, c)| {
1366 serde_json::json!({
1367 "name": c.source_name,
1368 "token": c.token,
1369 "frames": c.frames,
1370 "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),
1371 })
1372 })
1373 .collect::<Vec<_>>();
1374 let render_doc = serde_json::json!({
1375 "format_version": crate::export_set::OUTPUT_META_VERSION,
1376 "status": "incomplete",
1377 "project": {"id": job.project_id, "name": job.project_name},
1378 "sex": job.sex,
1379 "set": set,
1380 "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},
1381 "equipment_preset": equipment_preset,
1382 "target": [job.target_width, job.target_height],
1383 "fps": job.fps,
1384 "highres_scale": SCALE,
1385 "framing": job.framing,
1386 "pivot_source": pivot.source,
1387 "near": NEAR,
1388 "far": FAR,
1389 "normal_space": "view (+Y up), PNG rows top-down",
1390 "camera_distance": 10,
1391 "yaw_zero": "camera on -Z",
1392 "pitches": [-30, 0, 30],
1393 "yaw_step": 30,
1394 "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",
1395 "alpha": "straight, opaque/cutout, coverage resolve",
1396 "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1397 "depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
1398 "specular": "linear scalar; body defaults zero",
1399 "pass_order": PASSES,
1400 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1401 "clips": clips_json,
1402 "images": plan.images,
1403 "passes": plan.passes,
1404 "png_files": plan.files,
1405 });
1406 Ok(Contract {
1407 job,
1408 plan,
1409 set,
1410 render_doc,
1411 })
1412}
1413
1414/// Saved map tokens in per-image publication order.
1415pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];
1416
1417/// Every planned target PNG (relative, `/`-separated) in render order, four per image.
1418pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, String> {
1419 let mut out = Vec::with_capacity(plan.files as usize);
1420 for s in samples(plan) {
1421 for map in SAVED_ORDER {
1422 let path = output_path(
1423 sex,
1424 set,
1425 &plan.clips[s.clip].token,
1426 s.frame,
1427 s.pitch,
1428 s.yaw,
1429 map,
1430 )?;
1431 out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"));
1432 }
1433 }
1434 Ok(out)
1435}
1436
1437#[cfg(test)]
1438fn execute(
1439 job: RenderJob,
1440 gpu: &mpsc::Sender<GpuRequest>,
1441 cancel: &AtomicBool,
1442 ctx: Option<&eframe::egui::Context>,
1443 emit: &mut dyn FnMut(RenderEvent),
1444) -> Result<(), String> {
1445 execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit)
1446}
1447
1448fn execute_target(
1449 job: RenderJob,
1450 target: RunTarget,
1451 gpu: &mpsc::Sender<GpuRequest>,
1452 cancel: &AtomicBool,
1453 ctx: Option<&eframe::egui::Context>,
1454 emit: &mut dyn FnMut(RenderEvent),
1455) -> Result<(), String> {
1456 let Contract {
1457 mut job,
1458 plan,
1459 set,
1460 render_doc,
1461 } = contract(job)?;
1462 let planned = planned_paths(&plan, &job.sex, &set)?;
1463 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1464 let mut session = match target {
1465 RunTarget::Fresh => {
1466 let stamp = std::time::SystemTime::now()
1467 .duration_since(std::time::UNIX_EPOCH)
1468 .map_err(|e| e.to_string())?
1469 .as_nanos();
1470 let root = job
1471 .output
1472 .join(format!("run-{stamp}-{}", std::process::id()));
1473 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1474 let session = crate::resume::RunSession::fresh(&root)?;
1475 write_json(&root, "render.json", render_doc.clone())?;
1476 session
1477 }
1478 RunTarget::Resume(dir) => {
1479 let total = plan.passes;
1480 crate::resume::RunSession::resume(
1481 &job.output,
1482 &dir,
1483 &render_doc,
1484 &planned,
1485 &mut |done, all| {
1486 emit(RenderEvent::Step {
1487 index: 0,
1488 total,
1489 label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),
1490 })
1491 },
1492 )?
1493 }
1494 };
1495 let root = session.root.clone();
1496 // Internal unique names select every source clip, even duplicate display names.
1497 for (i, c) in job.character.clips.iter_mut().enumerate() {
1498 c.name = format!("render-clip-{i}");
1499 }
1500 let mut completed = 0u32;
1501 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1502 let mut cache = equipment::TextureCache::default();
1503 let result = (|| {
1504 for (index, sample) in samples(&plan).enumerate() {
1505 let clip = &plan.clips[sample.clip];
1506 let frame = sample.frame;
1507 let pitch = sample.pitch;
1508 let yaw = sample.yaw;
1509 let paths = &planned[index * 4..index * 4 + 4];
1510 if cancel.load(Ordering::Relaxed) {
1511 return Err("geannuleerd".into());
1512 }
1513 let pose = format!(
1514 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1515 job.sex, clip.source_name, clip.frames
1516 );
1517 if paths.iter().all(|p| session.verified.contains_key(p)) {
1518 for p in paths {
1519 png_sha256.insert(p.clone(), session.verified[p].clone());
1520 }
1521 completed += PASSES.len() as u32;
1522 emit(RenderEvent::Step {
1523 index: completed,
1524 total: plan.passes,
1525 label: format!("{pose} · al aanwezig, checksums kloppen"),
1526 });
1527 continue;
1528 }
1529 let key = if job.character.clips.is_empty() {
1530 String::new()
1531 } else {
1532 format!("render-clip-{}", sample.clip)
1533 };
1534 emit(RenderEvent::Step {
1535 index: completed,
1536 total: plan.passes,
1537 label: format!("{pose} · highres"),
1538 });
1539 let scene = scene(
1540 &job,
1541 &key,
1542 frame as f32 / job.fps as f32,
1543 pitch as f32,
1544 yaw as f32,
1545 &mut cache,
1546 )?;
1547 let (tx, rx) = mpsc::sync_channel(1);
1548 gpu.send(GpuRequest { scene, reply: tx })
1549 .map_err(|_| "GPU-service gesloten")?;
1550 if let Some(ctx) = ctx {
1551 ctx.request_repaint();
1552 }
1553 let raw = loop {
1554 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1555 Ok(result) => break result?,
1556 Err(mpsc::RecvTimeoutError::Timeout) => {
1557 if cancel.load(Ordering::Relaxed) {
1558 return Err("geannuleerd".into());
1559 }
1560 }
1561 Err(_) => return Err("GPU-service gestopt".into()),
1562 }
1563 };
1564 completed += 1;
1565 emit(RenderEvent::Pass {
1566 index: completed - 1,
1567 total: plan.passes,
1568 label: format!("{pose} · highres"),
1569 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1570 });
1571 if cancel.load(Ordering::Relaxed) {
1572 return Err("geannuleerd".into());
1573 }
1574 emit(RenderEvent::Step {
1575 index: completed,
1576 total: plan.passes,
1577 label: format!("{pose} · detail"),
1578 });
1579 let detail = detail_map(&raw)?;
1580 completed += 1;
1581 emit(RenderEvent::Pass {
1582 index: completed - 1,
1583 total: plan.passes,
1584 label: format!("{pose} · detail"),
1585 image: Some(detail_image(&raw, &detail)),
1586 });
1587 if cancel.load(Ordering::Relaxed) {
1588 return Err("geannuleerd".into());
1589 }
1590 emit(RenderEvent::Step {
1591 index: completed,
1592 total: plan.passes,
1593 label: format!("{pose} · target-res / diffuse"),
1594 });
1595 let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1596 for ((map, preview), rel) in [
1597 ("diffuse", target.diffuse),
1598 ("depth", target.depth_preview),
1599 ("normal", target.normal),
1600 ("specular", target.specular),
1601 ]
1602 .into_iter()
1603 .zip(paths)
1604 {
1605 if cancel.load(Ordering::Relaxed) {
1606 return Err("geannuleerd".into());
1607 }
1608 emit(RenderEvent::Step {
1609 index: completed,
1610 total: plan.passes,
1611 label: format!("{pose} · {map}"),
1612 });
1613 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1614 let kept = session.verified.contains_key(rel);
1615 let digest = if kept {
1616 session.verified[rel].clone()
1617 } else {
1618 let bytes = if map == "depth" {
1619 encode_depth_png(job.target_width, job.target_height, &target.depth)?
1620 } else {
1621 encode_rgba_png(&preview, map == "diffuse")?
1622 };
1623 session.publish(rel, &bytes)?
1624 };
1625 png_sha256.insert(rel.clone(), digest);
1626 completed += 1;
1627 let pass = if map == "diffuse" {
1628 "target-res / diffuse"
1629 } else {
1630 map
1631 };
1632 emit(RenderEvent::Pass {
1633 index: completed - 1,
1634 total: plan.passes,
1635 label: if kept {
1636 format!("{pose} · {pass} (bestaand bestand behouden)")
1637 } else {
1638 format!("{pose} · {pass}")
1639 },
1640 image: Some(preview),
1641 });
1642 }
1643 }
1644 Ok(())
1645 })();
1646 match result {
1647 Ok(()) => {
1648 if png_sha256.len() as u32 != plan.files {
1649 return Err(format!(
1650 "checksumtelling {} wijkt af van geplande {} PNGs",
1651 png_sha256.len(),
1652 plan.files
1653 ));
1654 }
1655 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1656 write_json(
1657 &root,
1658 "complete.json",
1659 serde_json::json!({
1660 "format_version": crate::export_set::OUTPUT_META_VERSION,
1661 "status": "complete",
1662 "identity": identity,
1663 "passes": completed,
1664 "png_files": plan.files,
1665 "png_sha256": png_sha256,
1666 }),
1667 )?;
1668 emit(RenderEvent::Finished {
1669 saved: Some(root.display().to_string()),
1670 });
1671 Ok(())
1672 }
1673 Err(error) => {
1674 let name = if cancel.load(Ordering::Relaxed) {
1675 "cancelled.json"
1676 } else {
1677 "failed.json"
1678 };
1679 let _ = write_json(
1680 &root,
1681 name,
1682 serde_json::json!({
1683 "error": error,
1684 "completed_passes": completed,
1685 "total_passes": plan.passes,
1686 "png_files_done": png_sha256.len(),
1687 "png_files": plan.files,
1688 "attempt": session.attempt,
1689 }),
1690 );
1691 Err(error)
1692 }
1693 }
1694}
1695
1696/// Real readback regression: nearest-surface depth/normal/specular, preserved
1697/// texture × vertex albedo/alpha and independence from opposed light directions.
1698/// # Safety
1699/// Run on a current disposable native context.
1700pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1701 let triangle = |z, normal, color, specular| Draw {
1702 positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1703 normals: Arc::new(vec![normal; 3]),
1704 uvs: Arc::new(vec![[0.; 2]; 3]),
1705 vertex_colors: Arc::new(Vec::new()),
1706 indices: vec![0, 1, 2],
1707 color,
1708 albedo: None,
1709 shiny: None,
1710 specular,
1711 cutoff: 0.001,
1712 flat_normals: false,
1713 };
1714 let mut renderer = GpuRenderer::default();
1715 let mut scene = Scene {
1716 draws: vec![
1717 triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1718 triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1719 ],
1720 view: preview::identity(),
1721 projection: preview::identity(),
1722 width: 32,
1723 height: 32,
1724 };
1725 let result = (|| {
1726 let raw = renderer.render(gl, &scene)?;
1727 let i = 16 * 32 + 16;
1728 if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1729 || (raw.depth[i] - 0.25).abs() > 1e-5
1730 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1731 || raw.specular[i * 4].abs_diff(191) > 1
1732 {
1733 return Err(format!(
1734 "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
1735 &raw.diffuse[i * 4..i * 4 + 4],
1736 raw.depth[i],
1737 &raw.normal[i * 4..i * 4 + 4],
1738 raw.specular[i * 4]
1739 ));
1740 }
1741 scene.draws.reverse();
1742 let reverse = renderer.render(gl, &scene)?;
1743 if reverse.diffuse != raw.diffuse
1744 || reverse.normal != raw.normal
1745 || reverse.depth != raw.depth
1746 {
1747 return Err("GPU map winner depends on submission order".into());
1748 }
1749 scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
1750 scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
1751 scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
1752 content_hash: 99,
1753 width: 1,
1754 height: 1,
1755 rgba: vec![128, 255, 64, 192],
1756 }));
1757 let expected = [
1758 srgb(linear(128) * 0.25),
1759 srgb(0.75),
1760 srgb(linear(64) * 0.5),
1761 192,
1762 ];
1763 // Production intentionally has no lighting input. This optional conventional
1764 // uniform is optimized out now; a future accidental lighting term using it
1765 // must fail these actual GPU readbacks under opposed light directions.
1766 for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
1767 let p = renderer.program.ok_or("missing probe program")?;
1768 gl.use_program(Some(p));
1769 gl.uniform_3_f32(
1770 gl.get_uniform_location(p, "u_light_direction").as_ref(),
1771 light[0],
1772 light[1],
1773 light[2],
1774 );
1775 let output = renderer.render(gl, &scene)?;
1776 let pixel = &output.diffuse[i * 4..i * 4 + 4];
1777 if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
1778 return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
1779 }
1780 }
1781 let detail = detail_map(&raw)?;
1782 let target = resolve(&raw, &detail, 4, 4)?;
1783 if target.diffuse.width != 4
1784 || target.depth.len() != 32
1785 || target
1786 .normal
1787 .rgba
1788 .chunks_exact(4)
1789 .filter(|p| p[3] > 0)
1790 .any(|p| {
1791 let n = p[..3]
1792 .iter()
1793 .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
1794 .sum::<f32>()
1795 .sqrt();
1796 (n - 1.).abs() > 0.03
1797 })
1798 {
1799 return Err("target map dimensions/normals invalid".into());
1800 }
1801 let mut flat = Scene {
1802 draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
1803 view: preview::identity(),
1804 projection: preview::identity(),
1805 width: 32,
1806 height: 32,
1807 };
1808 flat.draws[0].flat_normals = true;
1809 let derived = renderer.render(gl, &flat)?;
1810 if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
1811 return Err(format!(
1812 "flat normals are not geometry-derived: {:?}",
1813 &derived.normal[i * 4..i * 4 + 4]
1814 ));
1815 }
1816 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");
1817 Ok(())
1818 })();
1819 renderer.destroy(gl);
1820 result
1821}
1822
1823#[cfg(test)]
1824mod tests {
1825 use super::*;
1826 struct Temp(PathBuf);
1827 impl Temp {
1828 fn new() -> Self {
1829 let p = std::env::temp_dir().join(format!(
1830 "paperdoll-production-{}-{}",
1831 std::process::id(),
1832 std::time::SystemTime::now()
1833 .duration_since(std::time::UNIX_EPOCH)
1834 .unwrap()
1835 .as_nanos()
1836 ));
1837 std::fs::create_dir(&p).unwrap();
1838 Self(p)
1839 }
1840 }
1841 impl Drop for Temp {
1842 fn drop(&mut self) {
1843 let _ = std::fs::remove_dir_all(&self.0);
1844 }
1845 }
1846 fn body() -> PreviewCharacter {
1847 preview::load_gltf_file(
1848 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1849 )
1850 .unwrap()
1851 }
1852 fn raw(w: u32, h: u32) -> Buffers {
1853 let n = (w * h) as usize;
1854 Buffers {
1855 width: w,
1856 height: h,
1857 diffuse: [255, 0, 0, 255].repeat(n),
1858 normal: [128, 128, 255, 255].repeat(n),
1859 specular: [191, 191, 191, 255].repeat(n),
1860 depth: vec![0.25; n],
1861 }
1862 }
1863 #[test]
1864 fn job_counts_every_clip_frame_angle_and_exact_order() {
1865 let mut b = body();
1866 let mut c = b.clips[0].clone();
1867 c.name = "../A \u{2603}".into();
1868 c.duration = 0.5;
1869 b.clips = vec![c.clone(), c];
1870 let p = plan(&b, 3).unwrap();
1871 assert_eq!((p.images, p.passes, p.files), (144, 864, 576));
1872 let s = samples(&p).collect::<Vec<_>>();
1873 assert_eq!(s.len(), 144);
1874 assert_eq!(
1875 s[0],
1876 Sample {
1877 clip: 0,
1878 frame: 0,
1879 pitch: -30,
1880 yaw: 0
1881 }
1882 );
1883 assert_eq!(s[11].yaw, 330);
1884 assert_eq!(s[12].pitch, 0);
1885 assert_eq!(s[35].pitch, 30);
1886 assert_eq!(s[36].frame, 1);
1887 assert_eq!(s[72].clip, 1);
1888 assert_ne!(p.clips[0].token, p.clips[1].token);
1889 assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1890 assert_eq!(
1891 PASSES,
1892 [
1893 "highres",
1894 "detail",
1895 "target-res / diffuse",
1896 "depth",
1897 "normal",
1898 "specular"
1899 ]
1900 );
1901 b.clips.clear();
1902 let p = plan(&b, 12).unwrap();
1903 assert_eq!((p.images, p.files), (36, 144));
1904 }
1905 #[test]
1906 fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
1907 let mut b = body();
1908 b.clips.truncate(1);
1909 b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
1910 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
1911 b.clips[0].duration = 1.00001;
1912 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
1913 }
1914 #[test]
1915 fn planner_rejects_invalid_or_unbounded_inputs() {
1916 let mut b = body();
1917 assert!(plan(&b, 0).is_err());
1918 assert!(plan(&b, 241).is_err());
1919 for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
1920 b.clips[0].duration = duration;
1921 assert!(plan(&b, 12).is_err());
1922 }
1923 }
1924 #[test]
1925 fn output_tokens_only_four_target_maps() {
1926 let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");
1927 assert_eq!(
1928 output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),
1929 expected
1930 );
1931 for map in ["diffuse", "normal", "depth", "specular"] {
1932 assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
1933 }
1934 for map in ["highres", "detail", "shiny", "../x"] {
1935 assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
1936 }
1937 for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
1938 assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
1939 }
1940 assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
1941 assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
1942 }
1943 #[test]
1944 fn resolve_preserves_thin_foreground_and_matching_maps() {
1945 let mut r = raw(8, 8);
1946 r.diffuse = [0, 255, 0, 255].repeat(64);
1947 r.depth.fill(0.75);
1948 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1949 r.depth[0] = 0.25;
1950 let d = detail_map(&r).unwrap();
1951 assert!(d.iter().any(|x| *x > 0));
1952 let t = resolve(&r, &d, 1, 1).unwrap();
1953 assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
1954 assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
1955 assert!((t.depth[0] as i32 - 16384).abs() <= 1);
1956 let n = &t.normal.rgba;
1957 let len = (0..3)
1958 .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
1959 .sum::<f32>()
1960 .sqrt();
1961 assert!((len - 1.).abs() < 0.01);
1962 r.diffuse.fill(0);
1963 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1964 let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
1965 assert_eq!(t.diffuse.rgba[3], 4);
1966 assert_eq!(t.depth[1], 1028);
1967 assert_eq!(t.normal.rgba[3], 4);
1968 assert_eq!(t.specular.rgba[3], 4);
1969 }
1970 #[test]
1971 fn invalid_map_dimensions_and_depth_fail_without_panics() {
1972 let mut r = raw(8, 8);
1973 r.normal.pop();
1974 assert!(detail_map(&r).is_err());
1975 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1976 let mut r = raw(8, 8);
1977 r.depth[0] = f32::NAN;
1978 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1979 let r = raw(8, 8);
1980 assert!(resolve(&r, &[0; 63], 1, 1).is_err());
1981 assert!(resolve(&r, &[0; 64], 0, 1).is_err());
1982 assert!(resolve(&r, &[0; 64], 257, 1).is_err());
1983 }
1984 #[test]
1985 fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
1986 let t = Temp::new();
1987 let p = Path::new("map_diffuse.png");
1988 write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
1989 let original = std::fs::read(t.0.join(p)).unwrap();
1990 assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
1991 assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
1992 let mut reader = png::Decoder::new(std::io::Cursor::new(original))
1993 .read_info()
1994 .unwrap();
1995 assert!(reader.info().srgb.is_some());
1996 let mut out = vec![0; reader.output_buffer_size().unwrap()];
1997 reader.next_frame(&mut out).unwrap();
1998 assert_eq!(out, [128, 64, 32, 192]);
1999 write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
2000 let mut reader = png::Decoder::new(std::io::BufReader::new(
2001 std::fs::File::open(t.0.join("depth.png")).unwrap(),
2002 ))
2003 .read_info()
2004 .unwrap();
2005 assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
2006 assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
2007 assert!(reader.info().srgb.is_none());
2008 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2009 reader.next_frame(&mut out).unwrap();
2010 assert_eq!(out, [64, 0, 128, 128]);
2011 let tmp =
2012 t.0.join("blocked.png")
2013 .with_extension(format!("part-{}", std::process::id()));
2014 std::fs::write(&tmp, b"keep").unwrap();
2015 // A foreign/stale temp (e.g. left by a crashed process with a reused PID)
2016 // is never deleted or reused; per-write unique temp names still publish.
2017 write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();
2018 assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
2019 let temps = std::fs::read_dir(&t.0)
2020 .unwrap()
2021 .filter_map(|e| e.ok())
2022 .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))
2023 .count();
2024 assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");
2025 assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
2026 assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
2027 assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
2028 }
2029 #[cfg(unix)]
2030 #[test]
2031 fn output_rejects_escaping_symlinks() {
2032 let root = Temp::new();
2033 let outside = Temp::new();
2034 std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
2035 assert!(write_rgba(
2036 &root.0,
2037 Path::new("link/sub/x.png"),
2038 &image(1, 1, vec![0; 4])
2039 )
2040 .is_err());
2041 assert!(!outside.0.join("sub").exists());
2042 }
2043 #[test]
2044 fn latest_mailbox_is_bounded_and_retains_terminal() {
2045 let m = Mailbox::default();
2046 for i in 0..1000 {
2047 m.publish(RenderEvent::Pass {
2048 index: i,
2049 total: 1000,
2050 label: i.to_string(),
2051 image: Some(image(1, 1, vec![0; 4])),
2052 });
2053 }
2054 m.publish(RenderEvent::Step {
2055 index: 999,
2056 total: 1000,
2057 label: "working".into(),
2058 });
2059 m.publish(RenderEvent::Cancelled);
2060 let events = m.drain();
2061 assert_eq!(events.len(), 3);
2062 assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
2063 assert!(matches!(&events[2], RenderEvent::Cancelled));
2064 assert!(m.drain().is_empty());
2065 }
2066
2067 #[test]
2068 fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
2069 let temp = Temp::new();
2070 let mut job = RenderJob {
2071 character: body(),
2072 sex: "male".into(),
2073 preset: None,
2074 target_width: 64,
2075 target_height: 96,
2076 fps: 12,
2077 framing: CameraFraming {
2078 height_milli: 3100,
2079 center_y_milli: 1200,
2080 pivot_y_milli: Some(1200),
2081 },
2082 output: temp.0.clone(),
2083 project_id: "test-project".into(),
2084 project_name: "Test".into(),
2085 body_model: SourceFingerprint {
2086 path: "assets/body/test.glb".into(),
2087 sha256: crate::digest::sha256_hex(b"test-body"),
2088 },
2089 preset_sha256: None,
2090 };
2091 let mut cache = equipment::TextureCache::default();
2092 let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2093 let mut preset = equipment::Preset::new("sword".into());
2094 let fixture =
2095 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
2096 equipment::import_obj(&mut preset, &fixture).unwrap();
2097 let joint = job
2098 .character
2099 .joint_names
2100 .iter()
2101 .position(|n| n == "RightHand")
2102 .unwrap();
2103 preset.bind(0, &job.character, 0, joint).unwrap();
2104 let count = preset
2105 .attachments
2106 .iter()
2107 .map(|a| a.meshes.len())
2108 .sum::<usize>();
2109 job.preset = Some(preset);
2110 let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2111 assert_eq!(e.draws.len(), count);
2112 assert_eq!(b.view, e.view);
2113 assert_eq!(b.projection, e.projection);
2114 assert_eq!((e.width, e.height), (512, 768));
2115 assert_eq!(e.projection[5], 2. / 3.1);
2116 let center = point(&e.view, [0., 1.2, 0.]);
2117 assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
2118 assert!((center[2] + 10.).abs() < 0.0001);
2119 let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
2120 let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
2121 assert_eq!(early.view, late.view);
2122 assert_eq!(early.projection, late.projection);
2123 assert_ne!(early.draws[0].positions, late.draws[0].positions);
2124 let mut invalid = e.clone();
2125 invalid.view[0] = f32::NAN;
2126 assert!(invalid.validate().is_err());
2127 }
2128 #[test]
2129 fn body_import_retains_texture_material_and_linear_vertex_color() {
2130 let temp = Temp::new();
2131 let source = std::fs::read(
2132 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2133 )
2134 .unwrap();
2135 let glb = gltf::binary::Glb::from_slice(&source).unwrap();
2136 let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
2137 let mut bin = glb.bin.unwrap().into_owned();
2138 while !bin.len().is_multiple_of(4) {
2139 bin.push(0);
2140 }
2141 let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
2142 .as_u64()
2143 .unwrap() as usize;
2144 let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
2145 let offset = bin.len();
2146 for _ in 0..count {
2147 for value in [0.25f32, 0.75, 0.5, 0.5] {
2148 bin.extend(value.to_le_bytes());
2149 }
2150 }
2151 let view = json["bufferViews"].as_array().unwrap().len();
2152 json["bufferViews"]
2153 .as_array_mut()
2154 .unwrap()
2155 .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
2156 let accessor = json["accessors"].as_array().unwrap().len();
2157 json["accessors"].as_array_mut().unwrap().push(
2158 serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
2159 );
2160 json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
2161 json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
2162 json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
2163 json["textures"] = serde_json::json!([{"source":0}]);
2164 let material = json["meshes"][0]["primitives"][0]["material"]
2165 .as_u64()
2166 .unwrap() as usize;
2167 json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
2168 serde_json::json!({"index":0});
2169 json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
2170 serde_json::json!([0.5, 0.75, 1., 1.]);
2171 std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
2172 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2173 write_rgba(
2174 &temp.0,
2175 Path::new("albedo.png"),
2176 &image(1, 1, vec![128, 255, 64, 192]),
2177 )
2178 .unwrap();
2179 let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2180 assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
2181 assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
2182 assert_eq!(
2183 body.surfaces[0].albedo.as_ref().unwrap().rgba,
2184 [128, 255, 64, 192]
2185 );
2186 assert_eq!(body.colors.len(), body.positions.len());
2187 assert_eq!(body.uvs.len(), body.positions.len());
2188 assert!(body.surfaces[0].flat_normals);
2189 json["meshes"][0]["primitives"][0]["attributes"]
2190 .as_object_mut()
2191 .unwrap()
2192 .remove("NORMAL");
2193 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2194 let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2195 assert!(!missing.source_normals_complete);
2196 let job = RenderJob {
2197 character: missing,
2198 sex: "male".into(),
2199 preset: None,
2200 target_width: 1,
2201 target_height: 1,
2202 fps: 12,
2203 framing: CameraFraming::default(),
2204 output: temp.0.clone(),
2205 project_id: "test-project".into(),
2206 project_name: "Test".into(),
2207 body_model: SourceFingerprint {
2208 path: "assets/body/test.glb".into(),
2209 sha256: crate::digest::sha256_hex(b"test-body"),
2210 },
2211 preset_sha256: None,
2212 };
2213 assert!(
2214 scene(
2215 &job,
2216 "",
2217 0.,
2218 0.,
2219 0.,
2220 &mut equipment::TextureCache::default()
2221 )
2222 .unwrap()
2223 .draws[0]
2224 .flat_normals
2225 );
2226 }
2227 #[test]
2228 fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
2229 for stop in [false, true] {
2230 let tmp = Temp::new();
2231 let mut b = body();
2232 b.clips.clear();
2233 let job = RenderJob {
2234 character: b,
2235 sex: "male".into(),
2236 preset: None,
2237 target_width: 1,
2238 target_height: 1,
2239 fps: 12,
2240 framing: CameraFraming::default(),
2241 output: tmp.0.clone(),
2242 project_id: "test-project".into(),
2243 project_name: "Test".into(),
2244 body_model: SourceFingerprint {
2245 path: "assets/body/test.glb".into(),
2246 sha256: crate::digest::sha256_hex(b"test-body"),
2247 },
2248 preset_sha256: None,
2249 };
2250 let flag = AtomicBool::new(false);
2251 let (tx, rx) = mpsc::channel::<GpuRequest>();
2252 let gpu = std::thread::spawn(move || {
2253 for request in rx {
2254 let _ = request
2255 .reply
2256 .send(Ok(raw(request.scene.width, request.scene.height)));
2257 }
2258 });
2259 let mut labels = Vec::new();
2260 let result = execute(job, &tx, &flag, None, &mut |event| {
2261 if let RenderEvent::Pass { label, .. } = event {
2262 labels.push(label);
2263 if stop && labels.len() == 3 {
2264 flag.store(true, Ordering::Relaxed);
2265 }
2266 }
2267 });
2268 drop(tx);
2269 gpu.join().unwrap();
2270 let run = std::fs::read_dir(&tmp.0)
2271 .unwrap()
2272 .next()
2273 .unwrap()
2274 .unwrap()
2275 .path();
2276 assert!(run.join("render.json").exists());
2277 assert_eq!(run.join("complete.json").exists(), !stop);
2278 assert_eq!(run.join("cancelled.json").exists(), stop);
2279 assert_eq!(result.is_ok(), !stop);
2280 let meta: serde_json::Value =
2281 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2282 .unwrap();
2283 assert_eq!(meta["format_version"], 3);
2284 assert_eq!(meta["project"]["id"], "test-project");
2285 assert_eq!(
2286 meta["body_model"]["sha256"],
2287 crate::digest::sha256_hex(b"test-body")
2288 );
2289 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
2290 assert_eq!(meta["pivot_source"]["kind"], "neck");
2291 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
2292 assert_eq!(
2293 meta["saved_maps"]
2294 .as_array()
2295 .unwrap()
2296 .iter()
2297 .map(|v| v.as_str().unwrap())
2298 .collect::<Vec<_>>(),
2299 ["diffuse", "depth", "normal", "specular"]
2300 );
2301 if !stop {
2302 let complete: serde_json::Value = serde_json::from_str(
2303 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2304 )
2305 .unwrap();
2306 assert_eq!(complete["format_version"], 3);
2307 assert_eq!(complete["status"], "complete");
2308 assert_eq!(complete["png_files"], 144);
2309 let checksums = complete["png_sha256"].as_object().unwrap();
2310 assert_eq!(checksums.len(), 144);
2311 for (rel, digest) in checksums {
2312 assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2313 let bytes = std::fs::read(run.join(rel)).unwrap();
2314 assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2315 }
2316 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2317 let sample = opened
2318 .load_map(
2319 "clip000-bind",
2320 0,
2321 -30,
2322 0,
2323 crate::export_set::MapKind::Diffuse,
2324 )
2325 .unwrap();
2326 assert_eq!(sample.width(), 1);
2327 }
2328 let frame = run.join("male/body/clip000-bind/f000000");
2329 assert!(frame.join("p-30_y000_diffuse.png").exists());
2330 assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
2331 assert!(!frame.join("p-30_y000_highres.png").exists());
2332 assert!(!frame.join("p-30_y000_detail.png").exists());
2333 if !stop {
2334 assert_eq!(labels.len(), 216);
2335 for labels in labels.chunks_exact(6) {
2336 for (label, pass) in labels.iter().zip([
2337 "highres",
2338 "detail",
2339 "target-res / diffuse",
2340 "depth",
2341 "normal",
2342 "specular",
2343 ]) {
2344 assert!(label.ends_with(pass));
2345 }
2346 }
2347 }
2348 }
2349 }
2350
2351 // ---- Resume (design §7/§12.6) ----
2352 fn resume_job(out: &Path) -> RenderJob {
2353 let mut b = body();
2354 b.clips.clear();
2355 RenderJob {
2356 character: b,
2357 sex: "male".into(),
2358 preset: None,
2359 target_width: 2,
2360 target_height: 2,
2361 fps: 12,
2362 framing: CameraFraming::default(),
2363 output: out.to_path_buf(),
2364 project_id: "resume-project".into(),
2365 project_name: "Resume".into(),
2366 body_model: SourceFingerprint {
2367 path: "assets/body/test.glb".into(),
2368 sha256: crate::digest::sha256_hex(b"resume-body"),
2369 },
2370 preset_sha256: None,
2371 }
2372 }
2373 /// Deterministic pose-dependent fake GPU output (view matrix drives the colour).
2374 fn fake_raw(scene: &Scene) -> Buffers {
2375 let mut b = raw(scene.width, scene.height);
2376 let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)
2377 as u32
2378 % 200;
2379 for px in b.diffuse.chunks_exact_mut(4) {
2380 px[1] = v as u8;
2381 px[2] = (255 - v) as u8;
2382 }
2383 b
2384 }
2385 /// Run a job; cancel after `stop` completed Pass events (None = run to the end).
2386 fn drive(
2387 job: RenderJob,
2388 target: RunTarget,
2389 stop: Option<usize>,
2390 ) -> (Result<(), String>, Vec<RenderEvent>) {
2391 let flag = AtomicBool::new(false);
2392 let (tx, rx) = mpsc::channel::<GpuRequest>();
2393 let gpu = std::thread::spawn(move || {
2394 for request in rx {
2395 let reply = fake_raw(&request.scene);
2396 let _ = request.reply.send(Ok(reply));
2397 }
2398 });
2399 let mut events = Vec::new();
2400 let mut passes = 0;
2401 let result = execute_target(job, target, &tx, &flag, None, &mut |event| {
2402 if matches!(event, RenderEvent::Pass { .. }) {
2403 passes += 1;
2404 if stop == Some(passes) {
2405 flag.store(true, Ordering::Relaxed);
2406 }
2407 }
2408 events.push(event);
2409 });
2410 drop(tx);
2411 gpu.join().unwrap();
2412 (result, events)
2413 }
2414 fn runs(out: &Path) -> Vec<PathBuf> {
2415 let mut v = std::fs::read_dir(out)
2416 .unwrap()
2417 .map(|e| e.unwrap().path())
2418 .collect::<Vec<_>>();
2419 v.sort();
2420 v
2421 }
2422 fn checksums(run: &Path) -> serde_json::Value {
2423 let c: serde_json::Value =
2424 serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();
2425 c["png_sha256"].clone()
2426 }
2427 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2428 let mut out = std::collections::BTreeMap::new();
2429 let mut stack = vec![run.to_path_buf()];
2430 while let Some(dir) = stack.pop() {
2431 for e in std::fs::read_dir(dir).unwrap() {
2432 let p = e.unwrap().path();
2433 if p.is_dir() {
2434 stack.push(p);
2435 } else {
2436 out.insert(
2437 p.clone(),
2438 crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),
2439 );
2440 }
2441 }
2442 }
2443 out
2444 }
2445 #[cfg(unix)]
2446 fn inode(p: &Path) -> u64 {
2447 use std::os::unix::fs::MetadataExt;
2448 std::fs::metadata(p).unwrap().ino()
2449 }
2450
2451 #[test]
2452 #[cfg(unix)]
2453 fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {
2454 let reference_dir = Temp::new();
2455 let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2456 ok.unwrap();
2457 let reference = checksums(&runs(&reference_dir.0)[0]);
2458 assert_eq!(reference.as_object().unwrap().len(), 144);
2459
2460 let tmp = Temp::new();
2461 // An earlier completed run in the same output must stay byte-identical.
2462 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2463 let earlier = runs(&tmp.0)[0].clone();
2464 let earlier_tree = tree(&earlier);
2465 std::thread::sleep(std::time::Duration::from_millis(2));
2466 let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));
2467 assert!(first.is_err());
2468 let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();
2469 let cancelled: serde_json::Value =
2470 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2471 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2472 assert!(done_first > 0 && done_first < 144);
2473 assert_eq!(cancelled["attempt"], 0);
2474 let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");
2475 let first_inode = inode(&first_file);
2476 assert!(crate::export_set::ExportRun::open(&run).is_err());
2477
2478 let found =
2479 crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)
2480 .unwrap();
2481 assert_eq!(found.len(), 1);
2482 assert_eq!(found[0].state, "geannuleerd");
2483 assert_eq!(found[0].journaled as u64, done_first);
2484 assert!(found[0].blocked.is_none());
2485
2486 // Second attempt: cancelled again after more work.
2487 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));
2488 assert!(second.is_err());
2489 // Progress resumes at the verified work, not at zero, and skipped images are labelled.
2490 let first_step = events
2491 .iter()
2492 .find_map(|e| match e {
2493 RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {
2494 Some(*index)
2495 }
2496 _ => None,
2497 })
2498 .unwrap();
2499 assert_eq!(first_step, 6);
2500 let cancelled2: serde_json::Value =
2501 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2502 assert_eq!(cancelled2["attempt"], 1);
2503 assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);
2504 assert!(run.join("attempt-000-cancelled.json").exists());
2505 assert_eq!(
2506 inode(&first_file),
2507 first_inode,
2508 "verified PNG was rewritten"
2509 );
2510
2511 // Third attempt completes.
2512 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2513 third.unwrap();
2514 assert!(!run.join("cancelled.json").exists());
2515 assert!(run.join("attempt-001-cancelled.json").exists());
2516 assert_eq!(inode(&first_file), first_inode);
2517 assert_eq!(checksums(&run), reference);
2518 crate::export_set::ExportRun::open(&run).unwrap();
2519 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2520 assert_eq!(log.lines().count(), 2);
2521 assert_eq!(tree(&earlier), earlier_tree);
2522 // A completed run is no longer offered and cannot be resumed.
2523 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2524 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2525 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2526 assert!(again.unwrap_err().contains("al voltooid"));
2527 }
2528
2529 #[test]
2530 #[cfg(unix)]
2531 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
2532 let reference_dir = Temp::new();
2533 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2534 .0
2535 .unwrap();
2536 let reference = checksums(&runs(&reference_dir.0)[0]);
2537
2538 let tmp = Temp::new();
2539 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))
2540 .0
2541 .is_err());
2542 let run = runs(&tmp.0)[0].clone();
2543 // Simulate a hard crash: no terminal marker, torn last journal line.
2544 std::fs::remove_file(run.join("cancelled.json")).unwrap();
2545 let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();
2546 let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();
2547 let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
2548 let torn_path = run.join(torn["path"].as_str().unwrap());
2549 let last = lines.pop().unwrap();
2550 let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];
2551 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2552 let torn_inode = inode(&torn_path);
2553 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2554 let frame = run.join("male/body/clip000-bind/f000000");
2555 let corrupt = frame.join("p-30_y030_normal.png");
2556 std::fs::write(&corrupt, b"not a png").unwrap();
2557 let missing = frame.join("p-30_y060_depth.png");
2558 std::fs::remove_file(&missing).unwrap();
2559 let stale = frame.join("p-30_y090_diffuse.part-999999-0");
2560 std::fs::write(&stale, b"partial").unwrap();
2561
2562 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2563 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2564 assert_eq!(found[0].state, "afgebroken");
2565
2566 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2567 .0
2568 .unwrap();
2569 assert_eq!(checksums(&run), reference);
2570 crate::export_set::ExportRun::open(&run).unwrap();
2571 assert!(missing.exists());
2572 assert_eq!(
2573 std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),
2574 b"not a png"
2575 );
2576 assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
2577 assert_eq!(
2578 inode(&torn_path),
2579 torn_inode,
2580 "identical unjournaled file was replaced"
2581 );
2582 let log: serde_json::Value = serde_json::from_str(
2583 std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))
2584 .unwrap()
2585 .lines()
2586 .next()
2587 .unwrap(),
2588 )
2589 .unwrap();
2590 assert_eq!(log["previous_marker"], serde_json::Value::Null);
2591 assert_eq!(log["damaged_journal_lines"], 1);
2592 assert_eq!(log["unjournaled_existing"], 1);
2593 assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);
2594 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2595 }
2596
2597 #[test]
2598 fn unjournaled_different_file_is_quarantined_not_overwritten() {
2599 let tmp = Temp::new();
2600 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))
2601 .0
2602 .is_err());
2603 let run = runs(&tmp.0)[0].clone();
2604 // A planned PNG appears that the journal never recorded and that differs.
2605 let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");
2606 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
2607 std::fs::write(&foreign, b"foreign").unwrap();
2608 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2609 .0
2610 .unwrap();
2611 crate::export_set::ExportRun::open(&run).unwrap();
2612 assert_eq!(
2613 std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),
2614 b"foreign"
2615 );
2616 }
2617
2618 #[test]
2619 fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {
2620 let tmp = Temp::new();
2621 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))
2622 .0
2623 .is_err());
2624 let run = runs(&tmp.0)[0].clone();
2625 let before = std::fs::read(run.join("cancelled.json")).unwrap();
2626 // Contract drift: FPS, body bytes, target size.
2627 let mut job = resume_job(&tmp.0);
2628 job.fps = 24;
2629 let err = drive(job, RunTarget::Resume(run.clone()), None)
2630 .0
2631 .unwrap_err();
2632 assert!(err.contains("fps"), "{err}");
2633 let mut job = resume_job(&tmp.0);
2634 job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");
2635 let err = drive(job, RunTarget::Resume(run.clone()), None)
2636 .0
2637 .unwrap_err();
2638 assert!(err.contains("body_model"), "{err}");
2639 let mut job = resume_job(&tmp.0);
2640 job.target_width = 3;
2641 let doc = contract(job.clone()).unwrap().render_doc;
2642 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2643 assert!(found[0].blocked.as_ref().unwrap().contains("target"));
2644 assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());
2645 // Refusals leave the run and its marker untouched.
2646 assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);
2647 assert!(!run.join(crate::resume::RESUME_LOG).exists());
2648 // Directory not directly inside the output directory.
2649 let other = Temp::new();
2650 let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)
2651 .0
2652 .unwrap_err();
2653 assert!(err.contains("uitvoermap"), "{err}");
2654 // Locked by a running worker.
2655 let lock = std::fs::OpenOptions::new()
2656 .write(true)
2657 .open(run.join(crate::resume::LOCK))
2658 .unwrap();
2659 lock.lock().unwrap();
2660 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2661 .0
2662 .unwrap_err();
2663 assert!(err.contains("run.lock"), "{err}");
2664 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2665 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]
2666 .blocked
2667 .is_some());
2668 drop(lock);
2669 // Journal naming a path outside the plan.
2670 use std::io::Write;
2671 let mut j = std::fs::OpenOptions::new()
2672 .append(true)
2673 .open(run.join(crate::resume::JOURNAL))
2674 .unwrap();
2675 writeln!(
2676 j,
2677 "{}",
2678 serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})
2679 )
2680 .unwrap();
2681 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2682 .0
2683 .unwrap_err();
2684 assert!(err.contains("onbekend pad"), "{err}");
2685 }
2686}