WIP: 60 views, default 4 fps, atlas writer + .ppda reader, output schema 4
@@ -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 manifest2
//! (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 the7
//! run directory. Cells are exactly the target resolution, without padding or8
//! 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 identical10
//! 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 are33
/// 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 == 073
|| cell_height == 074
|| cell_width > MAX_TARGET75
|| cell_height > MAX_TARGET76
{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 = plan104
.clips105
.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 for143
/// 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> = self225
.maps226
.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.clips271
.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 or278
/// 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 = job290
.png_sha256291
.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 = writer332
.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
total347
));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 already367
/// exists (crash after publication during an earlier attempt) it is kept when368
/// 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 regular387
&& 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_digests436
.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_doc448
.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_doc479
.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_version552
)));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.settype564
)));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 = p603
.clips604
.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() > 64617
|| !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.token623
)));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.yaw643
)));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 = p656
.maps657
.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.file663
)));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.height676
)));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.dir694
}695
696
fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> {697
let dir = path698
.parent()699
.ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?;700
let dir = dir701
.canonicalize()702
.map_err(|e| ExportError::Io(e.to_string()))?;703
let file_name = path704
.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.file728
)));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 = decoder742
.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.width751
|| info.height != layout.height752
|| info.color_type != color753
|| info.bit_depth != depth754
{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) with786
/// 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.ppda823
}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 = self834
.set835
.ppda836
.clips837
.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
frame844
.views845
.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
}@@ -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,@@ -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.21
pub const OUTPUT_META_VERSION: u32 = 3;21
pub const OUTPUT_META_VERSION: u32 = 4;22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;22
pub 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).24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;24
pub 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)]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
#[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 by289
// `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.ppda297
)));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 = atlas602
.maps603
.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
574
fn decode_map(632
pub(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`) under642
/// 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
decoder.set_transformations(png::Transformations::IDENTITY);660
decoder.set_transformations(png::Transformations::IDENTITY);582
let mut reader = decoder661
let mut reader = decoder583
.read_info()662
.read_info()@@ -589,8 +668,8 @@ fn decode_map(589
info.width, info.height668
info.width, info.height590
)));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
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
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
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
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!(@@ -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/identity7
//! Completed export runs are opened through `export_set` with checksum/identity8
//! 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
10
pub mod atlas;10
pub mod camera;11
pub mod camera;11
pub mod compositor;12
pub mod compositor;12
pub mod dialogs;13
pub mod dialogs;@@ -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 and3
//! This is not the paperdoll export. The export camera is orthographic and4
//! still out of scope. The interactive camera can snap to the same three4
//! still out of scope. The interactive camera can snap to the same five5
//! 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
8
use std::path::Path;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
pub const PAPERDOLL_YAW_COUNT: usize = 12;15
pub const PAPERDOLL_YAW_COUNT: usize = 12;12
pub const MAX_JOINTS: usize = 128;16
pub 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
@@ -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;28
pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;28
pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;29
pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;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
#[derive(Debug, Clone, Copy, PartialEq, Eq)]34
#[derive(Debug, Clone, Copy, PartialEq, Eq)]32
pub enum LightAxis {35
pub enum LightAxis {33
Azimuth,36
Azimuth,@@ -217,7 +220,7 @@ pub fn pitch_row_from_deg(pitch: i32) -> i32 {217
PAPERDOLL_PITCHES_DEG220
PAPERDOLL_PITCHES_DEG218
.iter()221
.iter()219
.position(|p| *p as i32 == pitch)222
.position(|p| *p as i32 == pitch)220
.unwrap_or(1) as i32223
.unwrap_or(ZERO_PITCH_ROW as usize) as i32221
}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
@@ -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(@@ -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 = frames150
.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 {169
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {171
pub 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_PITCHES173
(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.189
pub fn production_yaws() -> [u32; 12] {190
std::array::from_fn(|i| i as u32 * 30)191
}192
184
pub fn clip_token(index: usize, name: &str) -> String {193
pub fn clip_token(index: usize, name: &str) -> String {185
let slug: String = name194
let slug: String = name186
.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 >= 360223
|| yaw >= 360215
|| !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
}1049
fn atomic_file(1058
pub(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.files1662
));1663
}1664
// Atlases + .ppda only after every loose map is published and verified1665
// (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.files1653
));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 {@@ -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.85
pub 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)]86
pub struct ProjectFile {89
pub struct ProjectFile {@@ -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, boo153
}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.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
let path = root.join(rel);157
let path = root.join(rel);158
let name = path158
let name = path159
.file_name()159
.file_name()