BlueGit

golden-age paperdoll-renderer

WIP: 60 views, default 4 fps, atlas writer + .ppda reader, output schema 4

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
bcc9bf421fe880031eb2dc0cf3207e3c24cffbc8
Parent
b5aeaddb
10 files changed +1054 −38
src/atlas.rs added +890 −0
@@ -0,0 +1,890 @@
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 }
@@ -0,0 +1,890 @@
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.
14use 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};
21use serde::{Deserialize, Serialize};
22use 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.
31pub 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.
34pub const MAX_ATLAS_DIM: u32 = 16384;
35/// Bounded `.ppda` size on read.
36pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;
37/// Bounded encoded atlas PNG size on read.
38pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;
39/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.
40pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
41/// Map order inside the `.ppda` and atlas production.
42pub const ATLAS_MAPS: [MapKind; 4] = [
43 MapKind::Diffuse,
44 MapKind::Normal,
45 MapKind::Depth,
46 MapKind::Specular,
47];
48const MAX_CLIPS: usize = 4096;
49const MAX_CLIP_NAME: usize = 256;
50
51pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {
52 format!("{sex}-{set}-{}-atlas.png", kind.token())
53}
54
55pub 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)]
61pub 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
70impl 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)]
137pub 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)]
156pub struct PpdaProject {
157 pub id: String,
158 pub name: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub 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)]
177pub 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)]
186pub 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)]
197pub 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)]
204pub 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)]
215pub 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
222impl 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
237struct HashingWriter<W: Write> {
238 inner: W,
239 hasher: Sha256,
240}
241
242impl<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.
254pub 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
269fn 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).
279fn 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`.
310fn 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.
369fn 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).
411pub 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.
501pub 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
543fn ident(msg: impl Into<String>) -> ExportError {
544 ExportError::Identity(msg.into())
545}
546
547fn 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)]
684pub struct AtlasSet {
685 dir: PathBuf,
686 pub ppda: Ppda,
687 pub layout: AtlasLayout,
688}
689
690impl 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)]
815pub struct LoadedAtlas {
816 pub set: AtlasSet,
817 maps: BTreeMap<&'static str, MapImage>,
818}
819
820impl 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}
src/editor.rs +1 −1
@@ -70,7 +70,7 @@ impl EditorApp {
70 70 applied_shell: None,
71 71 width_text: "128".to_string(),
72 72 height_text: "128".to_string(),
73 fps_text: "12".to_string(),
73 fps_text: crate::project::DEFAULT_FPS.to_string(),
74 74 allow_close: false,
75 75 dialog_pending: false,
76 76 equipment_part: 0,
@@ -70,7 +70,7 @@ impl EditorApp {
70 applied_shell: None,
70 applied_shell: None,
71 width_text: "128".to_string(),
71 width_text: "128".to_string(),
72 height_text: "128".to_string(),
72 height_text: "128".to_string(),
73 fps_text: "12".to_string(),
73 fps_text: crate::project::DEFAULT_FPS.to_string(),
74 allow_close: false,
74 allow_close: false,
75 dialog_pending: false,
75 dialog_pending: false,
76 equipment_part: 0,
76 equipment_part: 0,
src/export_set.rs +86 −7
@@ -18,7 +18,7 @@ use std::{
18 18 };
19 19
20 20 /// Output metadata schema written by production and required by this reader.
21 pub const OUTPUT_META_VERSION: u32 = 3;
21 pub const OUTPUT_META_VERSION: u32 = 4;
22 22 pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
23 23 /// Hard ceiling for a single published PNG on disk (encoded).
24 24 pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
@@ -159,6 +159,8 @@ pub struct RenderManifest {
159 159 pub near: f32,
160 160 pub far: f32,
161 161 pub camera_distance: f32,
162 pub pitches: Vec<i32>,
163 pub yaws: Vec<u32>,
162 164 pub pass_order: Vec<String>,
163 165 pub saved_maps: Vec<String>,
164 166 pub clips: Vec<ClipRecord>,
@@ -200,6 +202,23 @@ pub struct CompleteManifest {
200 202 pub passes: u32,
201 203 pub png_files: u32,
202 204 pub png_sha256: BTreeMap<String, String>,
205 /// Per-map atlases + `.ppda` written before this marker (t955u).
206 pub atlas: AtlasRef,
207 }
208
209 #[derive(Debug, Clone, Deserialize)]
210 #[serde(deny_unknown_fields)]
211 pub struct AtlasRef {
212 pub ppda: String,
213 pub ppda_sha256: String,
214 pub maps: BTreeMap<String, AtlasMapRef>,
215 }
216
217 #[derive(Debug, Clone, Deserialize)]
218 #[serde(deny_unknown_fields)]
219 pub struct AtlasMapRef {
220 pub file: String,
221 pub sha256: String,
203 222 }
204 223
205 224 #[derive(Debug, Clone)]
@@ -266,6 +285,17 @@ impl ExportRun {
266 285 )));
267 286 }
268 287 }
288 // The `.ppda` is small; verify it here. Atlas PNGs are verified by
289 // `atlas::AtlasSet`, the consumer reader.
290 let ppda = resolve_inside(&root, &complete.atlas.ppda)?;
291 verify_regular_file(&ppda, &root)?;
292 let bytes = read_bounded_file(&ppda, crate::atlas::MAX_PPDA_BYTES)?;
293 if digest::sha256_hex(&bytes) != complete.atlas.ppda_sha256 {
294 return Err(ExportError::Corrupt(format!(
295 "checksum mismatch voor '{}'",
296 complete.atlas.ppda
297 )));
298 }
269 299 Ok(Self {
270 300 root,
271 301 render,
@@ -402,6 +432,16 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
402 432 "camera_distance wijkt af van contract".into(),
403 433 ));
404 434 }
435 if m.pitches != crate::preview::PRODUCTION_PITCHES {
436 return Err(ExportError::Identity(
437 "pitches wijken af van −60,−30,0,+30,+60".into(),
438 ));
439 }
440 if m.yaws != crate::production::production_yaws() {
441 return Err(ExportError::Identity(
442 "yaws wijken af van 0…330 stap 30".into(),
443 ));
444 }
405 445 if m.pass_order != PASSES {
406 446 return Err(ExportError::Identity(
407 447 "pass_order wijkt af van contract".into(),
@@ -550,6 +590,24 @@ fn identities_match(
550 590 "near/far mismatch",
551 591 )?;
552 592 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
593 let atlas = &complete.atlas;
594 check(
595 atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)
596 && is_sha256_hex(&atlas.ppda_sha256),
597 "atlas.ppda naam/sha256 ongeldig",
598 )?;
599 check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;
600 for kind in crate::atlas::ATLAS_MAPS {
601 let entry = atlas
602 .maps
603 .get(kind.token())
604 .ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;
605 check(
606 entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)
607 && is_sha256_hex(&entry.sha256),
608 "atlas-bestandsnaam/sha256 ongeldig",
609 )?;
610 }
553 611 check(
554 612 id.clips.len() == render.clips.len(),
555 613 "clips length mismatch",
@@ -571,13 +629,34 @@ fn identities_match(
571 629 Ok(())
572 630 }
573 631
574 fn decode_map(
632 pub(crate) fn decode_map(
575 633 bytes: &[u8],
576 634 kind: MapKind,
577 635 expect_w: u32,
578 636 expect_h: u32,
579 637 ) -> Result<MapImage, ExportError> {
580 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
638 decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)
639 }
640
641 /// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
642 /// the published pixel contract. Atlases use the same rules with a larger cap.
643 pub(crate) fn decode_map_sized(
644 bytes: &[u8],
645 kind: MapKind,
646 expect_w: u32,
647 expect_h: u32,
648 max_dim: u32,
649 ) -> Result<MapImage, ExportError> {
650 if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
651 return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
652 }
653 let decoded = expect_w as usize * expect_h as usize * 4;
654 let mut decoder = png::Decoder::new_with_limits(
655 std::io::Cursor::new(bytes),
656 png::Limits {
657 bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),
658 },
659 );
581 660 decoder.set_transformations(png::Transformations::IDENTITY);
582 661 let mut reader = decoder
583 662 .read_info()
@@ -589,8 +668,8 @@ fn decode_map(
589 668 info.width, info.height
590 669 )));
591 670 }
592 if info.width > MAX_TARGET || info.height > MAX_TARGET {
593 return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));
671 if info.width > max_dim || info.height > max_dim {
672 return Err(ExportError::Resource("PNG groter dan limiet".into()));
594 673 }
595 674 match kind {
596 675 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
@@ -724,7 +803,7 @@ fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
724 803 read_bounded_file(&path, MAX_MANIFEST_BYTES)
725 804 }
726 805
727 fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
806 pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
728 807 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
729 808 if meta.len() > max {
730 809 return Err(ExportError::Resource(format!(
@@ -753,7 +832,7 @@ fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
753 832 Ok(path)
754 833 }
755 834
756 fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
835 pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
757 836 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
758 837 if meta.file_type().is_symlink() {
759 838 return Err(ExportError::Traversal(format!(
@@ -18,7 +18,7 @@ use std::{
18};
18};
19
19
20/// Output metadata schema written by production and required by this reader.
20/// Output metadata schema written by production and required by this reader.
21pub const OUTPUT_META_VERSION: u32 = 3;
21pub const OUTPUT_META_VERSION: u32 = 4;
22pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
22pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
23/// Hard ceiling for a single published PNG on disk (encoded).
23/// Hard ceiling for a single published PNG on disk (encoded).
24pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
24pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
@@ -159,6 +159,8 @@ pub struct RenderManifest {
159 pub near: f32,
159 pub near: f32,
160 pub far: f32,
160 pub far: f32,
161 pub camera_distance: f32,
161 pub camera_distance: f32,
162 pub pitches: Vec<i32>,
163 pub yaws: Vec<u32>,
162 pub pass_order: Vec<String>,
164 pub pass_order: Vec<String>,
163 pub saved_maps: Vec<String>,
165 pub saved_maps: Vec<String>,
164 pub clips: Vec<ClipRecord>,
166 pub clips: Vec<ClipRecord>,
@@ -200,6 +202,23 @@ pub struct CompleteManifest {
200 pub passes: u32,
202 pub passes: u32,
201 pub png_files: u32,
203 pub png_files: u32,
202 pub png_sha256: BTreeMap<String, String>,
204 pub png_sha256: BTreeMap<String, String>,
205 /// Per-map atlases + `.ppda` written before this marker (t955u).
206 pub atlas: AtlasRef,
207}
208
209#[derive(Debug, Clone, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct AtlasRef {
212 pub ppda: String,
213 pub ppda_sha256: String,
214 pub maps: BTreeMap<String, AtlasMapRef>,
215}
216
217#[derive(Debug, Clone, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct AtlasMapRef {
220 pub file: String,
221 pub sha256: String,
203}
222}
204
223
205#[derive(Debug, Clone)]
224#[derive(Debug, Clone)]
@@ -266,6 +285,17 @@ impl ExportRun {
266 )));
285 )));
267 }
286 }
268 }
287 }
288 // The `.ppda` is small; verify it here. Atlas PNGs are verified by
289 // `atlas::AtlasSet`, the consumer reader.
290 let ppda = resolve_inside(&root, &complete.atlas.ppda)?;
291 verify_regular_file(&ppda, &root)?;
292 let bytes = read_bounded_file(&ppda, crate::atlas::MAX_PPDA_BYTES)?;
293 if digest::sha256_hex(&bytes) != complete.atlas.ppda_sha256 {
294 return Err(ExportError::Corrupt(format!(
295 "checksum mismatch voor '{}'",
296 complete.atlas.ppda
297 )));
298 }
269 Ok(Self {
299 Ok(Self {
270 root,
300 root,
271 render,
301 render,
@@ -402,6 +432,16 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
402 "camera_distance wijkt af van contract".into(),
432 "camera_distance wijkt af van contract".into(),
403 ));
433 ));
404 }
434 }
435 if m.pitches != crate::preview::PRODUCTION_PITCHES {
436 return Err(ExportError::Identity(
437 "pitches wijken af van −60,−30,0,+30,+60".into(),
438 ));
439 }
440 if m.yaws != crate::production::production_yaws() {
441 return Err(ExportError::Identity(
442 "yaws wijken af van 0…330 stap 30".into(),
443 ));
444 }
405 if m.pass_order != PASSES {
445 if m.pass_order != PASSES {
406 return Err(ExportError::Identity(
446 return Err(ExportError::Identity(
407 "pass_order wijkt af van contract".into(),
447 "pass_order wijkt af van contract".into(),
@@ -550,6 +590,24 @@ fn identities_match(
550 "near/far mismatch",
590 "near/far mismatch",
551 )?;
591 )?;
552 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
592 check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
593 let atlas = &complete.atlas;
594 check(
595 atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)
596 && is_sha256_hex(&atlas.ppda_sha256),
597 "atlas.ppda naam/sha256 ongeldig",
598 )?;
599 check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;
600 for kind in crate::atlas::ATLAS_MAPS {
601 let entry = atlas
602 .maps
603 .get(kind.token())
604 .ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;
605 check(
606 entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)
607 && is_sha256_hex(&entry.sha256),
608 "atlas-bestandsnaam/sha256 ongeldig",
609 )?;
610 }
553 check(
611 check(
554 id.clips.len() == render.clips.len(),
612 id.clips.len() == render.clips.len(),
555 "clips length mismatch",
613 "clips length mismatch",
@@ -571,13 +629,34 @@ fn identities_match(
571 Ok(())
629 Ok(())
572}
630}
573
631
574fn decode_map(
632pub(crate) fn decode_map(
575 bytes: &[u8],
633 bytes: &[u8],
576 kind: MapKind,
634 kind: MapKind,
577 expect_w: u32,
635 expect_w: u32,
578 expect_h: u32,
636 expect_h: u32,
579) -> Result<MapImage, ExportError> {
637) -> Result<MapImage, ExportError> {
580 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
638 decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)
639}
640
641/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
642/// the published pixel contract. Atlases use the same rules with a larger cap.
643pub(crate) fn decode_map_sized(
644 bytes: &[u8],
645 kind: MapKind,
646 expect_w: u32,
647 expect_h: u32,
648 max_dim: u32,
649) -> Result<MapImage, ExportError> {
650 if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
651 return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
652 }
653 let decoded = expect_w as usize * expect_h as usize * 4;
654 let mut decoder = png::Decoder::new_with_limits(
655 std::io::Cursor::new(bytes),
656 png::Limits {
657 bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),
658 },
659 );
581 decoder.set_transformations(png::Transformations::IDENTITY);
660 decoder.set_transformations(png::Transformations::IDENTITY);
582 let mut reader = decoder
661 let mut reader = decoder
583 .read_info()
662 .read_info()
@@ -589,8 +668,8 @@ fn decode_map(
589 info.width, info.height
668 info.width, info.height
590 )));
669 )));
591 }
670 }
592 if info.width > MAX_TARGET || info.height > MAX_TARGET {
671 if info.width > max_dim || info.height > max_dim {
593 return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));
672 return Err(ExportError::Resource("PNG groter dan limiet".into()));
594 }
673 }
595 match kind {
674 match kind {
596 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
675 MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
@@ -724,7 +803,7 @@ fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
724 read_bounded_file(&path, MAX_MANIFEST_BYTES)
803 read_bounded_file(&path, MAX_MANIFEST_BYTES)
725}
804}
726
805
727fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
806pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
728 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
807 let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
729 if meta.len() > max {
808 if meta.len() > max {
730 return Err(ExportError::Resource(format!(
809 return Err(ExportError::Resource(format!(
@@ -753,7 +832,7 @@ fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
753 Ok(path)
832 Ok(path)
754}
833}
755
834
756fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
835pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
757 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
836 let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
758 if meta.file_type().is_symlink() {
837 if meta.file_type().is_symlink() {
759 return Err(ExportError::Traversal(format!(
838 return Err(ExportError::Traversal(format!(
src/lib.rs +1 −0
@@ -7,6 +7,7 @@
7 7 //! Completed export runs are opened through `export_set` with checksum/identity
8 8 //! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9 9
10 pub mod atlas;
10 11 pub mod camera;
11 12 pub mod compositor;
12 13 pub mod dialogs;
@@ -7,6 +7,7 @@
7//! Completed export runs are opened through `export_set` with checksum/identity
7//! Completed export runs are opened through `export_set` with checksum/identity
8//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
8//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9
9
10pub mod atlas;
10pub mod camera;
11pub mod camera;
11pub mod compositor;
12pub mod compositor;
12pub mod dialogs;
13pub mod dialogs;
src/preview.rs +7 −3
@@ -1,13 +1,17 @@
1 1 //! CPU skeleton sampling and orbit camera for the live glTF preview.
2 2 //!
3 3 //! This is not the paperdoll export. The export camera is orthographic and
4 //! still out of scope. The interactive camera can snap to the same three
4 //! still out of scope. The interactive camera can snap to the same five
5 5 //! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6 6 //! so yaw 0 places the camera on -Z, looking at the body.
7 7
8 8 use std::path::Path;
9 9
10 pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
10 pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11 /// Production pitches in integer degrees, atlas column order (t955u).
12 pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
13 /// Production views per sample: five pitches × twelve yaws.
14 pub const PRODUCTION_VIEWS: u32 = 60;
11 15 pub const PAPERDOLL_YAW_COUNT: usize = 12;
12 16 pub const MAX_JOINTS: usize = 128;
13 17
@@ -61,7 +65,7 @@ impl PreviewControls {
61 65 }
62 66
63 67 pub fn snap_pitch_row(&mut self, row: i32) {
64 let row = row.clamp(0, 2) as usize;
68 let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;
65 69 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66 70 }
67 71
@@ -1,13 +1,17 @@
1//! CPU skeleton sampling and orbit camera for the live glTF preview.
1//! CPU skeleton sampling and orbit camera for the live glTF preview.
2//!
2//!
3//! This is not the paperdoll export. The export camera is orthographic and
3//! This is not the paperdoll export. The export camera is orthographic and
4//! still out of scope. The interactive camera can snap to the same three
4//! still out of scope. The interactive camera can snap to the same five
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6//! so yaw 0 places the camera on -Z, looking at the body.
6//! so yaw 0 places the camera on -Z, looking at the body.
7
7
8use std::path::Path;
8use std::path::Path;
9
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
10pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11/// Production pitches in integer degrees, atlas column order (t955u).
12pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
13/// Production views per sample: five pitches × twelve yaws.
14pub const PRODUCTION_VIEWS: u32 = 60;
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
15pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
16pub const MAX_JOINTS: usize = 128;
13
17
@@ -61,7 +65,7 @@ impl PreviewControls {
61 }
65 }
62
66
63 pub fn snap_pitch_row(&mut self, row: i32) {
67 pub fn snap_pitch_row(&mut self, row: i32) {
64 let row = row.clamp(0, 2) as usize;
68 let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;
65 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
69 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66 }
70 }
67
71
src/preview2d.rs +7 −4
@@ -28,6 +28,9 @@ pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
28 28 pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
29 29 pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
30 30
31 /// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
32 pub const ZERO_PITCH_ROW: i32 = 2;
33
31 34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 35 pub enum LightAxis {
33 36 Azimuth,
@@ -217,7 +220,7 @@ pub fn pitch_row_from_deg(pitch: i32) -> i32 {
217 220 PAPERDOLL_PITCHES_DEG
218 221 .iter()
219 222 .position(|p| *p as i32 == pitch)
220 .unwrap_or(1) as i32
223 .unwrap_or(ZERO_PITCH_ROW as usize) as i32
221 224 }
222 225
223 226 /// View-space light direction toward the light.
@@ -290,12 +293,12 @@ impl Default for Preview2dState {
290 293 clip_token: String::new(),
291 294 clip_frames: 1,
292 295 clip_duration: 0.0,
293 fps: 12,
296 fps: crate::project::DEFAULT_FPS,
294 297 playback_speed: 1.0,
295 298 playing: true,
296 299 elapsed_secs: 0.0,
297 300 yaw_index: 0,
298 pitch_row: 1,
301 pitch_row: ZERO_PITCH_ROW,
299 302 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
300 303 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
301 304 hold_azimuth: None,
@@ -355,7 +358,7 @@ impl Preview2dState {
355 358 }
356 359
357 360 pub fn step_pitch(&mut self, delta: i32) {
358 self.pitch_row = (self.pitch_row + delta).clamp(0, 2);
361 self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);
359 362 // Animation time intentionally preserved; pitch is clamped, not cyclic.
360 363 }
361 364
@@ -28,6 +28,9 @@ pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
28pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
28pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
29pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
29pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
30
30
31/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
32pub const ZERO_PITCH_ROW: i32 = 2;
33
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LightAxis {
35pub enum LightAxis {
33 Azimuth,
36 Azimuth,
@@ -217,7 +220,7 @@ pub fn pitch_row_from_deg(pitch: i32) -> i32 {
217 PAPERDOLL_PITCHES_DEG
220 PAPERDOLL_PITCHES_DEG
218 .iter()
221 .iter()
219 .position(|p| *p as i32 == pitch)
222 .position(|p| *p as i32 == pitch)
220 .unwrap_or(1) as i32
223 .unwrap_or(ZERO_PITCH_ROW as usize) as i32
221}
224}
222
225
223/// View-space light direction toward the light.
226/// View-space light direction toward the light.
@@ -290,12 +293,12 @@ impl Default for Preview2dState {
290 clip_token: String::new(),
293 clip_token: String::new(),
291 clip_frames: 1,
294 clip_frames: 1,
292 clip_duration: 0.0,
295 clip_duration: 0.0,
293 fps: 12,
296 fps: crate::project::DEFAULT_FPS,
294 playback_speed: 1.0,
297 playback_speed: 1.0,
295 playing: true,
298 playing: true,
296 elapsed_secs: 0.0,
299 elapsed_secs: 0.0,
297 yaw_index: 0,
300 yaw_index: 0,
298 pitch_row: 1,
301 pitch_row: ZERO_PITCH_ROW,
299 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
302 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
300 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
303 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
301 hold_azimuth: None,
304 hold_azimuth: None,
@@ -355,7 +358,7 @@ impl Preview2dState {
355 }
358 }
356
359
357 pub fn step_pitch(&mut self, delta: i32) {
360 pub fn step_pitch(&mut self, delta: i32) {
358 self.pitch_row = (self.pitch_row + delta).clamp(0, 2);
361 self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);
359 // Animation time intentionally preserved; pitch is clamped, not cyclic.
362 // Animation time intentionally preserved; pitch is clamped, not cyclic.
360 }
363 }
361
364
src/preview_gl.rs +1 −1
@@ -137,7 +137,7 @@ impl Viewport {
137 137 ));
138 138 });
139 139 ui.label(
140 "Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
140 "Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de vijf pitches en de twaalf yaws.",
141 141 );
142 142 let available = ui.available_rect_before_wrap();
143 143 let rect = egui::Rect::from_min_size(
@@ -137,7 +137,7 @@ impl Viewport {
137 ));
137 ));
138 });
138 });
139 ui.label(
139 ui.label(
140 "Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
140 "Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de vijf pitches en de twaalf yaws.",
141 );
141 );
142 let available = ui.available_rect_before_wrap();
142 let available = ui.available_rect_before_wrap();
143 let rect = egui::Rect::from_min_size(
143 let rect = egui::Rect::from_min_size(
src/production.rs +56 −20
@@ -146,7 +146,9 @@ pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
146 146 .iter()
147 147 .try_fold(0u32, |n, c| n.checked_add(c.frames))
148 148 .ok_or("te veel frames")?;
149 let images = frames.checked_mul(36).ok_or("te veel beelden")?;
149 let images = frames
150 .checked_mul(preview::PRODUCTION_VIEWS)
151 .ok_or("te veel beelden")?;
150 152 let passes = images.checked_mul(6).ok_or("te veel passes")?;
151 153 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
152 154 if files > 500000 {
@@ -169,18 +171,25 @@ pub struct Sample {
169 171 pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
170 172 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
171 173 (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,
174 preview::PRODUCTION_PITCHES
175 .into_iter()
176 .flat_map(move |pitch| {
177 (0..12).map(move |y| Sample {
178 clip,
179 frame,
180 pitch,
181 yaw: y * 30,
182 })
178 183 })
179 })
180 184 })
181 185 })
182 186 }
183 187
188 /// Production yaw values in degrees, atlas column order within one pitch.
189 pub fn production_yaws() -> [u32; 12] {
190 std::array::from_fn(|i| i as u32 * 30)
191 }
192
184 193 pub fn clip_token(index: usize, name: &str) -> String {
185 194 let slug: String = name
186 195 .chars()
@@ -210,7 +219,7 @@ pub fn output_path(
210 219 if !["male", "female"].contains(&sex)
211 220 || !(set == "body" || equipment::valid_name(set))
212 221 || !equipment::valid_name(clip)
213 || ![-30, 0, 30].contains(&pitch)
222 || !preview::PRODUCTION_PITCHES.contains(&pitch)
214 223 || yaw >= 360
215 224 || !yaw.is_multiple_of(30)
216 225 || !["diffuse", "normal", "depth", "specular"].contains(&map)
@@ -1046,7 +1055,7 @@ fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1046 1055 }
1047 1056 Ok(path)
1048 1057 }
1049 fn atomic_file(
1058 pub(crate) fn atomic_file(
1050 1059 root: &Path,
1051 1060 relative: &Path,
1052 1061 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
@@ -1337,6 +1346,9 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1337 1346 .map(|p| p.name.as_str())
1338 1347 .unwrap_or("body")
1339 1348 .to_string();
1349 // Refuse up front when the per-map atlas would exceed the PNG/GPU limit,
1350 // instead of rendering for hours and failing (or splitting) at the end.
1351 crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;
1340 1352 if job.project_id.is_empty() || job.project_id.len() > 128 {
1341 1353 return Err("project.id ongeldig voor export-identiteit".into());
1342 1354 }
@@ -1389,7 +1401,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1389 1401 "normal_space": "view (+Y up), PNG rows top-down",
1390 1402 "camera_distance": 10,
1391 1403 "yaw_zero": "camera on -Z",
1392 "pitches": [-30, 0, 30],
1404 "pitches": preview::PRODUCTION_PITCHES,
1405 "yaws": production_yaws(),
1393 1406 "yaw_step": 30,
1394 1407 "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 1408 "alpha": "straight, opaque/cutout, coverage resolve",
@@ -1641,17 +1654,39 @@ fn execute_target(
1641 1654 });
1642 1655 }
1643 1656 }
1644 Ok(())
1657 if png_sha256.len() as u32 != plan.files {
1658 return Err(format!(
1659 "checksumtelling {} wijkt af van geplande {} PNGs",
1660 png_sha256.len(),
1661 plan.files
1662 ));
1663 }
1664 // Atlases + .ppda only after every loose map is published and verified
1665 // (also after a resume); complete.json stays the final marker.
1666 crate::atlas::write_atlases(
1667 &crate::atlas::AtlasJob {
1668 root: &root,
1669 render_doc: &render_doc,
1670 plan: &plan,
1671 sex: &job.sex,
1672 set: &set,
1673 cell_width: job.target_width,
1674 cell_height: job.target_height,
1675 png_sha256: &png_sha256,
1676 attempt: session.attempt,
1677 cancel,
1678 },
1679 &mut |label| {
1680 emit(RenderEvent::Step {
1681 index: completed,
1682 total: plan.passes,
1683 label,
1684 })
1685 },
1686 )
1645 1687 })();
1646 1688 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 }
1689 Ok(atlas) => {
1655 1690 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1656 1691 write_json(
1657 1692 &root,
@@ -1663,6 +1698,7 @@ fn execute_target(
1663 1698 "passes": completed,
1664 1699 "png_files": plan.files,
1665 1700 "png_sha256": png_sha256,
1701 "atlas": atlas.to_json(),
1666 1702 }),
1667 1703 )?;
1668 1704 emit(RenderEvent::Finished {
@@ -146,7 +146,9 @@ pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
146 .iter()
146 .iter()
147 .try_fold(0u32, |n, c| n.checked_add(c.frames))
147 .try_fold(0u32, |n, c| n.checked_add(c.frames))
148 .ok_or("te veel frames")?;
148 .ok_or("te veel frames")?;
149 let images = frames.checked_mul(36).ok_or("te veel beelden")?;
149 let images = frames
150 .checked_mul(preview::PRODUCTION_VIEWS)
151 .ok_or("te veel beelden")?;
150 let passes = images.checked_mul(6).ok_or("te veel passes")?;
152 let passes = images.checked_mul(6).ok_or("te veel passes")?;
151 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
153 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
152 if files > 500000 {
154 if files > 500000 {
@@ -169,18 +171,25 @@ pub struct Sample {
169pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
171pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
170 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
172 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
171 (0..c.frames).flat_map(move |frame| {
173 (0..c.frames).flat_map(move |frame| {
172 [-30, 0, 30].into_iter().flat_map(move |pitch| {
174 preview::PRODUCTION_PITCHES
173 (0..12).map(move |y| Sample {
175 .into_iter()
174 clip,
176 .flat_map(move |pitch| {
175 frame,
177 (0..12).map(move |y| Sample {
176 pitch,
178 clip,
177 yaw: y * 30,
179 frame,
180 pitch,
181 yaw: y * 30,
182 })
178 })
183 })
179 })
180 })
184 })
181 })
185 })
182}
186}
183
187
188/// Production yaw values in degrees, atlas column order within one pitch.
189pub fn production_yaws() -> [u32; 12] {
190 std::array::from_fn(|i| i as u32 * 30)
191}
192
184pub fn clip_token(index: usize, name: &str) -> String {
193pub fn clip_token(index: usize, name: &str) -> String {
185 let slug: String = name
194 let slug: String = name
186 .chars()
195 .chars()
@@ -210,7 +219,7 @@ pub fn output_path(
210 if !["male", "female"].contains(&sex)
219 if !["male", "female"].contains(&sex)
211 || !(set == "body" || equipment::valid_name(set))
220 || !(set == "body" || equipment::valid_name(set))
212 || !equipment::valid_name(clip)
221 || !equipment::valid_name(clip)
213 || ![-30, 0, 30].contains(&pitch)
222 || !preview::PRODUCTION_PITCHES.contains(&pitch)
214 || yaw >= 360
223 || yaw >= 360
215 || !yaw.is_multiple_of(30)
224 || !yaw.is_multiple_of(30)
216 || !["diffuse", "normal", "depth", "specular"].contains(&map)
225 || !["diffuse", "normal", "depth", "specular"].contains(&map)
@@ -1046,7 +1055,7 @@ fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1046 }
1055 }
1047 Ok(path)
1056 Ok(path)
1048}
1057}
1049fn atomic_file(
1058pub(crate) fn atomic_file(
1050 root: &Path,
1059 root: &Path,
1051 relative: &Path,
1060 relative: &Path,
1052 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1061 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
@@ -1337,6 +1346,9 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1337 .map(|p| p.name.as_str())
1346 .map(|p| p.name.as_str())
1338 .unwrap_or("body")
1347 .unwrap_or("body")
1339 .to_string();
1348 .to_string();
1349 // Refuse up front when the per-map atlas would exceed the PNG/GPU limit,
1350 // instead of rendering for hours and failing (or splitting) at the end.
1351 crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;
1340 if job.project_id.is_empty() || job.project_id.len() > 128 {
1352 if job.project_id.is_empty() || job.project_id.len() > 128 {
1341 return Err("project.id ongeldig voor export-identiteit".into());
1353 return Err("project.id ongeldig voor export-identiteit".into());
1342 }
1354 }
@@ -1389,7 +1401,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1389 "normal_space": "view (+Y up), PNG rows top-down",
1401 "normal_space": "view (+Y up), PNG rows top-down",
1390 "camera_distance": 10,
1402 "camera_distance": 10,
1391 "yaw_zero": "camera on -Z",
1403 "yaw_zero": "camera on -Z",
1392 "pitches": [-30, 0, 30],
1404 "pitches": preview::PRODUCTION_PITCHES,
1405 "yaws": production_yaws(),
1393 "yaw_step": 30,
1406 "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",
1407 "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",
1408 "alpha": "straight, opaque/cutout, coverage resolve",
@@ -1641,17 +1654,39 @@ fn execute_target(
1641 });
1654 });
1642 }
1655 }
1643 }
1656 }
1644 Ok(())
1657 if png_sha256.len() as u32 != plan.files {
1658 return Err(format!(
1659 "checksumtelling {} wijkt af van geplande {} PNGs",
1660 png_sha256.len(),
1661 plan.files
1662 ));
1663 }
1664 // Atlases + .ppda only after every loose map is published and verified
1665 // (also after a resume); complete.json stays the final marker.
1666 crate::atlas::write_atlases(
1667 &crate::atlas::AtlasJob {
1668 root: &root,
1669 render_doc: &render_doc,
1670 plan: &plan,
1671 sex: &job.sex,
1672 set: &set,
1673 cell_width: job.target_width,
1674 cell_height: job.target_height,
1675 png_sha256: &png_sha256,
1676 attempt: session.attempt,
1677 cancel,
1678 },
1679 &mut |label| {
1680 emit(RenderEvent::Step {
1681 index: completed,
1682 total: plan.passes,
1683 label,
1684 })
1685 },
1686 )
1645 })();
1687 })();
1646 match result {
1688 match result {
1647 Ok(()) => {
1689 Ok(atlas) => {
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)?;
1690 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1656 write_json(
1691 write_json(
1657 &root,
1692 &root,
@@ -1663,6 +1698,7 @@ fn execute_target(
1663 "passes": completed,
1698 "passes": completed,
1664 "png_files": plan.files,
1699 "png_files": plan.files,
1665 "png_sha256": png_sha256,
1700 "png_sha256": png_sha256,
1701 "atlas": atlas.to_json(),
1666 }),
1702 }),
1667 )?;
1703 )?;
1668 emit(RenderEvent::Finished {
1704 emit(RenderEvent::Finished {
src/project.rs +4 −1
@@ -75,12 +75,15 @@ impl Default for RenderSettings {
75 75 Self {
76 76 target_width: 128,
77 77 target_height: 128,
78 fps: 12,
78 fps: DEFAULT_FPS,
79 79 framing: [CameraFraming::default(); 2],
80 80 }
81 81 }
82 82 }
83 83
84 /// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
85 pub const DEFAULT_FPS: u32 = 4;
86
84 87 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85 88 #[serde(deny_unknown_fields)]
86 89 pub struct ProjectFile {
@@ -75,12 +75,15 @@ impl Default for RenderSettings {
75 Self {
75 Self {
76 target_width: 128,
76 target_width: 128,
77 target_height: 128,
77 target_height: 128,
78 fps: 12,
78 fps: DEFAULT_FPS,
79 framing: [CameraFraming::default(); 2],
79 framing: [CameraFraming::default(); 2],
80 }
80 }
81 }
81 }
82}
82}
83
83
84/// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
85pub const DEFAULT_FPS: u32 = 4;
86
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
88#[serde(deny_unknown_fields)]
86pub struct ProjectFile {
89pub struct ProjectFile {
src/resume.rs +1 −1
@@ -153,7 +153,7 @@ pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, boo
153 153 }
154 154
155 155 /// Move a bad file/link aside inside the run without deleting or clobbering anything.
156 fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
156 pub(crate) fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
157 157 let path = root.join(rel);
158 158 let name = path
159 159 .file_name()
@@ -153,7 +153,7 @@ pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, boo
153}
153}
154
154
155/// Move a bad file/link aside inside the run without deleting or clobbering anything.
155/// Move a bad file/link aside inside the run without deleting or clobbering anything.
156fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
156pub(crate) fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
157 let path = root.join(rel);
157 let path = root.join(rel);
158 let name = path
158 let name = path
159 .file_name()
159 .file_name()