| 1 | //! Per-map atlases and the `.ppda` (PaPerDollAtlas) consumer manifest |
| 2 | //! (Tim t955u/t957u). |
| 3 | //! |
| 4 | //! After every loose target PNG of a run is published and checksum-verified, |
| 5 | //! production writes one atlas per map type (`<sex>-<settype>-<map>-atlas.png`) |
| 6 | //! and a strict JSON `.ppda` (`<sex>-<settype>-atlas.ppda`) next to them in the |
| 7 | //! run directory. Cells are exactly the target resolution, without padding or |
| 8 | //! scaling. One row per (clip, frame) in source clip order and frame order; |
| 9 | //! columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical |
| 10 | //! cell coordinates. Atlases are streamed band by band (one cell row at a time), |
| 11 | //! published atomically without clobbering, and the `.ppda` is written last. |
| 12 | //! |
| 13 | //! [`AtlasSet`] is the validating consumer reader used by the 2D preview. |
| 14 | use crate::{ |
| 15 | digest::{self, is_sha256_hex, Sha256}, |
| 16 | export_set::{self, ExportError, MapImage, MapKind, MAX_PNG_BYTES, MAX_TARGET}, |
| 17 | preview::PRODUCTION_PITCHES, |
| 18 | production::{self, Plan, FAR, NEAR}, |
| 19 | project::CameraFraming, |
| 20 | }; |
| 21 | use serde::{Deserialize, Serialize}; |
| 22 | use std::{ |
| 23 | collections::BTreeMap, |
| 24 | fs, |
| 25 | io::Write, |
| 26 | path::{Path, PathBuf}, |
| 27 | sync::atomic::{AtomicBool, Ordering}, |
| 28 | }; |
| 29 | |
| 30 | /// `.ppda` schema version written and accepted. |
| 31 | pub const PPDA_FORMAT_VERSION: u32 = 1; |
| 32 | /// Largest atlas side in pixels (common GPU texture limit). Larger exports are |
| 33 | /// refused up front with a clear message; atlases are never split. |
| 34 | pub const MAX_ATLAS_DIM: u32 = 16384; |
| 35 | /// Bounded `.ppda` size on read. |
| 36 | pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024; |
| 37 | /// Bounded encoded atlas PNG size on read. |
| 38 | pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024; |
| 39 | /// Decoded-memory budget for one loaded atlas set (four maps) in the preview. |
| 40 | pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024; |
| 41 | /// Map order inside the `.ppda` and atlas production. |
| 42 | pub const ATLAS_MAPS: [MapKind; 4] = [ |
| 43 | MapKind::Diffuse, |
| 44 | MapKind::Normal, |
| 45 | MapKind::Depth, |
| 46 | MapKind::Specular, |
| 47 | ]; |
| 48 | const MAX_CLIPS: usize = 4096; |
| 49 | const MAX_CLIP_NAME: usize = 256; |
| 50 | |
| 51 | pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String { |
| 52 | format!("{sex}-{set}-{}-atlas.png", kind.token()) |
| 53 | } |
| 54 | |
| 55 | pub fn ppda_name(sex: &str, set: &str) -> String { |
| 56 | format!("{sex}-{set}-atlas.ppda") |
| 57 | } |
| 58 | |
| 59 | /// Deterministic grid shared by all four atlases of one export. |
| 60 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 61 | pub struct AtlasLayout { |
| 62 | pub cell_width: u32, |
| 63 | pub cell_height: u32, |
| 64 | pub columns: u32, |
| 65 | pub rows: u32, |
| 66 | pub width: u32, |
| 67 | pub height: u32, |
| 68 | } |
| 69 | |
| 70 | impl AtlasLayout { |
| 71 | pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result<Self, String> { |
| 72 | if cell_width == 0 |
| 73 | || cell_height == 0 |
| 74 | || cell_width > MAX_TARGET |
| 75 | || cell_height > MAX_TARGET |
| 76 | { |
| 77 | return Err("atlascel moet 1–256 pixels per as zijn".into()); |
| 78 | } |
| 79 | if rows == 0 { |
| 80 | return Err("atlas zonder rijen".into()); |
| 81 | } |
| 82 | let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32; |
| 83 | let width = u64::from(columns) * u64::from(cell_width); |
| 84 | let height = u64::from(rows) * u64::from(cell_height); |
| 85 | if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) { |
| 86 | return Err(format!( |
| 87 | "atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \ |
| 88 | maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \ |
| 89 | animatieduur — de atlas wordt niet gesplitst" |
| 90 | )); |
| 91 | } |
| 92 | Ok(Self { |
| 93 | cell_width, |
| 94 | cell_height, |
| 95 | columns, |
| 96 | rows, |
| 97 | width: width as u32, |
| 98 | height: height as u32, |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result<Self, String> { |
| 103 | let rows = plan |
| 104 | .clips |
| 105 | .iter() |
| 106 | .try_fold(0u32, |n, c| n.checked_add(c.frames)) |
| 107 | .ok_or("te veel frames voor de atlas")?; |
| 108 | Self::new(cell_width, cell_height, rows) |
| 109 | } |
| 110 | |
| 111 | /// Column of (pitch, yaw): pitch-major, then yaw. |
| 112 | pub fn column(pitch: i32, yaw: u32) -> Option<u32> { |
| 113 | let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?; |
| 114 | let y = production::production_yaws() |
| 115 | .iter() |
| 116 | .position(|x| *x == yaw)?; |
| 117 | Some((p * production::production_yaws().len() + y) as u32) |
| 118 | } |
| 119 | |
| 120 | pub fn cell(&self, row: u32, column: u32) -> PpdaView { |
| 121 | let yaws = production::production_yaws(); |
| 122 | let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()]; |
| 123 | let yaw = yaws[column as usize % yaws.len()]; |
| 124 | PpdaView { |
| 125 | pitch, |
| 126 | yaw, |
| 127 | x: column * self.cell_width, |
| 128 | y: row * self.cell_height, |
| 129 | w: self.cell_width, |
| 130 | h: self.cell_height, |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 136 | #[serde(deny_unknown_fields)] |
| 137 | pub struct Ppda { |
| 138 | pub format_version: u32, |
| 139 | pub sex: String, |
| 140 | pub settype: String, |
| 141 | pub project: PpdaProject, |
| 142 | /// Export/camera contract so body and equipment atlases can be checked for |
| 143 | /// compatibility without the loose run manifests. |
| 144 | pub identity: PpdaIdentity, |
| 145 | pub fps: u32, |
| 146 | pub cell_width: u32, |
| 147 | pub cell_height: u32, |
| 148 | pub pitches: Vec<i32>, |
| 149 | pub yaws: Vec<u32>, |
| 150 | pub maps: BTreeMap<String, PpdaMap>, |
| 151 | pub clips: Vec<PpdaClip>, |
| 152 | } |
| 153 | |
| 154 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 155 | #[serde(deny_unknown_fields)] |
| 156 | pub struct PpdaProject { |
| 157 | pub id: String, |
| 158 | pub name: String, |
| 159 | } |
| 160 | |
| 161 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 162 | #[serde(deny_unknown_fields)] |
| 163 | pub struct PpdaIdentity { |
| 164 | pub body_model_sha256: String, |
| 165 | pub equipment_preset_sha256: Option<String>, |
| 166 | pub framing: CameraFraming, |
| 167 | pub pivot_source: String, |
| 168 | pub near: f32, |
| 169 | pub far: f32, |
| 170 | pub camera_distance: f32, |
| 171 | pub normal_space: String, |
| 172 | pub depth: String, |
| 173 | } |
| 174 | |
| 175 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 176 | #[serde(deny_unknown_fields)] |
| 177 | pub struct PpdaMap { |
| 178 | pub file: String, |
| 179 | pub sha256: String, |
| 180 | pub width: u32, |
| 181 | pub height: u32, |
| 182 | } |
| 183 | |
| 184 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 185 | #[serde(deny_unknown_fields)] |
| 186 | pub struct PpdaClip { |
| 187 | pub source_index: u32, |
| 188 | pub name: String, |
| 189 | pub token: String, |
| 190 | pub frame_count: u32, |
| 191 | pub duration: f32, |
| 192 | pub frames: Vec<PpdaFrame>, |
| 193 | } |
| 194 | |
| 195 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 196 | #[serde(deny_unknown_fields)] |
| 197 | pub struct PpdaFrame { |
| 198 | pub index: u32, |
| 199 | pub views: Vec<PpdaView>, |
| 200 | } |
| 201 | |
| 202 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 203 | #[serde(deny_unknown_fields)] |
| 204 | pub struct PpdaView { |
| 205 | pub pitch: i32, |
| 206 | pub yaw: u32, |
| 207 | pub x: u32, |
| 208 | pub y: u32, |
| 209 | pub w: u32, |
| 210 | pub h: u32, |
| 211 | } |
| 212 | |
| 213 | /// What production records in `complete.json` after writing the atlases. |
| 214 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 215 | pub struct AtlasOutput { |
| 216 | pub ppda: String, |
| 217 | pub ppda_sha256: String, |
| 218 | /// map token → (file name, sha256) |
| 219 | pub maps: BTreeMap<String, (String, String)>, |
| 220 | } |
| 221 | |
| 222 | impl AtlasOutput { |
| 223 | pub fn to_json(&self) -> serde_json::Value { |
| 224 | let maps: serde_json::Map<String, serde_json::Value> = self |
| 225 | .maps |
| 226 | .iter() |
| 227 | .map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha}))) |
| 228 | .collect(); |
| 229 | serde_json::json!({ |
| 230 | "ppda": self.ppda, |
| 231 | "ppda_sha256": self.ppda_sha256, |
| 232 | "maps": maps, |
| 233 | }) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | struct HashingWriter<W: Write> { |
| 238 | inner: W, |
| 239 | hasher: Sha256, |
| 240 | } |
| 241 | |
| 242 | impl<W: Write> Write for HashingWriter<W> { |
| 243 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { |
| 244 | let n = self.inner.write(buf)?; |
| 245 | self.hasher.update(&buf[..n]); |
| 246 | Ok(n) |
| 247 | } |
| 248 | fn flush(&mut self) -> std::io::Result<()> { |
| 249 | self.inner.flush() |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | /// Inputs for writing the atlases of one completed run. |
| 254 | pub struct AtlasJob<'a> { |
| 255 | pub root: &'a Path, |
| 256 | pub render_doc: &'a serde_json::Value, |
| 257 | pub plan: &'a Plan, |
| 258 | pub sex: &'a str, |
| 259 | pub set: &'a str, |
| 260 | pub cell_width: u32, |
| 261 | pub cell_height: u32, |
| 262 | /// Relative loose PNG path → verified SHA-256 (every planned file). |
| 263 | pub png_sha256: &'a BTreeMap<String, String>, |
| 264 | /// Resume attempt number for quarantine names of stale atlas files. |
| 265 | pub attempt: u32, |
| 266 | pub cancel: &'a AtomicBool, |
| 267 | } |
| 268 | |
| 269 | fn rows_of(plan: &Plan) -> Vec<(usize, u32)> { |
| 270 | plan.clips |
| 271 | .iter() |
| 272 | .enumerate() |
| 273 | .flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f))) |
| 274 | .collect() |
| 275 | } |
| 276 | |
| 277 | /// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or |
| 278 | /// big-endian gray16+alpha16, matching the atlas PNG sample layout). |
| 279 | fn load_cell( |
| 280 | job: &AtlasJob<'_>, |
| 281 | clip: &str, |
| 282 | frame: u32, |
| 283 | pitch: i32, |
| 284 | yaw: u32, |
| 285 | kind: MapKind, |
| 286 | ) -> Result<Vec<u8>, String> { |
| 287 | let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?; |
| 288 | let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"); |
| 289 | let expected = job |
| 290 | .png_sha256 |
| 291 | .get(&rel) |
| 292 | .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?; |
| 293 | let path = job.root.join(&rel); |
| 294 | export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?; |
| 295 | let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?; |
| 296 | if digest::sha256_hex(&bytes) != *expected { |
| 297 | return Err(format!("atlas: checksum van {rel} klopt niet meer")); |
| 298 | } |
| 299 | match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height) |
| 300 | .map_err(|e| format!("atlas: {rel}: {e}"))? |
| 301 | { |
| 302 | MapImage::Rgba8 { rgba, .. } => Ok(rgba), |
| 303 | MapImage::Depth16 { samples, .. } => { |
| 304 | Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect()) |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /// Stream-encode one atlas PNG into `out`. |
| 310 | fn encode_atlas( |
| 311 | job: &AtlasJob<'_>, |
| 312 | layout: &AtlasLayout, |
| 313 | kind: MapKind, |
| 314 | out: &mut dyn Write, |
| 315 | progress: &mut dyn FnMut(String), |
| 316 | ) -> Result<(), String> { |
| 317 | let mut encoder = png::Encoder::new(out, layout.width, layout.height); |
| 318 | if kind == MapKind::Depth { |
| 319 | encoder.set_color(png::ColorType::GrayscaleAlpha); |
| 320 | encoder.set_depth(png::BitDepth::Sixteen); |
| 321 | } else { |
| 322 | encoder.set_color(png::ColorType::Rgba); |
| 323 | encoder.set_depth(png::BitDepth::Eight); |
| 324 | if kind == MapKind::Diffuse { |
| 325 | encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); |
| 326 | } |
| 327 | } |
| 328 | let mut writer = encoder.write_header().map_err(|e| e.to_string())?; |
| 329 | let row_bytes = layout.width as usize * 4; |
| 330 | let cell_row_bytes = layout.cell_width as usize * 4; |
| 331 | let mut stream = writer |
| 332 | .stream_writer_with_size(row_bytes.max(4096)) |
| 333 | .map_err(|e| e.to_string())?; |
| 334 | let rows = rows_of(job.plan); |
| 335 | let total = rows.len(); |
| 336 | let mut band = vec![0u8; row_bytes * layout.cell_height as usize]; |
| 337 | for (row, (clip, frame)) in rows.into_iter().enumerate() { |
| 338 | if job.cancel.load(Ordering::Relaxed) { |
| 339 | return Err("geannuleerd".into()); |
| 340 | } |
| 341 | if row % 8 == 0 { |
| 342 | progress(format!( |
| 343 | "atlas {} · rij {}/{}", |
| 344 | kind.token(), |
| 345 | row + 1, |
| 346 | total |
| 347 | )); |
| 348 | } |
| 349 | let token = &job.plan.clips[clip].token; |
| 350 | for column in 0..layout.columns { |
| 351 | let view = layout.cell(0, column); |
| 352 | let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?; |
| 353 | for y in 0..layout.cell_height as usize { |
| 354 | let dst = y * row_bytes + view.x as usize * 4; |
| 355 | band[dst..dst + cell_row_bytes] |
| 356 | .copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]); |
| 357 | } |
| 358 | } |
| 359 | stream.write_all(&band).map_err(|e| e.to_string())?; |
| 360 | } |
| 361 | stream.finish().map_err(|e| e.to_string())?; |
| 362 | writer.finish().map_err(|e| e.to_string())?; |
| 363 | Ok(()) |
| 364 | } |
| 365 | |
| 366 | /// Publish `name` in the run root without clobbering. When a file already |
| 367 | /// exists (crash after publication during an earlier attempt) it is kept when |
| 368 | /// byte-identical to the regenerated output, otherwise moved aside. |
| 369 | fn publish_streamed( |
| 370 | root: &Path, |
| 371 | name: &str, |
| 372 | attempt: u32, |
| 373 | mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>, |
| 374 | ) -> Result<String, String> { |
| 375 | let path = root.join(name); |
| 376 | if fs::symlink_metadata(&path).is_ok() { |
| 377 | let mut sink = HashingWriter { |
| 378 | inner: std::io::sink(), |
| 379 | hasher: Sha256::new(), |
| 380 | }; |
| 381 | encode(&mut sink)?; |
| 382 | let fresh = digest::hex(&sink.hasher.finalize()); |
| 383 | let regular = fs::symlink_metadata(&path) |
| 384 | .map(|m| m.file_type().is_file()) |
| 385 | .unwrap_or(false); |
| 386 | if regular |
| 387 | && digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES) |
| 388 | .ok() |
| 389 | .as_deref() |
| 390 | == Some(fresh.as_str()) |
| 391 | { |
| 392 | return Ok(fresh); |
| 393 | } |
| 394 | crate::resume::quarantine(root, name, attempt)?; |
| 395 | } |
| 396 | let mut digest_out = None; |
| 397 | production::atomic_file(root, Path::new(name), |file| { |
| 398 | let mut writer = HashingWriter { |
| 399 | inner: std::io::BufWriter::new(file), |
| 400 | hasher: Sha256::new(), |
| 401 | }; |
| 402 | encode(&mut writer)?; |
| 403 | writer.flush().map_err(|e| e.to_string())?; |
| 404 | digest_out = Some(digest::hex(&writer.hasher.finalize())); |
| 405 | Ok(()) |
| 406 | })?; |
| 407 | digest_out.ok_or_else(|| "atlas-digest ontbreekt".into()) |
| 408 | } |
| 409 | |
| 410 | /// Build the `.ppda` document for a plan/contract (pure; no disk access). |
| 411 | pub fn build_ppda( |
| 412 | render_doc: &serde_json::Value, |
| 413 | plan: &Plan, |
| 414 | layout: &AtlasLayout, |
| 415 | sex: &str, |
| 416 | set: &str, |
| 417 | map_digests: &BTreeMap<String, String>, |
| 418 | ) -> Result<Ppda, String> { |
| 419 | let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}")); |
| 420 | let text = |p: &str| -> Result<String, String> { |
| 421 | get(p)? |
| 422 | .as_str() |
| 423 | .map(str::to_string) |
| 424 | .ok_or(format!("{p} is geen tekst")) |
| 425 | }; |
| 426 | let framing: CameraFraming = |
| 427 | serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?; |
| 428 | let fps = get("/fps")?.as_u64().ok_or("fps")? as u32; |
| 429 | let mut maps = BTreeMap::new(); |
| 430 | for kind in ATLAS_MAPS { |
| 431 | maps.insert( |
| 432 | kind.token().to_string(), |
| 433 | PpdaMap { |
| 434 | file: atlas_png_name(sex, set, kind), |
| 435 | sha256: map_digests |
| 436 | .get(kind.token()) |
| 437 | .cloned() |
| 438 | .ok_or("atlas-digest ontbreekt")?, |
| 439 | width: layout.width, |
| 440 | height: layout.height, |
| 441 | }, |
| 442 | ); |
| 443 | } |
| 444 | let mut row = 0u32; |
| 445 | let mut clips = Vec::with_capacity(plan.clips.len()); |
| 446 | for (i, clip) in plan.clips.iter().enumerate() { |
| 447 | let duration = render_doc |
| 448 | .pointer(&format!("/clips/{i}/duration")) |
| 449 | .and_then(|d| d.as_f64()) |
| 450 | .unwrap_or(0.) as f32; |
| 451 | let mut frames = Vec::with_capacity(clip.frames as usize); |
| 452 | for index in 0..clip.frames { |
| 453 | frames.push(PpdaFrame { |
| 454 | index, |
| 455 | views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(), |
| 456 | }); |
| 457 | row += 1; |
| 458 | } |
| 459 | clips.push(PpdaClip { |
| 460 | source_index: i as u32, |
| 461 | name: clip.source_name.clone(), |
| 462 | token: clip.token.clone(), |
| 463 | frame_count: clip.frames, |
| 464 | duration, |
| 465 | frames, |
| 466 | }); |
| 467 | } |
| 468 | Ok(Ppda { |
| 469 | format_version: PPDA_FORMAT_VERSION, |
| 470 | sex: sex.into(), |
| 471 | settype: set.into(), |
| 472 | project: PpdaProject { |
| 473 | id: text("/project/id")?, |
| 474 | name: text("/project/name")?, |
| 475 | }, |
| 476 | identity: PpdaIdentity { |
| 477 | body_model_sha256: text("/body_model/sha256")?, |
| 478 | equipment_preset_sha256: render_doc |
| 479 | .pointer("/equipment_preset/sha256") |
| 480 | .and_then(|v| v.as_str()) |
| 481 | .map(str::to_string), |
| 482 | framing, |
| 483 | pivot_source: text("/pivot_source/kind")?, |
| 484 | near: NEAR, |
| 485 | far: FAR, |
| 486 | camera_distance: 10., |
| 487 | normal_space: text("/normal_space")?, |
| 488 | depth: text("/depth")?, |
| 489 | }, |
| 490 | fps, |
| 491 | cell_width: layout.cell_width, |
| 492 | cell_height: layout.cell_height, |
| 493 | pitches: PRODUCTION_PITCHES.to_vec(), |
| 494 | yaws: production::production_yaws().to_vec(), |
| 495 | maps, |
| 496 | clips, |
| 497 | }) |
| 498 | } |
| 499 | |
| 500 | /// Write the four atlases and the `.ppda` after all loose maps are verified. |
| 501 | pub fn write_atlases( |
| 502 | job: &AtlasJob<'_>, |
| 503 | progress: &mut dyn FnMut(String), |
| 504 | ) -> Result<AtlasOutput, String> { |
| 505 | let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?; |
| 506 | if job.png_sha256.len() as u64 != u64::from(job.plan.files) { |
| 507 | return Err("atlas: niet alle losse maps zijn geverifieerd".into()); |
| 508 | } |
| 509 | let mut maps = BTreeMap::new(); |
| 510 | let mut digests = BTreeMap::new(); |
| 511 | for kind in ATLAS_MAPS { |
| 512 | let name = atlas_png_name(job.sex, job.set, kind); |
| 513 | let digest = publish_streamed(job.root, &name, job.attempt, |out| { |
| 514 | encode_atlas(job, &layout, kind, out, progress) |
| 515 | })?; |
| 516 | digests.insert(kind.token().to_string(), digest.clone()); |
| 517 | maps.insert(kind.token().to_string(), (name, digest)); |
| 518 | } |
| 519 | let ppda = build_ppda( |
| 520 | job.render_doc, |
| 521 | job.plan, |
| 522 | &layout, |
| 523 | job.sex, |
| 524 | job.set, |
| 525 | &digests, |
| 526 | )?; |
| 527 | let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?; |
| 528 | bytes.push(b'\n'); |
| 529 | let name = ppda_name(job.sex, job.set); |
| 530 | progress(format!("atlas · {name}")); |
| 531 | let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| { |
| 532 | out.write_all(&bytes).map_err(|e| e.to_string()) |
| 533 | })?; |
| 534 | Ok(AtlasOutput { |
| 535 | ppda: name, |
| 536 | ppda_sha256, |
| 537 | maps, |
| 538 | }) |
| 539 | } |
| 540 | |
| 541 | // ---------------------------------------------------------------- reader -- |
| 542 | |
| 543 | fn ident(msg: impl Into<String>) -> ExportError { |
| 544 | ExportError::Identity(msg.into()) |
| 545 | } |
| 546 | |
| 547 | fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> { |
| 548 | if p.format_version != PPDA_FORMAT_VERSION { |
| 549 | return Err(ident(format!( |
| 550 | ".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION})", |
| 551 | p.format_version |
| 552 | ))); |
| 553 | } |
| 554 | if !["male", "female"].contains(&p.sex.as_str()) { |
| 555 | return Err(ident(".ppda: ongeldig geslacht")); |
| 556 | } |
| 557 | if p.settype != "body" && !crate::equipment::valid_name(&p.settype) { |
| 558 | return Err(ident(".ppda: ongeldig settype")); |
| 559 | } |
| 560 | if file_name != ppda_name(&p.sex, &p.settype) { |
| 561 | return Err(ident(format!( |
| 562 | ".ppda-bestandsnaam '{file_name}' past niet bij {}/{}", |
| 563 | p.sex, p.settype |
| 564 | ))); |
| 565 | } |
| 566 | if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 { |
| 567 | return Err(ident(".ppda: project id/naam ongeldig")); |
| 568 | } |
| 569 | let id = &p.identity; |
| 570 | if !is_sha256_hex(&id.body_model_sha256) { |
| 571 | return Err(ExportError::Corrupt( |
| 572 | ".ppda: body_model_sha256 ongeldig".into(), |
| 573 | )); |
| 574 | } |
| 575 | match (&id.equipment_preset_sha256, p.settype == "body") { |
| 576 | (None, true) => {} |
| 577 | (Some(h), false) if is_sha256_hex(h) => {} |
| 578 | _ => { |
| 579 | return Err(ident( |
| 580 | ".ppda: equipment_preset_sha256 past niet bij settype", |
| 581 | )) |
| 582 | } |
| 583 | } |
| 584 | id.framing.validate().map_err(ExportError::Identity)?; |
| 585 | if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) { |
| 586 | return Err(ident(".ppda: onbekende pivot_source")); |
| 587 | } |
| 588 | if id.near != NEAR || id.far != FAR || id.camera_distance != 10. { |
| 589 | return Err(ident(".ppda: camera-contract wijkt af")); |
| 590 | } |
| 591 | if p.fps == 0 || p.fps > 240 { |
| 592 | return Err(ident(".ppda: ongeldige fps")); |
| 593 | } |
| 594 | if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() { |
| 595 | return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330")); |
| 596 | } |
| 597 | if p.clips.is_empty() || p.clips.len() > MAX_CLIPS { |
| 598 | return Err(ExportError::Resource( |
| 599 | ".ppda: clip-inventaris ongeldig".into(), |
| 600 | )); |
| 601 | } |
| 602 | let rows = p |
| 603 | .clips |
| 604 | .iter() |
| 605 | .try_fold(0u32, |n, c| n.checked_add(c.frame_count)) |
| 606 | .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?; |
| 607 | let layout = |
| 608 | AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?; |
| 609 | let mut tokens = std::collections::BTreeSet::new(); |
| 610 | let mut row = 0u32; |
| 611 | for (i, clip) in p.clips.iter().enumerate() { |
| 612 | if clip.source_index as usize != i { |
| 613 | return Err(ident(".ppda: clips niet in bronvolgorde")); |
| 614 | } |
| 615 | if clip.token.is_empty() |
| 616 | || clip.token.len() > 64 |
| 617 | || !crate::equipment::valid_name(&clip.token) |
| 618 | || !tokens.insert(clip.token.clone()) |
| 619 | { |
| 620 | return Err(ident(format!( |
| 621 | ".ppda: ongeldige clip-token '{}'", |
| 622 | clip.token |
| 623 | ))); |
| 624 | } |
| 625 | if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. { |
| 626 | return Err(ident(".ppda: clipnaam/duur ongeldig")); |
| 627 | } |
| 628 | if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize { |
| 629 | return Err(ident(".ppda: frame_count past niet bij frames")); |
| 630 | } |
| 631 | for (k, frame) in clip.frames.iter().enumerate() { |
| 632 | if frame.index as usize != k || frame.views.len() != layout.columns as usize { |
| 633 | return Err(ident(".ppda: frame-index of aantal hoeken ongeldig")); |
| 634 | } |
| 635 | for (c, view) in frame.views.iter().enumerate() { |
| 636 | let expected = layout.cell(row, c as u32); |
| 637 | let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width) |
| 638 | && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height); |
| 639 | if *view != expected || !inside { |
| 640 | return Err(ExportError::Corrupt(format!( |
| 641 | ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet", |
| 642 | clip.token, view.pitch, view.yaw |
| 643 | ))); |
| 644 | } |
| 645 | } |
| 646 | row += 1; |
| 647 | } |
| 648 | } |
| 649 | if p.maps.len() != ATLAS_MAPS.len() { |
| 650 | return Err(ident( |
| 651 | ".ppda: maps moet precies diffuse/normal/depth/specular bevatten", |
| 652 | )); |
| 653 | } |
| 654 | for kind in ATLAS_MAPS { |
| 655 | let m = p |
| 656 | .maps |
| 657 | .get(kind.token()) |
| 658 | .ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?; |
| 659 | if m.file != atlas_png_name(&p.sex, &p.settype, kind) { |
| 660 | return Err(ExportError::Traversal(format!( |
| 661 | ".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap", |
| 662 | m.file |
| 663 | ))); |
| 664 | } |
| 665 | if !is_sha256_hex(&m.sha256) { |
| 666 | return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into())); |
| 667 | } |
| 668 | if m.width != layout.width || m.height != layout.height { |
| 669 | return Err(ExportError::Corrupt(format!( |
| 670 | ".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}", |
| 671 | kind.token(), |
| 672 | m.width, |
| 673 | m.height, |
| 674 | layout.width, |
| 675 | layout.height |
| 676 | ))); |
| 677 | } |
| 678 | } |
| 679 | Ok(layout) |
| 680 | } |
| 681 | |
| 682 | /// A validated `.ppda` whose atlases exist, match their SHA-256 and headers. |
| 683 | #[derive(Debug, Clone)] |
| 684 | pub struct AtlasSet { |
| 685 | dir: PathBuf, |
| 686 | pub ppda: Ppda, |
| 687 | pub layout: AtlasLayout, |
| 688 | } |
| 689 | |
| 690 | impl AtlasSet { |
| 691 | /// Export folder containing the `.ppda` and its atlases. |
| 692 | pub fn dir(&self) -> &Path { |
| 693 | &self.dir |
| 694 | } |
| 695 | |
| 696 | fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> { |
| 697 | let dir = path |
| 698 | .parent() |
| 699 | .ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?; |
| 700 | let dir = dir |
| 701 | .canonicalize() |
| 702 | .map_err(|e| ExportError::Io(e.to_string()))?; |
| 703 | let file_name = path |
| 704 | .file_name() |
| 705 | .and_then(|n| n.to_str()) |
| 706 | .ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))? |
| 707 | .to_string(); |
| 708 | let path = dir.join(&file_name); |
| 709 | export_set::verify_regular_file(&path, &dir)?; |
| 710 | let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?; |
| 711 | let text = std::str::from_utf8(&bytes) |
| 712 | .map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?; |
| 713 | let ppda: Ppda = |
| 714 | serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?; |
| 715 | let layout = validate_ppda(&ppda, &file_name)?; |
| 716 | Ok((dir, ppda, layout)) |
| 717 | } |
| 718 | |
| 719 | fn read_map(&self, kind: MapKind) -> Result<Vec<u8>, ExportError> { |
| 720 | let m = &self.ppda.maps[kind.token()]; |
| 721 | let path = self.dir.join(&m.file); |
| 722 | export_set::verify_regular_file(&path, &self.dir)?; |
| 723 | let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?; |
| 724 | if digest::sha256_hex(&bytes) != m.sha256 { |
| 725 | return Err(ExportError::Corrupt(format!( |
| 726 | "checksum mismatch voor '{}'", |
| 727 | m.file |
| 728 | ))); |
| 729 | } |
| 730 | Ok(bytes) |
| 731 | } |
| 732 | |
| 733 | /// Validate the manifest, then each atlas: regular file inside the folder, |
| 734 | /// SHA-256, PNG header dimensions and sample format. |
| 735 | pub fn open(ppda_path: &Path) -> Result<Self, ExportError> { |
| 736 | let (dir, ppda, layout) = Self::read_ppda(ppda_path)?; |
| 737 | let set = Self { dir, ppda, layout }; |
| 738 | for kind in ATLAS_MAPS { |
| 739 | let bytes = set.read_map(kind)?; |
| 740 | let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..])); |
| 741 | let reader = decoder |
| 742 | .read_info() |
| 743 | .map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?; |
| 744 | let info = reader.info(); |
| 745 | let (color, depth) = if kind == MapKind::Depth { |
| 746 | (png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen) |
| 747 | } else { |
| 748 | (png::ColorType::Rgba, png::BitDepth::Eight) |
| 749 | }; |
| 750 | if info.width != layout.width |
| 751 | || info.height != layout.height |
| 752 | || info.color_type != color |
| 753 | || info.bit_depth != depth |
| 754 | { |
| 755 | return Err(ExportError::Corrupt(format!( |
| 756 | "{} atlas: afmetingen of kleurtype wijken af van .ppda", |
| 757 | kind.token() |
| 758 | ))); |
| 759 | } |
| 760 | } |
| 761 | Ok(set) |
| 762 | } |
| 763 | |
| 764 | /// Open the single `.ppda` inside a run directory. |
| 765 | pub fn open_run(run_dir: &Path) -> Result<Self, ExportError> { |
| 766 | let mut found = Vec::new(); |
| 767 | for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? { |
| 768 | let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?; |
| 769 | let name = entry.file_name(); |
| 770 | if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) { |
| 771 | found.push(entry.path()); |
| 772 | } |
| 773 | } |
| 774 | match found.as_slice() { |
| 775 | [one] => Self::open(one), |
| 776 | [] => Err(ExportError::Incomplete( |
| 777 | "geen .ppda in deze exportmap".into(), |
| 778 | )), |
| 779 | _ => Err(ExportError::Identity( |
| 780 | "meer dan één .ppda in deze exportmap".into(), |
| 781 | )), |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | /// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with |
| 786 | /// the full map pixel contract. |
| 787 | pub fn load(&self) -> Result<LoadedAtlas, ExportError> { |
| 788 | let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4; |
| 789 | if decoded > MAX_DECODED_SET_BYTES { |
| 790 | return Err(ExportError::Resource(format!( |
| 791 | "atlasset te groot om te laden ({decoded} bytes gedecodeerd)" |
| 792 | ))); |
| 793 | } |
| 794 | let mut maps = BTreeMap::new(); |
| 795 | for kind in ATLAS_MAPS { |
| 796 | let bytes = self.read_map(kind)?; |
| 797 | let image = export_set::decode_map_sized( |
| 798 | &bytes, |
| 799 | kind, |
| 800 | self.layout.width, |
| 801 | self.layout.height, |
| 802 | MAX_ATLAS_DIM, |
| 803 | )?; |
| 804 | maps.insert(kind.token(), image); |
| 805 | } |
| 806 | Ok(LoadedAtlas { |
| 807 | set: self.clone(), |
| 808 | maps, |
| 809 | }) |
| 810 | } |
| 811 | } |
| 812 | |
| 813 | /// Decoded atlases for one `.ppda`; crops cells for the compositor. |
| 814 | #[derive(Debug, Clone)] |
| 815 | pub struct LoadedAtlas { |
| 816 | pub set: AtlasSet, |
| 817 | maps: BTreeMap<&'static str, MapImage>, |
| 818 | } |
| 819 | |
| 820 | impl LoadedAtlas { |
| 821 | pub fn ppda(&self) -> &Ppda { |
| 822 | &self.set.ppda |
| 823 | } |
| 824 | |
| 825 | /// Coordinates of one view from the `.ppda`. |
| 826 | pub fn view( |
| 827 | &self, |
| 828 | clip_token: &str, |
| 829 | frame: u32, |
| 830 | pitch: i32, |
| 831 | yaw: u32, |
| 832 | ) -> Result<PpdaView, ExportError> { |
| 833 | let clip = self |
| 834 | .set |
| 835 | .ppda |
| 836 | .clips |
| 837 | .iter() |
| 838 | .find(|c| c.token == clip_token) |
| 839 | .ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?; |
| 840 | let frame = clip.frames.get(frame as usize).ok_or_else(|| { |
| 841 | ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'")) |
| 842 | })?; |
| 843 | frame |
| 844 | .views |
| 845 | .iter() |
| 846 | .find(|v| v.pitch == pitch && v.yaw == yaw) |
| 847 | .copied() |
| 848 | .ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda"))) |
| 849 | } |
| 850 | |
| 851 | /// Crop one map cell exactly as stored. |
| 852 | pub fn cell( |
| 853 | &self, |
| 854 | clip_token: &str, |
| 855 | frame: u32, |
| 856 | pitch: i32, |
| 857 | yaw: u32, |
| 858 | kind: MapKind, |
| 859 | ) -> Result<MapImage, ExportError> { |
| 860 | let v = self.view(clip_token, frame, pitch, yaw)?; |
| 861 | let atlas_w = self.set.layout.width as usize; |
| 862 | let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize); |
| 863 | match &self.maps[kind.token()] { |
| 864 | MapImage::Rgba8 { rgba, .. } => { |
| 865 | let mut out = Vec::with_capacity(w * h * 4); |
| 866 | for row in y..y + h { |
| 867 | let start = (row * atlas_w + x) * 4; |
| 868 | out.extend_from_slice(&rgba[start..start + w * 4]); |
| 869 | } |
| 870 | Ok(MapImage::Rgba8 { |
| 871 | width: v.w, |
| 872 | height: v.h, |
| 873 | rgba: out, |
| 874 | }) |
| 875 | } |
| 876 | MapImage::Depth16 { samples, .. } => { |
| 877 | let mut out = Vec::with_capacity(w * h * 2); |
| 878 | for row in y..y + h { |
| 879 | let start = (row * atlas_w + x) * 2; |
| 880 | out.extend_from_slice(&samples[start..start + w * 2]); |
| 881 | } |
| 882 | Ok(MapImage::Depth16 { |
| 883 | width: v.w, |
| 884 | height: v.h, |
| 885 | samples: out, |
| 886 | }) |
| 887 | } |
| 888 | } |
| 889 | } |
| 890 | } |