Merge feature/export-set-identity: schema-3 export identity + safe map reader
Local merge only; parent publishes. Does not push.
@@ -57,13 +57,13 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla
57
57
58
58
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
59
59
60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen. De latere 2D-lichtcompositor en rim-effecten zijn nog niet gebouwd.
60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
61
61
62
62
```text
63
63
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
64
64
```
65
65
66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` beschrijft instellingen en telling; alleen `complete.json` betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run. De map-/camera-/pixelcontracten staan in `design.md`.
66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
67
67
68
68
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
69
69
@@ -57,13 +57,13 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla57
57
58
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.58
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.59
59
60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen. De latere 2D-lichtcompositor en rim-effecten zijn nog niet gebouwd.60
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.61
61
62
```text62
```text63
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png63
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png64
```64
```65
65
66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` beschrijft instellingen en telling; alleen `complete.json` betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run. De map-/camera-/pixelcontracten staan in `design.md`.66
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.67
67
68
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.68
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.69
69
@@ -124,7 +124,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
124
124
125
125
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
126
126
127
`render.json` (output metadata schema 2, separate from `.ppd` schema 3) records sex/set, target, FPS, highres scale, framing (with resolved `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.
127
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
128
129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. Resume and the 2D compositor remain later work; they will consume this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
128
130
129
131
## 8. Map contracts and rendering algorithm
130
132
@@ -178,7 +180,7 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
178
180
179
181
## 10. Rendered 2D preview
180
182
181
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation.
183
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI itself is not built yet; only the safe map reader/index API is.
182
184
183
185
Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
184
186
@@ -215,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
215
217
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
216
218
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
217
219
218
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
220
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
219
221
220
222
## 12. Incremental feature plan and acceptance gates
221
223
@@ -124,7 +124,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/124
124
125
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.125
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.126
126
127
`render.json` (output metadata schema 2, separate from `.ppd` schema 3) records sex/set, target, FPS, highres scale, framing (with resolved `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, conventions, source clip names/tokens/durations/frame counts, six-pass order, four saved maps and actual counts. Frame timestamps follow the recorded sampling equation. `complete.json` is published ONLY after all PNGs complete; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json`. Source-file checksums, per-image checksums, resumable batches and automatic cleanup are future work, not implemented manifest fields.127
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.128
129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. Resume and the 2D compositor remain later work; they will consume this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.128
130
129
## 8. Map contracts and rendering algorithm131
## 8. Map contracts and rendering algorithm130
132
@@ -178,7 +180,7 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s178
180
179
## 10. Rendered 2D preview181
## 10. Rendered 2D preview180
182
181
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation.183
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI itself is not built yet; only the safe map reader/index API is.182
184
183
Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.185
Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.184
186
@@ -215,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup215
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.217
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.216
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.218
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.217
219
218
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.220
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.219
221
220
## 12. Incremental feature plan and acceptance gates222
## 12. Incremental feature plan and acceptance gates221
223
@@ -85,6 +85,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
85
85
pivot_y_milli: Some(1000),
86
86
},
87
87
output: out.clone(),
88
project_id: "neck-probe".into(),
89
project_name: "Neck probe".into(),
90
body_model: production::SourceFingerprint {
91
path: "assets/body/test-body-male.glb".into(),
92
sha256: paperdoll_renderer::digest::sha256_hex(b"neck-probe-body"),
93
},
94
preset_sha256: None,
88
95
};
89
96
let old_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;
90
97
let old = unsafe { renderer.render(gl, &old_scene) }?;
@@ -85,6 +85,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {85
pivot_y_milli: Some(1000),85
pivot_y_milli: Some(1000),86
},86
},87
output: out.clone(),87
output: out.clone(),88
project_id: "neck-probe".into(),89
project_name: "Neck probe".into(),90
body_model: production::SourceFingerprint {91
path: "assets/body/test-body-male.glb".into(),92
sha256: paperdoll_renderer::digest::sha256_hex(b"neck-probe-body"),93
},94
preset_sha256: None,88
};95
};89
let old_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;96
let old_scene = production::scene(&job, "walk", 0.0, pitch, yaw, &mut cache)?;90
let old = unsafe { renderer.render(gl, &old_scene) }?;97
let old = unsafe { renderer.render(gl, &old_scene) }?;@@ -0,0 +1,242 @@
1
//! SHA-256 digests for export-set identity (no external digest crate).
2
#![allow(clippy::unreadable_literal)]
3
4
const K: [u32; 64] = [
5
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
7
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
8
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
9
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
10
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
11
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
12
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
13
];
14
15
#[derive(Clone)]
16
pub struct Sha256 {
17
state: [u32; 8],
18
buffer: [u8; 64],
19
buffer_len: usize,
20
bit_len: u64,
21
}
22
23
impl Default for Sha256 {
24
fn default() -> Self {
25
Self::new()
26
}
27
}
28
29
impl Sha256 {
30
pub fn new() -> Self {
31
Self {
32
state: [
33
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
34
0x5be0cd19,
35
],
36
buffer: [0; 64],
37
buffer_len: 0,
38
bit_len: 0,
39
}
40
}
41
42
pub fn update(&mut self, data: &[u8]) {
43
let mut offset = 0;
44
if self.buffer_len > 0 {
45
let need = 64 - self.buffer_len;
46
let take = need.min(data.len());
47
self.buffer[self.buffer_len..self.buffer_len + take].copy_from_slice(&data[..take]);
48
self.buffer_len += take;
49
offset += take;
50
if self.buffer_len == 64 {
51
let block = self.buffer;
52
self.compress(&block);
53
self.bit_len = self.bit_len.saturating_add(512);
54
self.buffer_len = 0;
55
}
56
}
57
while offset + 64 <= data.len() {
58
let mut block = [0u8; 64];
59
block.copy_from_slice(&data[offset..offset + 64]);
60
self.compress(&block);
61
self.bit_len = self.bit_len.saturating_add(512);
62
offset += 64;
63
}
64
let rest = &data[offset..];
65
if !rest.is_empty() {
66
self.buffer[..rest.len()].copy_from_slice(rest);
67
self.buffer_len = rest.len();
68
}
69
}
70
71
pub fn finalize(mut self) -> [u8; 32] {
72
let total_bits = self
73
.bit_len
74
.saturating_add((self.buffer_len as u64).saturating_mul(8));
75
self.buffer[self.buffer_len] = 0x80;
76
self.buffer_len += 1;
77
if self.buffer_len > 56 {
78
for b in &mut self.buffer[self.buffer_len..] {
79
*b = 0;
80
}
81
let block = self.buffer;
82
self.compress(&block);
83
self.buffer = [0; 64];
84
self.buffer_len = 0;
85
}
86
for b in &mut self.buffer[self.buffer_len..56] {
87
*b = 0;
88
}
89
self.buffer[56..64].copy_from_slice(&total_bits.to_be_bytes());
90
let block = self.buffer;
91
self.compress(&block);
92
let mut out = [0u8; 32];
93
for (i, word) in self.state.iter().enumerate() {
94
out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
95
}
96
out
97
}
98
99
fn compress(&mut self, block: &[u8; 64]) {
100
let mut w = [0u32; 64];
101
for (i, chunk) in block.chunks_exact(4).enumerate().take(16) {
102
w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
103
}
104
for i in 16..64 {
105
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
106
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
107
w[i] = w[i - 16]
108
.wrapping_add(s0)
109
.wrapping_add(w[i - 7])
110
.wrapping_add(s1);
111
}
112
let mut a = self.state[0];
113
let mut b = self.state[1];
114
let mut c = self.state[2];
115
let mut d = self.state[3];
116
let mut e = self.state[4];
117
let mut f = self.state[5];
118
let mut g = self.state[6];
119
let mut h = self.state[7];
120
for i in 0..64 {
121
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
122
let ch = (e & f) ^ ((!e) & g);
123
let temp1 = h
124
.wrapping_add(s1)
125
.wrapping_add(ch)
126
.wrapping_add(K[i])
127
.wrapping_add(w[i]);
128
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
129
let maj = (a & b) ^ (a & c) ^ (b & c);
130
let temp2 = s0.wrapping_add(maj);
131
h = g;
132
g = f;
133
f = e;
134
e = d.wrapping_add(temp1);
135
d = c;
136
c = b;
137
b = a;
138
a = temp1.wrapping_add(temp2);
139
}
140
self.state[0] = self.state[0].wrapping_add(a);
141
self.state[1] = self.state[1].wrapping_add(b);
142
self.state[2] = self.state[2].wrapping_add(c);
143
self.state[3] = self.state[3].wrapping_add(d);
144
self.state[4] = self.state[4].wrapping_add(e);
145
self.state[5] = self.state[5].wrapping_add(f);
146
self.state[6] = self.state[6].wrapping_add(g);
147
self.state[7] = self.state[7].wrapping_add(h);
148
}
149
}
150
151
pub fn sha256(data: &[u8]) -> [u8; 32] {
152
let mut h = Sha256::new();
153
h.update(data);
154
h.finalize()
155
}
156
157
pub fn sha256_hex(data: &[u8]) -> String {
158
hex(&sha256(data))
159
}
160
161
pub fn hex(bytes: &[u8]) -> String {
162
const TABLE: &[u8; 16] = b"0123456789abcdef";
163
let mut out = String::with_capacity(bytes.len() * 2);
164
for &b in bytes {
165
out.push(TABLE[(b >> 4) as usize] as char);
166
out.push(TABLE[(b & 0xf) as usize] as char);
167
}
168
out
169
}
170
171
pub fn is_sha256_hex(value: &str) -> bool {
172
value.len() == 64
173
&& value
174
.bytes()
175
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
176
}
177
178
/// Stream a regular file through SHA-256 with an explicit size ceiling.
179
pub fn sha256_file(path: &std::path::Path, max_bytes: u64) -> Result<String, String> {
180
use std::io::Read;
181
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
182
if !meta.is_file() {
183
return Err("bronbestand is geen regulier bestand".into());
184
}
185
if meta.len() > max_bytes {
186
return Err(format!("bronbestand groter dan {max_bytes} bytes"));
187
}
188
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
189
let mut hasher = Sha256::new();
190
let mut buf = [0u8; 64 * 1024];
191
let mut total = 0u64;
192
loop {
193
let n = file.read(&mut buf).map_err(|e| e.to_string())?;
194
if n == 0 {
195
break;
196
}
197
total = total.saturating_add(n as u64);
198
if total > max_bytes {
199
return Err(format!("bronbestand groter dan {max_bytes} bytes"));
200
}
201
hasher.update(&buf[..n]);
202
}
203
Ok(hex(&hasher.finalize()))
204
}
205
206
#[cfg(test)]
207
mod tests {
208
use super::*;
209
210
#[test]
211
fn known_vectors() {
212
assert_eq!(
213
sha256_hex(b""),
214
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
215
);
216
assert_eq!(
217
sha256_hex(b"abc"),
218
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
219
);
220
assert_eq!(
221
sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
222
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
223
);
224
}
225
226
#[test]
227
fn streaming_matches_one_shot() {
228
let data: Vec<u8> = (0..300).map(|i| i as u8).collect();
229
let mut h = Sha256::new();
230
h.update(&data[..100]);
231
h.update(&data[100..200]);
232
h.update(&data[200..]);
233
assert_eq!(hex(&h.finalize()), sha256_hex(&data));
234
}
235
236
#[test]
237
fn hex_validator() {
238
assert!(is_sha256_hex(&sha256_hex(b"x")));
239
assert!(!is_sha256_hex("abc"));
240
assert!(!is_sha256_hex(&"g".repeat(64)));
241
}
242
}
@@ -0,0 +1,242 @@1
//! SHA-256 digests for export-set identity (no external digest crate).2
#![allow(clippy::unreadable_literal)]3
4
const K: [u32; 64] = [5
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,6
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,7
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,8
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,9
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,10
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,11
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,12
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,13
];14
15
#[derive(Clone)]16
pub struct Sha256 {17
state: [u32; 8],18
buffer: [u8; 64],19
buffer_len: usize,20
bit_len: u64,21
}22
23
impl Default for Sha256 {24
fn default() -> Self {25
Self::new()26
}27
}28
29
impl Sha256 {30
pub fn new() -> Self {31
Self {32
state: [33
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,34
0x5be0cd19,35
],36
buffer: [0; 64],37
buffer_len: 0,38
bit_len: 0,39
}40
}41
42
pub fn update(&mut self, data: &[u8]) {43
let mut offset = 0;44
if self.buffer_len > 0 {45
let need = 64 - self.buffer_len;46
let take = need.min(data.len());47
self.buffer[self.buffer_len..self.buffer_len + take].copy_from_slice(&data[..take]);48
self.buffer_len += take;49
offset += take;50
if self.buffer_len == 64 {51
let block = self.buffer;52
self.compress(&block);53
self.bit_len = self.bit_len.saturating_add(512);54
self.buffer_len = 0;55
}56
}57
while offset + 64 <= data.len() {58
let mut block = [0u8; 64];59
block.copy_from_slice(&data[offset..offset + 64]);60
self.compress(&block);61
self.bit_len = self.bit_len.saturating_add(512);62
offset += 64;63
}64
let rest = &data[offset..];65
if !rest.is_empty() {66
self.buffer[..rest.len()].copy_from_slice(rest);67
self.buffer_len = rest.len();68
}69
}70
71
pub fn finalize(mut self) -> [u8; 32] {72
let total_bits = self73
.bit_len74
.saturating_add((self.buffer_len as u64).saturating_mul(8));75
self.buffer[self.buffer_len] = 0x80;76
self.buffer_len += 1;77
if self.buffer_len > 56 {78
for b in &mut self.buffer[self.buffer_len..] {79
*b = 0;80
}81
let block = self.buffer;82
self.compress(&block);83
self.buffer = [0; 64];84
self.buffer_len = 0;85
}86
for b in &mut self.buffer[self.buffer_len..56] {87
*b = 0;88
}89
self.buffer[56..64].copy_from_slice(&total_bits.to_be_bytes());90
let block = self.buffer;91
self.compress(&block);92
let mut out = [0u8; 32];93
for (i, word) in self.state.iter().enumerate() {94
out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());95
}96
out97
}98
99
fn compress(&mut self, block: &[u8; 64]) {100
let mut w = [0u32; 64];101
for (i, chunk) in block.chunks_exact(4).enumerate().take(16) {102
w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);103
}104
for i in 16..64 {105
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);106
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);107
w[i] = w[i - 16]108
.wrapping_add(s0)109
.wrapping_add(w[i - 7])110
.wrapping_add(s1);111
}112
let mut a = self.state[0];113
let mut b = self.state[1];114
let mut c = self.state[2];115
let mut d = self.state[3];116
let mut e = self.state[4];117
let mut f = self.state[5];118
let mut g = self.state[6];119
let mut h = self.state[7];120
for i in 0..64 {121
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);122
let ch = (e & f) ^ ((!e) & g);123
let temp1 = h124
.wrapping_add(s1)125
.wrapping_add(ch)126
.wrapping_add(K[i])127
.wrapping_add(w[i]);128
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);129
let maj = (a & b) ^ (a & c) ^ (b & c);130
let temp2 = s0.wrapping_add(maj);131
h = g;132
g = f;133
f = e;134
e = d.wrapping_add(temp1);135
d = c;136
c = b;137
b = a;138
a = temp1.wrapping_add(temp2);139
}140
self.state[0] = self.state[0].wrapping_add(a);141
self.state[1] = self.state[1].wrapping_add(b);142
self.state[2] = self.state[2].wrapping_add(c);143
self.state[3] = self.state[3].wrapping_add(d);144
self.state[4] = self.state[4].wrapping_add(e);145
self.state[5] = self.state[5].wrapping_add(f);146
self.state[6] = self.state[6].wrapping_add(g);147
self.state[7] = self.state[7].wrapping_add(h);148
}149
}150
151
pub fn sha256(data: &[u8]) -> [u8; 32] {152
let mut h = Sha256::new();153
h.update(data);154
h.finalize()155
}156
157
pub fn sha256_hex(data: &[u8]) -> String {158
hex(&sha256(data))159
}160
161
pub fn hex(bytes: &[u8]) -> String {162
const TABLE: &[u8; 16] = b"0123456789abcdef";163
let mut out = String::with_capacity(bytes.len() * 2);164
for &b in bytes {165
out.push(TABLE[(b >> 4) as usize] as char);166
out.push(TABLE[(b & 0xf) as usize] as char);167
}168
out169
}170
171
pub fn is_sha256_hex(value: &str) -> bool {172
value.len() == 64173
&& value174
.bytes()175
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))176
}177
178
/// Stream a regular file through SHA-256 with an explicit size ceiling.179
pub fn sha256_file(path: &std::path::Path, max_bytes: u64) -> Result<String, String> {180
use std::io::Read;181
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;182
if !meta.is_file() {183
return Err("bronbestand is geen regulier bestand".into());184
}185
if meta.len() > max_bytes {186
return Err(format!("bronbestand groter dan {max_bytes} bytes"));187
}188
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;189
let mut hasher = Sha256::new();190
let mut buf = [0u8; 64 * 1024];191
let mut total = 0u64;192
loop {193
let n = file.read(&mut buf).map_err(|e| e.to_string())?;194
if n == 0 {195
break;196
}197
total = total.saturating_add(n as u64);198
if total > max_bytes {199
return Err(format!("bronbestand groter dan {max_bytes} bytes"));200
}201
hasher.update(&buf[..n]);202
}203
Ok(hex(&hasher.finalize()))204
}205
206
#[cfg(test)]207
mod tests {208
use super::*;209
210
#[test]211
fn known_vectors() {212
assert_eq!(213
sha256_hex(b""),214
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"215
);216
assert_eq!(217
sha256_hex(b"abc"),218
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"219
);220
assert_eq!(221
sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),222
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"223
);224
}225
226
#[test]227
fn streaming_matches_one_shot() {228
let data: Vec<u8> = (0..300).map(|i| i as u8).collect();229
let mut h = Sha256::new();230
h.update(&data[..100]);231
h.update(&data[100..200]);232
h.update(&data[200..]);233
assert_eq!(hex(&h.finalize()), sha256_hex(&data));234
}235
236
#[test]237
fn hex_validator() {238
assert!(is_sha256_hex(&sha256_hex(b"x")));239
assert!(!is_sha256_hex("abc"));240
assert!(!is_sha256_hex(&"g".repeat(64)));241
}242
}@@ -414,6 +414,36 @@ impl EditorApp {
414
414
self.session.fail("configureer eerst het equipment");
415
415
return;
416
416
}
417
let body_rel = match self.controls.sex {
418
PreviewSex::Male => opened.document.male_model.clone(),
419
PreviewSex::Female => opened.document.female_model.clone(),
420
};
421
let Some(body_rel) = body_rel else {
422
self.session.fail("dit lichaam heeft geen modelpad");
423
return;
424
};
425
if let Err(error) = crate::project::portable_relative(&body_rel) {
426
self.session.fail(error);
427
return;
428
}
429
let body_path = root.join(&body_rel);
430
let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
431
Ok(hash) => hash,
432
Err(error) => {
433
self.session.fail(format!("body-fingerprint: {error}"));
434
return;
435
}
436
};
437
let preset_sha256 = match &preset {
438
None => None,
439
Some(p) => match p.encode() {
440
Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
441
Err(error) => {
442
self.session.fail(format!("equipment-fingerprint: {error}"));
443
return;
444
}
445
},
446
};
417
447
let job = RenderJob {
418
448
character,
419
449
sex: if sex == 0 { "male" } else { "female" }.into(),
@@ -423,6 +453,13 @@ impl EditorApp {
423
453
fps: opened.document.render.fps,
424
454
framing: opened.document.render.framing[sex],
425
455
output: root.join(&opened.document.output_directory),
456
project_id: opened.document.id.clone(),
457
project_name: opened.document.name.clone(),
458
body_model: crate::production::SourceFingerprint {
459
path: body_rel,
460
sha256: body_sha,
461
},
462
preset_sha256,
426
463
};
427
464
self.render_worker = Some(spawn(job, None));
428
465
self.render_ui = RenderUiState::idle();
@@ -414,6 +414,36 @@ impl EditorApp {414
self.session.fail("configureer eerst het equipment");414
self.session.fail("configureer eerst het equipment");415
return;415
return;416
}416
}417
let body_rel = match self.controls.sex {418
PreviewSex::Male => opened.document.male_model.clone(),419
PreviewSex::Female => opened.document.female_model.clone(),420
};421
let Some(body_rel) = body_rel else {422
self.session.fail("dit lichaam heeft geen modelpad");423
return;424
};425
if let Err(error) = crate::project::portable_relative(&body_rel) {426
self.session.fail(error);427
return;428
}429
let body_path = root.join(&body_rel);430
let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {431
Ok(hash) => hash,432
Err(error) => {433
self.session.fail(format!("body-fingerprint: {error}"));434
return;435
}436
};437
let preset_sha256 = match &preset {438
None => None,439
Some(p) => match p.encode() {440
Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),441
Err(error) => {442
self.session.fail(format!("equipment-fingerprint: {error}"));443
return;444
}445
},446
};417
let job = RenderJob {447
let job = RenderJob {418
character,448
character,419
sex: if sex == 0 { "male" } else { "female" }.into(),449
sex: if sex == 0 { "male" } else { "female" }.into(),@@ -423,6 +453,13 @@ impl EditorApp {423
fps: opened.document.render.fps,453
fps: opened.document.render.fps,424
framing: opened.document.render.framing[sex],454
framing: opened.document.render.framing[sex],425
output: root.join(&opened.document.output_directory),455
output: root.join(&opened.document.output_directory),456
project_id: opened.document.id.clone(),457
project_name: opened.document.name.clone(),458
body_model: crate::production::SourceFingerprint {459
path: body_rel,460
sha256: body_sha,461
},462
preset_sha256,426
};463
};427
self.render_worker = Some(spawn(job, None));464
self.render_worker = Some(spawn(job, None));428
self.render_ui = RenderUiState::idle();465
self.render_ui = RenderUiState::idle();@@ -0,0 +1,1154 @@
1
//! Safe reader/index for completed production export runs.
2
//!
3
//! A run directory is accepted only when both `render.json` (schema 3) and
4
//! `complete.json` are present, their identity contracts match, every listed
5
//! PNG checksum verifies, and each map decodes under the published pixel
6
//! contract. Filenames alone never prove compatibility.
7
use crate::{
8
digest::{self, is_sha256_hex},
9
production::{FAR, MAX_HIGHRES, NEAR, PASSES, SCALE},
10
project::{self, CameraFraming},
11
};
12
use serde::Deserialize;
13
use std::{
14
collections::BTreeMap,
15
fs,
16
io::Read,
17
path::{Path, PathBuf},
18
};
19
20
/// Output metadata schema written by production and required by this reader.
21
pub const OUTPUT_META_VERSION: u32 = 3;
22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
23
/// Hard ceiling for a single published PNG on disk (encoded).
24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
25
/// Same batch file ceiling as production planning.
26
pub const MAX_PNG_FILES: u32 = 500_000;
27
const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024;
28
const MAX_CLIP_NAME: usize = 256;
29
const MAX_CLIPS: usize = 4096;
30
31
#[derive(Debug, Clone, PartialEq, Eq)]
32
pub enum ExportError {
33
Incomplete(String),
34
Identity(String),
35
Io(String),
36
Corrupt(String),
37
Resource(String),
38
Traversal(String),
39
}
40
41
impl std::fmt::Display for ExportError {
42
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43
match self {
44
Self::Incomplete(m)
45
| Self::Identity(m)
46
| Self::Io(m)
47
| Self::Corrupt(m)
48
| Self::Resource(m)
49
| Self::Traversal(m) => write!(f, "{m}"),
50
}
51
}
52
}
53
54
impl std::error::Error for ExportError {}
55
56
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57
pub enum MapKind {
58
Diffuse,
59
Normal,
60
Specular,
61
Depth,
62
}
63
64
impl MapKind {
65
pub fn token(self) -> &'static str {
66
match self {
67
Self::Diffuse => "diffuse",
68
Self::Normal => "normal",
69
Self::Specular => "specular",
70
Self::Depth => "depth",
71
}
72
}
73
74
pub fn parse(token: &str) -> Option<Self> {
75
match token {
76
"diffuse" => Some(Self::Diffuse),
77
"normal" => Some(Self::Normal),
78
"specular" => Some(Self::Specular),
79
"depth" => Some(Self::Depth),
80
_ => None,
81
}
82
}
83
}
84
85
#[derive(Debug, Clone, PartialEq, Eq)]
86
pub enum MapImage {
87
Rgba8 {
88
width: u32,
89
height: u32,
90
rgba: Vec<u8>,
91
},
92
Depth16 {
93
width: u32,
94
height: u32,
95
/// Interleaved gray, alpha samples (length = width*height*2).
96
samples: Vec<u16>,
97
},
98
}
99
100
impl MapImage {
101
pub fn width(&self) -> u32 {
102
match self {
103
Self::Rgba8 { width, .. } | Self::Depth16 { width, .. } => *width,
104
}
105
}
106
pub fn height(&self) -> u32 {
107
match self {
108
Self::Rgba8 { height, .. } | Self::Depth16 { height, .. } => *height,
109
}
110
}
111
}
112
113
#[derive(Debug, Clone, Deserialize)]
114
pub struct SourceRef {
115
pub path: String,
116
pub sha256: String,
117
}
118
119
#[derive(Debug, Clone, Deserialize)]
120
pub struct PresetRef {
121
pub name: String,
122
pub sha256: String,
123
}
124
125
#[derive(Debug, Clone, Deserialize)]
126
pub struct ClipRecord {
127
pub name: String,
128
pub token: String,
129
pub frames: u32,
130
pub duration: f32,
131
}
132
133
#[derive(Debug, Clone, Deserialize)]
134
pub struct PivotSourceJson {
135
pub kind: String,
136
#[serde(default)]
137
pub joint_index: Option<usize>,
138
#[serde(default)]
139
pub joint_name: Option<String>,
140
#[serde(default)]
141
pub reason: Option<String>,
142
}
143
144
#[derive(Debug, Clone, Deserialize)]
145
pub struct RenderManifest {
146
pub format_version: u32,
147
pub status: String,
148
pub project: ProjectRef,
149
pub sex: String,
150
pub set: String,
151
pub body_model: SourceRef,
152
#[serde(default)]
153
pub equipment_preset: Option<PresetRef>,
154
pub target: [u32; 2],
155
pub fps: u32,
156
pub highres_scale: u32,
157
pub framing: CameraFraming,
158
pub pivot_source: PivotSourceJson,
159
pub near: f32,
160
pub far: f32,
161
pub camera_distance: f32,
162
pub pass_order: Vec<String>,
163
pub saved_maps: Vec<String>,
164
pub clips: Vec<ClipRecord>,
165
pub images: u32,
166
pub passes: u32,
167
pub png_files: u32,
168
}
169
170
#[derive(Debug, Clone, Deserialize)]
171
pub struct ProjectRef {
172
pub id: String,
173
pub name: String,
174
}
175
176
#[derive(Debug, Clone, Deserialize)]
177
pub struct CompleteIdentity {
178
pub project_id: String,
179
pub project_name: String,
180
pub sex: String,
181
pub set: String,
182
pub body_model_sha256: String,
183
#[serde(default)]
184
pub equipment_preset_sha256: Option<String>,
185
pub target: [u32; 2],
186
pub fps: u32,
187
pub framing: CameraFraming,
188
pub pivot_source: PivotSourceJson,
189
pub near: f32,
190
pub far: f32,
191
pub clips: Vec<ClipRecord>,
192
pub saved_maps: Vec<String>,
193
}
194
195
#[derive(Debug, Clone, Deserialize)]
196
pub struct CompleteManifest {
197
pub format_version: u32,
198
pub status: String,
199
pub identity: CompleteIdentity,
200
pub passes: u32,
201
pub png_files: u32,
202
pub png_sha256: BTreeMap<String, String>,
203
}
204
205
#[derive(Debug, Clone)]
206
pub struct ExportRun {
207
root: PathBuf,
208
pub render: RenderManifest,
209
pub complete: CompleteManifest,
210
}
211
212
impl ExportRun {
213
pub fn root(&self) -> &Path {
214
&self.root
215
}
216
217
pub fn open(run_dir: &Path) -> Result<Self, ExportError> {
218
let root = canonicalize_dir(run_dir)?;
219
if root.join("cancelled.json").exists() || root.join("failed.json").exists() {
220
return Err(ExportError::Incomplete(
221
"run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
222
));
223
}
224
let render_bytes = read_manifest_file(&root, "render.json")?;
225
let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
226
if matches!(err, ExportError::Incomplete(_)) {
227
ExportError::Incomplete(
228
"complete.json ontbreekt; de exportset is niet volledig".into(),
229
)
230
} else {
231
err
232
}
233
})?;
234
let render: RenderManifest = serde_json::from_slice(&render_bytes)
235
.map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
236
let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
237
.map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?;
238
validate_render(&render)?;
239
validate_complete(&complete)?;
240
identities_match(&render, &complete)?;
241
if complete.png_sha256.len() as u32 != complete.png_files {
242
return Err(ExportError::Corrupt(
243
"complete.json png_sha256 telt niet gelijk aan png_files".into(),
244
));
245
}
246
if complete.png_files != render.png_files {
247
return Err(ExportError::Identity(
248
"png_files in complete.json wijkt af van render.json".into(),
249
));
250
}
251
// Verify every checksum exists as a bounded regular file under the run.
252
for (relative, digest) in &complete.png_sha256 {
253
let path = resolve_inside(&root, relative)?;
254
verify_regular_file(&path, &root)?;
255
let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?;
256
if meta.len() > MAX_PNG_BYTES {
257
return Err(ExportError::Resource(format!(
258
"PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes"
259
)));
260
}
261
let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?;
262
let actual = digest::sha256_hex(&bytes);
263
if actual != *digest {
264
return Err(ExportError::Corrupt(format!(
265
"checksum mismatch voor '{relative}'"
266
)));
267
}
268
}
269
Ok(Self {
270
root,
271
render,
272
complete,
273
})
274
}
275
276
pub fn map_path(
277
&self,
278
clip_token: &str,
279
frame: u32,
280
pitch: i32,
281
yaw: u32,
282
kind: MapKind,
283
) -> Result<String, ExportError> {
284
let relative = crate::production::output_path(
285
&self.render.sex,
286
&self.render.set,
287
clip_token,
288
frame,
289
pitch,
290
yaw,
291
kind.token(),
292
)
293
.map_err(ExportError::Identity)?;
294
let key = relative
295
.to_str()
296
.ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
297
.replace('\\', "/");
298
if !self.complete.png_sha256.contains_key(&key) {
299
return Err(ExportError::Incomplete(format!(
300
"map '{key}' staat niet in complete.json"
301
)));
302
}
303
Ok(key)
304
}
305
306
pub fn load_map(
307
&self,
308
clip_token: &str,
309
frame: u32,
310
pitch: i32,
311
yaw: u32,
312
kind: MapKind,
313
) -> Result<MapImage, ExportError> {
314
let key = self.map_path(clip_token, frame, pitch, yaw, kind)?;
315
self.load_relative(&key, kind)
316
}
317
318
pub fn load_relative(
319
&self,
320
relative: &str,
321
expected: MapKind,
322
) -> Result<MapImage, ExportError> {
323
let expected_digest = self
324
.complete
325
.png_sha256
326
.get(relative)
327
.ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?;
328
let path = resolve_inside(&self.root, relative)?;
329
verify_regular_file(&path, &self.root)?;
330
let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?;
331
let actual = digest::sha256_hex(&bytes);
332
if actual != *expected_digest {
333
return Err(ExportError::Corrupt(format!(
334
"checksum mismatch voor '{relative}'"
335
)));
336
}
337
let token = relative
338
.rsplit('_')
339
.next()
340
.and_then(|s| s.strip_suffix(".png"))
341
.ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?;
342
let kind = MapKind::parse(token).ok_or_else(|| {
343
ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'"))
344
})?;
345
if kind != expected {
346
return Err(ExportError::Identity(format!(
347
"verwachtte {} maar pad is {}",
348
expected.token(),
349
kind.token()
350
)));
351
}
352
let [width, height] = self.render.target;
353
decode_map(&bytes, kind, width, height)
354
}
355
356
pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {
357
self.complete
358
.png_sha256
359
.iter()
360
.map(|(k, v)| (k.as_str(), v.as_str()))
361
}
362
}
363
364
fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
365
if m.format_version != OUTPUT_META_VERSION {
366
return Err(ExportError::Identity(format!(
367
"render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})",
368
m.format_version
369
)));
370
}
371
if m.status != "incomplete" && m.status != "complete" {
372
// production leaves status=incomplete in render.json even after success
373
return Err(ExportError::Corrupt(format!(
374
"onbekende render.json status '{}'",
375
m.status
376
)));
377
}
378
if !["male", "female"].contains(&m.sex.as_str()) {
379
return Err(ExportError::Identity("ongeldig geslacht".into()));
380
}
381
if m.set != "body" && !crate::equipment::valid_name(&m.set) {
382
return Err(ExportError::Identity("ongeldige setnaam".into()));
383
}
384
if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET
385
{
386
return Err(ExportError::Resource("target buiten 1–256".into()));
387
}
388
if m.fps == 0 || m.fps > 240 {
389
return Err(ExportError::Identity("ongeldige fps".into()));
390
}
391
if m.highres_scale != SCALE {
392
return Err(ExportError::Identity("highres_scale wijkt af van 8".into()));
393
}
394
m.framing.validate().map_err(ExportError::Identity)?;
395
if (m.near - NEAR).abs() > f32::EPSILON || (m.far - FAR).abs() > f32::EPSILON {
396
return Err(ExportError::Identity(
397
"near/far wijkt af van contract".into(),
398
));
399
}
400
if (m.camera_distance - 10.).abs() > f32::EPSILON {
401
return Err(ExportError::Identity(
402
"camera_distance wijkt af van contract".into(),
403
));
404
}
405
if m.pass_order != PASSES {
406
return Err(ExportError::Identity(
407
"pass_order wijkt af van contract".into(),
408
));
409
}
410
if m.saved_maps != ["diffuse", "depth", "normal", "specular"] {
411
return Err(ExportError::Identity(
412
"saved_maps wijkt af van diffuse/depth/normal/specular".into(),
413
));
414
}
415
if m.clips.is_empty() || m.clips.len() > MAX_CLIPS {
416
return Err(ExportError::Resource("clip-inventaris ongeldig".into()));
417
}
418
for clip in &m.clips {
419
if clip.token.is_empty()
420
|| clip.token.len() > 64
421
|| !crate::equipment::valid_name(&clip.token)
422
{
423
return Err(ExportError::Identity(format!(
424
"ongeldige clip-token '{}'",
425
clip.token
426
)));
427
}
428
if clip.name.len() > MAX_CLIP_NAME {
429
return Err(ExportError::Resource("clipnaam te lang".into()));
430
}
431
if clip.frames == 0 {
432
return Err(ExportError::Identity("clip zonder frames".into()));
433
}
434
if !clip.duration.is_finite() || clip.duration < 0. {
435
return Err(ExportError::Identity("ongeldige clipduur".into()));
436
}
437
}
438
if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
439
return Err(ExportError::Resource("png_files buiten limiet".into()));
440
}
441
if !is_sha256_hex(&m.body_model.sha256) {
442
return Err(ExportError::Corrupt(
443
"body_model.sha256 is geen SHA-256 hex".into(),
444
));
445
}
446
project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?;
447
if m.project.id.is_empty() || m.project.id.len() > 128 {
448
return Err(ExportError::Identity("project.id ongeldig".into()));
449
}
450
if m.project.name.len() > 256 {
451
return Err(ExportError::Identity("project.name te lang".into()));
452
}
453
match &m.equipment_preset {
454
None if m.set == "body" => {}
455
Some(p)
456
if m.set == p.name
457
&& is_sha256_hex(&p.sha256)
458
&& crate::equipment::valid_name(&p.name) => {}
459
_ => {
460
return Err(ExportError::Identity(
461
"equipment_preset past niet bij set of mist geldige sha256".into(),
462
))
463
}
464
}
465
match m.pivot_source.kind.as_str() {
466
"manual" | "neck" | "fallback" => {}
467
other => {
468
return Err(ExportError::Identity(format!(
469
"onbekende pivot_source.kind '{other}'"
470
)))
471
}
472
}
473
Ok(())
474
}
475
476
fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
477
if m.format_version != OUTPUT_META_VERSION {
478
return Err(ExportError::Identity(format!(
479
"complete.json format_version {} wordt niet ondersteund",
480
m.format_version
481
)));
482
}
483
if m.status != "complete" {
484
return Err(ExportError::Incomplete(
485
"complete.json status is niet 'complete'".into(),
486
));
487
}
488
if m.png_files == 0 || m.png_files > MAX_PNG_FILES {
489
return Err(ExportError::Resource("png_files buiten limiet".into()));
490
}
491
if m.png_sha256.len() > MAX_PNG_FILES as usize {
492
return Err(ExportError::Resource("te veel png_sha256-items".into()));
493
}
494
if !is_sha256_hex(&m.identity.body_model_sha256) {
495
return Err(ExportError::Corrupt(
496
"identity.body_model_sha256 ongeldig".into(),
497
));
498
}
499
if let Some(hash) = &m.identity.equipment_preset_sha256 {
500
if !is_sha256_hex(hash) {
501
return Err(ExportError::Corrupt(
502
"identity.equipment_preset_sha256 ongeldig".into(),
503
));
504
}
505
}
506
for (path, hash) in &m.png_sha256 {
507
project::portable_relative(path).map_err(ExportError::Traversal)?;
508
if !path.ends_with(".png") {
509
return Err(ExportError::Corrupt(format!(
510
"png_sha256-sleutel '{path}' is geen .png"
511
)));
512
}
513
if !is_sha256_hex(hash) {
514
return Err(ExportError::Corrupt(format!(
515
"png_sha256 voor '{path}' is geen SHA-256 hex"
516
)));
517
}
518
}
519
Ok(())
520
}
521
522
fn identities_match(
523
render: &RenderManifest,
524
complete: &CompleteManifest,
525
) -> Result<(), ExportError> {
526
let id = &complete.identity;
527
let check = |ok: bool, msg: &str| {
528
if ok {
529
Ok(())
530
} else {
531
Err(ExportError::Identity(msg.into()))
532
}
533
};
534
check(id.project_id == render.project.id, "project.id mismatch")?;
535
check(
536
id.project_name == render.project.name,
537
"project.name mismatch",
538
)?;
539
check(id.sex == render.sex, "sex mismatch")?;
540
check(id.set == render.set, "set mismatch")?;
541
check(
542
id.body_model_sha256 == render.body_model.sha256,
543
"body_model sha256 mismatch",
544
)?;
545
check(id.target == render.target, "target mismatch")?;
546
check(id.fps == render.fps, "fps mismatch")?;
547
check(id.framing == render.framing, "framing mismatch")?;
548
check(
549
id.near == render.near && id.far == render.far,
550
"near/far mismatch",
551
)?;
552
check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
553
check(
554
id.clips.len() == render.clips.len(),
555
"clips length mismatch",
556
)?;
557
for (a, b) in id.clips.iter().zip(render.clips.iter()) {
558
if a.token != b.token || a.frames != b.frames || a.name != b.name {
559
return Err(ExportError::Identity("clip inventory mismatch".into()));
560
}
561
}
562
check(
563
id.pivot_source.kind == render.pivot_source.kind,
564
"pivot_source mismatch",
565
)?;
566
let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone());
567
check(
568
id.equipment_preset_sha256 == preset_hash,
569
"equipment preset sha256 mismatch",
570
)?;
571
Ok(())
572
}
573
574
fn decode_map(
575
bytes: &[u8],
576
kind: MapKind,
577
expect_w: u32,
578
expect_h: u32,
579
) -> Result<MapImage, ExportError> {
580
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
581
decoder.set_transformations(png::Transformations::IDENTITY);
582
let mut reader = decoder
583
.read_info()
584
.map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
585
let info = reader.info();
586
if info.width != expect_w || info.height != expect_h {
587
return Err(ExportError::Corrupt(format!(
588
"PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
589
info.width, info.height
590
)));
591
}
592
if info.width > MAX_TARGET || info.height > MAX_TARGET {
593
return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));
594
}
595
match kind {
596
MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
597
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
598
return Err(ExportError::Corrupt(
599
"RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(),
600
));
601
}
602
let size = reader.output_buffer_size().ok_or_else(|| {
603
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
604
})?;
605
let mut buf = vec![0u8; size];
606
let frame = reader
607
.next_frame(&mut buf)
608
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
609
if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 {
610
return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into()));
611
}
612
buf.truncate(frame.buffer_size());
613
if kind == MapKind::Normal {
614
validate_normals(&buf, expect_w, expect_h)?;
615
}
616
if kind == MapKind::Specular {
617
validate_specular_channels(&buf)?;
618
}
619
Ok(MapImage::Rgba8 {
620
width: expect_w,
621
height: expect_h,
622
rgba: buf,
623
})
624
}
625
MapKind::Depth => {
626
if info.color_type != png::ColorType::GrayscaleAlpha
627
|| info.bit_depth != png::BitDepth::Sixteen
628
{
629
return Err(ExportError::Corrupt(
630
"depth-map moet gray-alpha 16-bit zijn".into(),
631
));
632
}
633
let size = reader.output_buffer_size().ok_or_else(|| {
634
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
635
})?;
636
let mut buf = vec![0u8; size];
637
let frame = reader
638
.next_frame(&mut buf)
639
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
640
let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes
641
if frame.buffer_size() != expected {
642
return Err(ExportError::Corrupt(
643
"depth buffergrootte klopt niet".into(),
644
));
645
}
646
buf.truncate(frame.buffer_size());
647
let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);
648
for chunk in buf.chunks_exact(2) {
649
samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));
650
}
651
validate_depth_alpha(&samples)?;
652
Ok(MapImage::Depth16 {
653
width: expect_w,
654
height: expect_h,
655
samples,
656
})
657
}
658
}
659
}
660
661
fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
662
for px in rgba.chunks_exact(4) {
663
let a = px[3];
664
if a == 0 {
665
if px[0] != 0 || px[1] != 0 || px[2] != 0 {
666
return Err(ExportError::Corrupt(
667
"transparante normal heeft niet-nul RGB".into(),
668
));
669
}
670
continue;
671
}
672
let nx = px[0] as f32 / 255. * 2. - 1.;
673
let ny = px[1] as f32 / 255. * 2. - 1.;
674
let nz = px[2] as f32 / 255. * 2. - 1.;
675
let len = (nx * nx + ny * ny + nz * nz).sqrt();
676
if !(0.85..=1.15).contains(&len) {
677
return Err(ExportError::Corrupt(format!(
678
"normal lengte {len} buiten tolerantie"
679
)));
680
}
681
}
682
Ok(())
683
}
684
685
fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {
686
for px in rgba.chunks_exact(4) {
687
if px[0] != px[1] || px[1] != px[2] {
688
return Err(ExportError::Corrupt(
689
"specular RGB-kanalen zijn niet gelijk".into(),
690
));
691
}
692
}
693
Ok(())
694
}
695
696
fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {
697
for pair in samples.chunks_exact(2) {
698
let gray = pair[0];
699
let alpha = pair[1];
700
if alpha == 0 && gray != 0 {
701
return Err(ExportError::Corrupt(
702
"transparante depth heeft niet-nul gray".into(),
703
));
704
}
705
}
706
Ok(())
707
}
708
709
fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
710
let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
711
if !meta.is_dir() {
712
return Err(ExportError::Io("runpad is geen map".into()));
713
}
714
path.canonicalize()
715
.map_err(|e| ExportError::Io(e.to_string()))
716
}
717
718
fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
719
let path = root.join(name);
720
if !path.exists() {
721
return Err(ExportError::Incomplete(format!("{name} ontbreekt")));
722
}
723
verify_regular_file(&path, root)?;
724
read_bounded_file(&path, MAX_MANIFEST_BYTES)
725
}
726
727
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()))?;
729
if meta.len() > max {
730
return Err(ExportError::Resource(format!(
731
"{} groter dan {max} bytes",
732
path.display()
733
)));
734
}
735
let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;
736
let mut bytes = Vec::new();
737
file.take(max + 1)
738
.read_to_end(&mut bytes)
739
.map_err(|e| ExportError::Io(e.to_string()))?;
740
if bytes.len() as u64 > max {
741
return Err(ExportError::Resource(format!(
742
"{} groter dan {max} bytes",
743
path.display()
744
)));
745
}
746
Ok(bytes)
747
}
748
749
fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
750
let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;
751
let path = root.join(&clean);
752
// Lexical rejection of oddities already handled; still verify canonical containment.
753
Ok(path)
754
}
755
756
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()))?;
758
if meta.file_type().is_symlink() {
759
return Err(ExportError::Traversal(format!(
760
"symlink geweigerd: {}",
761
path.display()
762
)));
763
}
764
if !meta.is_file() {
765
return Err(ExportError::Io(format!(
766
"geen regulier bestand: {}",
767
path.display()
768
)));
769
}
770
let root_real = root
771
.canonicalize()
772
.map_err(|e| ExportError::Io(e.to_string()))?;
773
let path_real = path
774
.canonicalize()
775
.map_err(|e| ExportError::Io(e.to_string()))?;
776
if !path_real.starts_with(&root_real) {
777
return Err(ExportError::Traversal(format!(
778
"pad verlaat runmap: {}",
779
path.display()
780
)));
781
}
782
Ok(())
783
}
784
785
/// Build the identity object embedded in complete.json (shared with production).
786
pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {
787
let project = render.get("project").ok_or("render.json mist project")?;
788
Ok(serde_json::json!({
789
"project_id": project.get("id").ok_or("project.id")?,
790
"project_name": project.get("name").ok_or("project.name")?,
791
"sex": render.get("sex").ok_or("sex")?,
792
"set": render.get("set").ok_or("set")?,
793
"body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,
794
"equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),
795
"target": render.get("target").ok_or("target")?,
796
"fps": render.get("fps").ok_or("fps")?,
797
"framing": render.get("framing").ok_or("framing")?,
798
"pivot_source": render.get("pivot_source").ok_or("pivot")?,
799
"near": render.get("near").ok_or("near")?,
800
"far": render.get("far").ok_or("far")?,
801
"clips": render.get("clips").ok_or("clips")?,
802
"saved_maps": render.get("saved_maps").ok_or("maps")?,
803
}))
804
}
805
806
#[cfg(test)]
807
mod tests {
808
use super::*;
809
use crate::production::{write_depth, write_rgba};
810
use crate::render_job::ImageBuf;
811
use std::sync::atomic::{AtomicU64, Ordering};
812
813
struct Temp(PathBuf);
814
impl Temp {
815
fn new() -> Self {
816
static N: AtomicU64 = AtomicU64::new(0);
817
let id = N.fetch_add(1, Ordering::Relaxed);
818
let path = std::env::temp_dir().join(format!(
819
"paperdoll-export-set-{}-{}",
820
std::process::id(),
821
id
822
));
823
let _ = fs::remove_dir_all(&path);
824
fs::create_dir_all(&path).unwrap();
825
Self(path)
826
}
827
}
828
impl Drop for Temp {
829
fn drop(&mut self) {
830
let _ = fs::remove_dir_all(&self.0);
831
}
832
}
833
834
fn framing() -> CameraFraming {
835
CameraFraming {
836
height_milli: 2600,
837
center_y_milli: 1000,
838
pivot_y_milli: Some(1460),
839
}
840
}
841
842
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
843
let data = serde_json::to_vec_pretty(value).unwrap();
844
let path = root.join(name);
845
let temp = path.with_extension(format!("part-{}", std::process::id()));
846
fs::write(&temp, &data).unwrap();
847
fs::hard_link(&temp, &path).unwrap();
848
let _ = fs::remove_file(temp);
849
}
850
851
fn base_render(png_files: u32) -> serde_json::Value {
852
serde_json::json!({
853
"format_version": 3,
854
"status": "incomplete",
855
"project": {"id": "demo", "name": "Demo"},
856
"sex": "male",
857
"set": "body",
858
"body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},
859
"equipment_preset": null,
860
"target": [1, 1],
861
"fps": 12,
862
"highres_scale": 8,
863
"framing": framing(),
864
"pivot_source": {"kind": "manual"},
865
"near": NEAR,
866
"far": FAR,
867
"camera_distance": 10.0,
868
"yaw_zero": "camera on -Z",
869
"normal_space": "view (+Y up), PNG rows top-down",
870
"pitches": [-30, 0, 30],
871
"yaw_step": 30,
872
"sampling": "test",
873
"alpha": "straight",
874
"diffuse": "unlit",
875
"depth": "16-bit",
876
"specular": "scalar",
877
"pass_order": PASSES,
878
"saved_maps": ["diffuse", "depth", "normal", "specular"],
879
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
880
"images": 1,
881
"passes": 6,
882
"png_files": png_files
883
})
884
}
885
886
fn publish_four(root: &Path) -> BTreeMap<String, String> {
887
let mut map = BTreeMap::new();
888
let rels = [
889
(
890
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",
891
"diffuse",
892
),
893
(
894
"male/body/clip000-bind/f000000/p-30_y000_depth.png",
895
"depth",
896
),
897
(
898
"male/body/clip000-bind/f000000/p-30_y000_normal.png",
899
"normal",
900
),
901
(
902
"male/body/clip000-bind/f000000/p-30_y000_specular.png",
903
"specular",
904
),
905
];
906
for (rel, kind) in rels {
907
let path = Path::new(rel);
908
match kind {
909
"depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),
910
"normal" => write_rgba(
911
root,
912
path,
913
&ImageBuf {
914
width: 1,
915
height: 1,
916
rgba: vec![128, 128, 255, 255],
917
},
918
)
919
.unwrap(),
920
"specular" => write_rgba(
921
root,
922
path,
923
&ImageBuf {
924
width: 1,
925
height: 1,
926
rgba: vec![0, 0, 0, 255],
927
},
928
)
929
.unwrap(),
930
_ => write_rgba(
931
root,
932
path,
933
&ImageBuf {
934
width: 1,
935
height: 1,
936
rgba: vec![10, 20, 30, 255],
937
},
938
)
939
.unwrap(),
940
};
941
let bytes = fs::read(root.join(rel)).unwrap();
942
map.insert(rel.to_string(), digest::sha256_hex(&bytes));
943
}
944
map
945
}
946
947
#[test]
948
fn opens_completed_run_and_reads_maps() {
949
let tmp = Temp::new();
950
let checksums = publish_four(&tmp.0);
951
let render = base_render(4);
952
write_atomic_json(&tmp.0, "render.json", &render);
953
let identity = complete_identity_json(&render).unwrap();
954
write_atomic_json(
955
&tmp.0,
956
"complete.json",
957
&serde_json::json!({
958
"format_version": 3,
959
"status": "complete",
960
"identity": identity,
961
"passes": 6,
962
"png_files": 4,
963
"png_sha256": checksums
964
}),
965
);
966
let run = ExportRun::open(&tmp.0).unwrap();
967
let diffuse = run
968
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)
969
.unwrap();
970
match diffuse {
971
MapImage::Rgba8 {
972
width,
973
height,
974
rgba,
975
} => {
976
assert_eq!((width, height), (1, 1));
977
assert_eq!(rgba, [10, 20, 30, 255]);
978
}
979
_ => panic!("expected rgba"),
980
}
981
let depth = run
982
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)
983
.unwrap();
984
match depth {
985
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
986
_ => panic!("expected depth"),
987
}
988
}
989
990
#[test]
991
fn rejects_missing_complete_and_checksum_mismatch() {
992
let tmp = Temp::new();
993
let render = base_render(4);
994
write_atomic_json(&tmp.0, "render.json", &render);
995
assert!(matches!(
996
ExportRun::open(&tmp.0),
997
Err(ExportError::Incomplete(_))
998
));
999
let mut checksums = publish_four(&tmp.0);
1000
*checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");
1001
let identity = complete_identity_json(&render).unwrap();
1002
write_atomic_json(
1003
&tmp.0,
1004
"complete.json",
1005
&serde_json::json!({
1006
"format_version": 3,
1007
"status": "complete",
1008
"identity": identity,
1009
"passes": 6,
1010
"png_files": 4,
1011
"png_sha256": checksums
1012
}),
1013
);
1014
assert!(matches!(
1015
ExportRun::open(&tmp.0),
1016
Err(ExportError::Corrupt(_))
1017
));
1018
}
1019
1020
#[test]
1021
fn rejects_path_traversal_and_symlink_escape() {
1022
let tmp = Temp::new();
1023
let mut checksums = publish_four(&tmp.0);
1024
checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));
1025
let render = base_render(5);
1026
write_atomic_json(&tmp.0, "render.json", &render);
1027
let identity = complete_identity_json(&render).unwrap();
1028
write_atomic_json(
1029
&tmp.0,
1030
"complete.json",
1031
&serde_json::json!({
1032
"format_version": 3,
1033
"status": "complete",
1034
"identity": identity,
1035
"passes": 6,
1036
"png_files": 5,
1037
"png_sha256": checksums
1038
}),
1039
);
1040
assert!(matches!(
1041
ExportRun::open(&tmp.0),
1042
Err(ExportError::Traversal(_))
1043
));
1044
1045
#[cfg(unix)]
1046
{
1047
let tmp = Temp::new();
1048
let outside = Temp::new();
1049
let checksums = publish_four(&tmp.0);
1050
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";
1051
let _ = fs::remove_file(tmp.0.join(evil));
1052
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1053
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
1054
let render = base_render(4);
1055
write_atomic_json(&tmp.0, "render.json", &render);
1056
let identity = complete_identity_json(&render).unwrap();
1057
let mut checksums = checksums;
1058
checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));
1059
write_atomic_json(
1060
&tmp.0,
1061
"complete.json",
1062
&serde_json::json!({
1063
"format_version": 3,
1064
"status": "complete",
1065
"identity": identity,
1066
"passes": 6,
1067
"png_files": 4,
1068
"png_sha256": checksums
1069
}),
1070
);
1071
assert!(matches!(
1072
ExportRun::open(&tmp.0),
1073
Err(ExportError::Traversal(_))
1074
));
1075
}
1076
}
1077
1078
#[test]
1079
fn rejects_stale_schema_and_identity_drift() {
1080
let tmp = Temp::new();
1081
let checksums = publish_four(&tmp.0);
1082
let mut render = base_render(4);
1083
render["format_version"] = serde_json::json!(2);
1084
write_atomic_json(&tmp.0, "render.json", &render);
1085
write_atomic_json(
1086
&tmp.0,
1087
"complete.json",
1088
&serde_json::json!({
1089
"format_version": 3,
1090
"status": "complete",
1091
"identity": {
1092
"project_id": "demo",
1093
"project_name": "Demo",
1094
"sex": "male",
1095
"set": "body",
1096
"body_model_sha256": digest::sha256_hex(b"body"),
1097
"equipment_preset_sha256": null,
1098
"target": [1,1],
1099
"fps": 12,
1100
"framing": framing(),
1101
"pivot_source": {"kind": "manual"},
1102
"near": NEAR,
1103
"far": FAR,
1104
"clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],
1105
"saved_maps": ["diffuse","depth","normal","specular"]
1106
},
1107
"passes": 6,
1108
"png_files": 4,
1109
"png_sha256": checksums
1110
}),
1111
);
1112
assert!(matches!(
1113
ExportRun::open(&tmp.0),
1114
Err(ExportError::Identity(_))
1115
));
1116
}
1117
1118
#[test]
1119
fn rejects_bad_normal_and_wrong_depth_type() {
1120
let tmp = Temp::new();
1121
// Broken normal: transparent with non-zero RGB
1122
write_rgba(
1123
&tmp.0,
1124
Path::new("bad_normal.png"),
1125
&ImageBuf {
1126
width: 1,
1127
height: 1,
1128
rgba: vec![10, 0, 0, 0],
1129
},
1130
)
1131
.unwrap();
1132
let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1133
assert!(matches!(
1134
decode_map(&bytes, MapKind::Normal, 1, 1),
1135
Err(ExportError::Corrupt(_))
1136
));
1137
// RGBA written as depth should fail color type check
1138
write_rgba(
1139
&tmp.0,
1140
Path::new("fake_depth.png"),
1141
&ImageBuf {
1142
width: 1,
1143
height: 1,
1144
rgba: vec![0, 0, 0, 255],
1145
},
1146
)
1147
.unwrap();
1148
let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1149
assert!(matches!(
1150
decode_map(&bytes, MapKind::Depth, 1, 1),
1151
Err(ExportError::Corrupt(_))
1152
));
1153
}
1154
}
@@ -0,0 +1,1154 @@1
//! Safe reader/index for completed production export runs.2
//!3
//! A run directory is accepted only when both `render.json` (schema 3) and4
//! `complete.json` are present, their identity contracts match, every listed5
//! PNG checksum verifies, and each map decodes under the published pixel6
//! contract. Filenames alone never prove compatibility.7
use crate::{8
digest::{self, is_sha256_hex},9
production::{FAR, MAX_HIGHRES, NEAR, PASSES, SCALE},10
project::{self, CameraFraming},11
};12
use serde::Deserialize;13
use std::{14
collections::BTreeMap,15
fs,16
io::Read,17
path::{Path, PathBuf},18
};19
20
/// Output metadata schema written by production and required by this reader.21
pub const OUTPUT_META_VERSION: u32 = 3;22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;23
/// Hard ceiling for a single published PNG on disk (encoded).24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;25
/// Same batch file ceiling as production planning.26
pub const MAX_PNG_FILES: u32 = 500_000;27
const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024;28
const MAX_CLIP_NAME: usize = 256;29
const MAX_CLIPS: usize = 4096;30
31
#[derive(Debug, Clone, PartialEq, Eq)]32
pub enum ExportError {33
Incomplete(String),34
Identity(String),35
Io(String),36
Corrupt(String),37
Resource(String),38
Traversal(String),39
}40
41
impl std::fmt::Display for ExportError {42
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {43
match self {44
Self::Incomplete(m)45
| Self::Identity(m)46
| Self::Io(m)47
| Self::Corrupt(m)48
| Self::Resource(m)49
| Self::Traversal(m) => write!(f, "{m}"),50
}51
}52
}53
54
impl std::error::Error for ExportError {}55
56
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]57
pub enum MapKind {58
Diffuse,59
Normal,60
Specular,61
Depth,62
}63
64
impl MapKind {65
pub fn token(self) -> &'static str {66
match self {67
Self::Diffuse => "diffuse",68
Self::Normal => "normal",69
Self::Specular => "specular",70
Self::Depth => "depth",71
}72
}73
74
pub fn parse(token: &str) -> Option<Self> {75
match token {76
"diffuse" => Some(Self::Diffuse),77
"normal" => Some(Self::Normal),78
"specular" => Some(Self::Specular),79
"depth" => Some(Self::Depth),80
_ => None,81
}82
}83
}84
85
#[derive(Debug, Clone, PartialEq, Eq)]86
pub enum MapImage {87
Rgba8 {88
width: u32,89
height: u32,90
rgba: Vec<u8>,91
},92
Depth16 {93
width: u32,94
height: u32,95
/// Interleaved gray, alpha samples (length = width*height*2).96
samples: Vec<u16>,97
},98
}99
100
impl MapImage {101
pub fn width(&self) -> u32 {102
match self {103
Self::Rgba8 { width, .. } | Self::Depth16 { width, .. } => *width,104
}105
}106
pub fn height(&self) -> u32 {107
match self {108
Self::Rgba8 { height, .. } | Self::Depth16 { height, .. } => *height,109
}110
}111
}112
113
#[derive(Debug, Clone, Deserialize)]114
pub struct SourceRef {115
pub path: String,116
pub sha256: String,117
}118
119
#[derive(Debug, Clone, Deserialize)]120
pub struct PresetRef {121
pub name: String,122
pub sha256: String,123
}124
125
#[derive(Debug, Clone, Deserialize)]126
pub struct ClipRecord {127
pub name: String,128
pub token: String,129
pub frames: u32,130
pub duration: f32,131
}132
133
#[derive(Debug, Clone, Deserialize)]134
pub struct PivotSourceJson {135
pub kind: String,136
#[serde(default)]137
pub joint_index: Option<usize>,138
#[serde(default)]139
pub joint_name: Option<String>,140
#[serde(default)]141
pub reason: Option<String>,142
}143
144
#[derive(Debug, Clone, Deserialize)]145
pub struct RenderManifest {146
pub format_version: u32,147
pub status: String,148
pub project: ProjectRef,149
pub sex: String,150
pub set: String,151
pub body_model: SourceRef,152
#[serde(default)]153
pub equipment_preset: Option<PresetRef>,154
pub target: [u32; 2],155
pub fps: u32,156
pub highres_scale: u32,157
pub framing: CameraFraming,158
pub pivot_source: PivotSourceJson,159
pub near: f32,160
pub far: f32,161
pub camera_distance: f32,162
pub pass_order: Vec<String>,163
pub saved_maps: Vec<String>,164
pub clips: Vec<ClipRecord>,165
pub images: u32,166
pub passes: u32,167
pub png_files: u32,168
}169
170
#[derive(Debug, Clone, Deserialize)]171
pub struct ProjectRef {172
pub id: String,173
pub name: String,174
}175
176
#[derive(Debug, Clone, Deserialize)]177
pub struct CompleteIdentity {178
pub project_id: String,179
pub project_name: String,180
pub sex: String,181
pub set: String,182
pub body_model_sha256: String,183
#[serde(default)]184
pub equipment_preset_sha256: Option<String>,185
pub target: [u32; 2],186
pub fps: u32,187
pub framing: CameraFraming,188
pub pivot_source: PivotSourceJson,189
pub near: f32,190
pub far: f32,191
pub clips: Vec<ClipRecord>,192
pub saved_maps: Vec<String>,193
}194
195
#[derive(Debug, Clone, Deserialize)]196
pub struct CompleteManifest {197
pub format_version: u32,198
pub status: String,199
pub identity: CompleteIdentity,200
pub passes: u32,201
pub png_files: u32,202
pub png_sha256: BTreeMap<String, String>,203
}204
205
#[derive(Debug, Clone)]206
pub struct ExportRun {207
root: PathBuf,208
pub render: RenderManifest,209
pub complete: CompleteManifest,210
}211
212
impl ExportRun {213
pub fn root(&self) -> &Path {214
&self.root215
}216
217
pub fn open(run_dir: &Path) -> Result<Self, ExportError> {218
let root = canonicalize_dir(run_dir)?;219
if root.join("cancelled.json").exists() || root.join("failed.json").exists() {220
return Err(ExportError::Incomplete(221
"run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),222
));223
}224
let render_bytes = read_manifest_file(&root, "render.json")?;225
let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {226
if matches!(err, ExportError::Incomplete(_)) {227
ExportError::Incomplete(228
"complete.json ontbreekt; de exportset is niet volledig".into(),229
)230
} else {231
err232
}233
})?;234
let render: RenderManifest = serde_json::from_slice(&render_bytes)235
.map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;236
let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)237
.map_err(|e| ExportError::Corrupt(format!("complete.json: {e}")))?;238
validate_render(&render)?;239
validate_complete(&complete)?;240
identities_match(&render, &complete)?;241
if complete.png_sha256.len() as u32 != complete.png_files {242
return Err(ExportError::Corrupt(243
"complete.json png_sha256 telt niet gelijk aan png_files".into(),244
));245
}246
if complete.png_files != render.png_files {247
return Err(ExportError::Identity(248
"png_files in complete.json wijkt af van render.json".into(),249
));250
}251
// Verify every checksum exists as a bounded regular file under the run.252
for (relative, digest) in &complete.png_sha256 {253
let path = resolve_inside(&root, relative)?;254
verify_regular_file(&path, &root)?;255
let meta = fs::metadata(&path).map_err(|e| ExportError::Io(e.to_string()))?;256
if meta.len() > MAX_PNG_BYTES {257
return Err(ExportError::Resource(format!(258
"PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes"259
)));260
}261
let bytes = fs::read(&path).map_err(|e| ExportError::Io(e.to_string()))?;262
let actual = digest::sha256_hex(&bytes);263
if actual != *digest {264
return Err(ExportError::Corrupt(format!(265
"checksum mismatch voor '{relative}'"266
)));267
}268
}269
Ok(Self {270
root,271
render,272
complete,273
})274
}275
276
pub fn map_path(277
&self,278
clip_token: &str,279
frame: u32,280
pitch: i32,281
yaw: u32,282
kind: MapKind,283
) -> Result<String, ExportError> {284
let relative = crate::production::output_path(285
&self.render.sex,286
&self.render.set,287
clip_token,288
frame,289
pitch,290
yaw,291
kind.token(),292
)293
.map_err(ExportError::Identity)?;294
let key = relative295
.to_str()296
.ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?297
.replace('\\', "/");298
if !self.complete.png_sha256.contains_key(&key) {299
return Err(ExportError::Incomplete(format!(300
"map '{key}' staat niet in complete.json"301
)));302
}303
Ok(key)304
}305
306
pub fn load_map(307
&self,308
clip_token: &str,309
frame: u32,310
pitch: i32,311
yaw: u32,312
kind: MapKind,313
) -> Result<MapImage, ExportError> {314
let key = self.map_path(clip_token, frame, pitch, yaw, kind)?;315
self.load_relative(&key, kind)316
}317
318
pub fn load_relative(319
&self,320
relative: &str,321
expected: MapKind,322
) -> Result<MapImage, ExportError> {323
let expected_digest = self324
.complete325
.png_sha256326
.get(relative)327
.ok_or_else(|| ExportError::Incomplete(format!("onbekende map '{relative}'")))?;328
let path = resolve_inside(&self.root, relative)?;329
verify_regular_file(&path, &self.root)?;330
let bytes = read_bounded_file(&path, MAX_PNG_BYTES)?;331
let actual = digest::sha256_hex(&bytes);332
if actual != *expected_digest {333
return Err(ExportError::Corrupt(format!(334
"checksum mismatch voor '{relative}'"335
)));336
}337
let token = relative338
.rsplit('_')339
.next()340
.and_then(|s| s.strip_suffix(".png"))341
.ok_or_else(|| ExportError::Corrupt(format!("geen map-token in '{relative}'")))?;342
let kind = MapKind::parse(token).ok_or_else(|| {343
ExportError::Corrupt(format!("onbekend map-token '{token}' in '{relative}'"))344
})?;345
if kind != expected {346
return Err(ExportError::Identity(format!(347
"verwachtte {} maar pad is {}",348
expected.token(),349
kind.token()350
)));351
}352
let [width, height] = self.render.target;353
decode_map(&bytes, kind, width, height)354
}355
356
pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {357
self.complete358
.png_sha256359
.iter()360
.map(|(k, v)| (k.as_str(), v.as_str()))361
}362
}363
364
fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {365
if m.format_version != OUTPUT_META_VERSION {366
return Err(ExportError::Identity(format!(367
"render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})",368
m.format_version369
)));370
}371
if m.status != "incomplete" && m.status != "complete" {372
// production leaves status=incomplete in render.json even after success373
return Err(ExportError::Corrupt(format!(374
"onbekende render.json status '{}'",375
m.status376
)));377
}378
if !["male", "female"].contains(&m.sex.as_str()) {379
return Err(ExportError::Identity("ongeldig geslacht".into()));380
}381
if m.set != "body" && !crate::equipment::valid_name(&m.set) {382
return Err(ExportError::Identity("ongeldige setnaam".into()));383
}384
if m.target[0] == 0 || m.target[1] == 0 || m.target[0] > MAX_TARGET || m.target[1] > MAX_TARGET385
{386
return Err(ExportError::Resource("target buiten 1–256".into()));387
}388
if m.fps == 0 || m.fps > 240 {389
return Err(ExportError::Identity("ongeldige fps".into()));390
}391
if m.highres_scale != SCALE {392
return Err(ExportError::Identity("highres_scale wijkt af van 8".into()));393
}394
m.framing.validate().map_err(ExportError::Identity)?;395
if (m.near - NEAR).abs() > f32::EPSILON || (m.far - FAR).abs() > f32::EPSILON {396
return Err(ExportError::Identity(397
"near/far wijkt af van contract".into(),398
));399
}400
if (m.camera_distance - 10.).abs() > f32::EPSILON {401
return Err(ExportError::Identity(402
"camera_distance wijkt af van contract".into(),403
));404
}405
if m.pass_order != PASSES {406
return Err(ExportError::Identity(407
"pass_order wijkt af van contract".into(),408
));409
}410
if m.saved_maps != ["diffuse", "depth", "normal", "specular"] {411
return Err(ExportError::Identity(412
"saved_maps wijkt af van diffuse/depth/normal/specular".into(),413
));414
}415
if m.clips.is_empty() || m.clips.len() > MAX_CLIPS {416
return Err(ExportError::Resource("clip-inventaris ongeldig".into()));417
}418
for clip in &m.clips {419
if clip.token.is_empty()420
|| clip.token.len() > 64421
|| !crate::equipment::valid_name(&clip.token)422
{423
return Err(ExportError::Identity(format!(424
"ongeldige clip-token '{}'",425
clip.token426
)));427
}428
if clip.name.len() > MAX_CLIP_NAME {429
return Err(ExportError::Resource("clipnaam te lang".into()));430
}431
if clip.frames == 0 {432
return Err(ExportError::Identity("clip zonder frames".into()));433
}434
if !clip.duration.is_finite() || clip.duration < 0. {435
return Err(ExportError::Identity("ongeldige clipduur".into()));436
}437
}438
if m.png_files == 0 || m.png_files > MAX_PNG_FILES {439
return Err(ExportError::Resource("png_files buiten limiet".into()));440
}441
if !is_sha256_hex(&m.body_model.sha256) {442
return Err(ExportError::Corrupt(443
"body_model.sha256 is geen SHA-256 hex".into(),444
));445
}446
project::portable_relative(&m.body_model.path).map_err(ExportError::Traversal)?;447
if m.project.id.is_empty() || m.project.id.len() > 128 {448
return Err(ExportError::Identity("project.id ongeldig".into()));449
}450
if m.project.name.len() > 256 {451
return Err(ExportError::Identity("project.name te lang".into()));452
}453
match &m.equipment_preset {454
None if m.set == "body" => {}455
Some(p)456
if m.set == p.name457
&& is_sha256_hex(&p.sha256)458
&& crate::equipment::valid_name(&p.name) => {}459
_ => {460
return Err(ExportError::Identity(461
"equipment_preset past niet bij set of mist geldige sha256".into(),462
))463
}464
}465
match m.pivot_source.kind.as_str() {466
"manual" | "neck" | "fallback" => {}467
other => {468
return Err(ExportError::Identity(format!(469
"onbekende pivot_source.kind '{other}'"470
)))471
}472
}473
Ok(())474
}475
476
fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {477
if m.format_version != OUTPUT_META_VERSION {478
return Err(ExportError::Identity(format!(479
"complete.json format_version {} wordt niet ondersteund",480
m.format_version481
)));482
}483
if m.status != "complete" {484
return Err(ExportError::Incomplete(485
"complete.json status is niet 'complete'".into(),486
));487
}488
if m.png_files == 0 || m.png_files > MAX_PNG_FILES {489
return Err(ExportError::Resource("png_files buiten limiet".into()));490
}491
if m.png_sha256.len() > MAX_PNG_FILES as usize {492
return Err(ExportError::Resource("te veel png_sha256-items".into()));493
}494
if !is_sha256_hex(&m.identity.body_model_sha256) {495
return Err(ExportError::Corrupt(496
"identity.body_model_sha256 ongeldig".into(),497
));498
}499
if let Some(hash) = &m.identity.equipment_preset_sha256 {500
if !is_sha256_hex(hash) {501
return Err(ExportError::Corrupt(502
"identity.equipment_preset_sha256 ongeldig".into(),503
));504
}505
}506
for (path, hash) in &m.png_sha256 {507
project::portable_relative(path).map_err(ExportError::Traversal)?;508
if !path.ends_with(".png") {509
return Err(ExportError::Corrupt(format!(510
"png_sha256-sleutel '{path}' is geen .png"511
)));512
}513
if !is_sha256_hex(hash) {514
return Err(ExportError::Corrupt(format!(515
"png_sha256 voor '{path}' is geen SHA-256 hex"516
)));517
}518
}519
Ok(())520
}521
522
fn identities_match(523
render: &RenderManifest,524
complete: &CompleteManifest,525
) -> Result<(), ExportError> {526
let id = &complete.identity;527
let check = |ok: bool, msg: &str| {528
if ok {529
Ok(())530
} else {531
Err(ExportError::Identity(msg.into()))532
}533
};534
check(id.project_id == render.project.id, "project.id mismatch")?;535
check(536
id.project_name == render.project.name,537
"project.name mismatch",538
)?;539
check(id.sex == render.sex, "sex mismatch")?;540
check(id.set == render.set, "set mismatch")?;541
check(542
id.body_model_sha256 == render.body_model.sha256,543
"body_model sha256 mismatch",544
)?;545
check(id.target == render.target, "target mismatch")?;546
check(id.fps == render.fps, "fps mismatch")?;547
check(id.framing == render.framing, "framing mismatch")?;548
check(549
id.near == render.near && id.far == render.far,550
"near/far mismatch",551
)?;552
check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;553
check(554
id.clips.len() == render.clips.len(),555
"clips length mismatch",556
)?;557
for (a, b) in id.clips.iter().zip(render.clips.iter()) {558
if a.token != b.token || a.frames != b.frames || a.name != b.name {559
return Err(ExportError::Identity("clip inventory mismatch".into()));560
}561
}562
check(563
id.pivot_source.kind == render.pivot_source.kind,564
"pivot_source mismatch",565
)?;566
let preset_hash = render.equipment_preset.as_ref().map(|p| p.sha256.clone());567
check(568
id.equipment_preset_sha256 == preset_hash,569
"equipment preset sha256 mismatch",570
)?;571
Ok(())572
}573
574
fn decode_map(575
bytes: &[u8],576
kind: MapKind,577
expect_w: u32,578
expect_h: u32,579
) -> Result<MapImage, ExportError> {580
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));581
decoder.set_transformations(png::Transformations::IDENTITY);582
let mut reader = decoder583
.read_info()584
.map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;585
let info = reader.info();586
if info.width != expect_w || info.height != expect_h {587
return Err(ExportError::Corrupt(format!(588
"PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",589
info.width, info.height590
)));591
}592
if info.width > MAX_TARGET || info.height > MAX_TARGET {593
return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));594
}595
match kind {596
MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {597
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {598
return Err(ExportError::Corrupt(599
"RGBA8-map heeft onverwacht kleurtype/bitdiepte".into(),600
));601
}602
let size = reader.output_buffer_size().ok_or_else(|| {603
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())604
})?;605
let mut buf = vec![0u8; size];606
let frame = reader607
.next_frame(&mut buf)608
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;609
if frame.buffer_size() != expect_w as usize * expect_h as usize * 4 {610
return Err(ExportError::Corrupt("PNG buffergrootte klopt niet".into()));611
}612
buf.truncate(frame.buffer_size());613
if kind == MapKind::Normal {614
validate_normals(&buf, expect_w, expect_h)?;615
}616
if kind == MapKind::Specular {617
validate_specular_channels(&buf)?;618
}619
Ok(MapImage::Rgba8 {620
width: expect_w,621
height: expect_h,622
rgba: buf,623
})624
}625
MapKind::Depth => {626
if info.color_type != png::ColorType::GrayscaleAlpha627
|| info.bit_depth != png::BitDepth::Sixteen628
{629
return Err(ExportError::Corrupt(630
"depth-map moet gray-alpha 16-bit zijn".into(),631
));632
}633
let size = reader.output_buffer_size().ok_or_else(|| {634
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())635
})?;636
let mut buf = vec![0u8; size];637
let frame = reader638
.next_frame(&mut buf)639
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;640
let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes641
if frame.buffer_size() != expected {642
return Err(ExportError::Corrupt(643
"depth buffergrootte klopt niet".into(),644
));645
}646
buf.truncate(frame.buffer_size());647
let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);648
for chunk in buf.chunks_exact(2) {649
samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));650
}651
validate_depth_alpha(&samples)?;652
Ok(MapImage::Depth16 {653
width: expect_w,654
height: expect_h,655
samples,656
})657
}658
}659
}660
661
fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {662
for px in rgba.chunks_exact(4) {663
let a = px[3];664
if a == 0 {665
if px[0] != 0 || px[1] != 0 || px[2] != 0 {666
return Err(ExportError::Corrupt(667
"transparante normal heeft niet-nul RGB".into(),668
));669
}670
continue;671
}672
let nx = px[0] as f32 / 255. * 2. - 1.;673
let ny = px[1] as f32 / 255. * 2. - 1.;674
let nz = px[2] as f32 / 255. * 2. - 1.;675
let len = (nx * nx + ny * ny + nz * nz).sqrt();676
if !(0.85..=1.15).contains(&len) {677
return Err(ExportError::Corrupt(format!(678
"normal lengte {len} buiten tolerantie"679
)));680
}681
}682
Ok(())683
}684
685
fn validate_specular_channels(rgba: &[u8]) -> Result<(), ExportError> {686
for px in rgba.chunks_exact(4) {687
if px[0] != px[1] || px[1] != px[2] {688
return Err(ExportError::Corrupt(689
"specular RGB-kanalen zijn niet gelijk".into(),690
));691
}692
}693
Ok(())694
}695
696
fn validate_depth_alpha(samples: &[u16]) -> Result<(), ExportError> {697
for pair in samples.chunks_exact(2) {698
let gray = pair[0];699
let alpha = pair[1];700
if alpha == 0 && gray != 0 {701
return Err(ExportError::Corrupt(702
"transparante depth heeft niet-nul gray".into(),703
));704
}705
}706
Ok(())707
}708
709
fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {710
let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;711
if !meta.is_dir() {712
return Err(ExportError::Io("runpad is geen map".into()));713
}714
path.canonicalize()715
.map_err(|e| ExportError::Io(e.to_string()))716
}717
718
fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {719
let path = root.join(name);720
if !path.exists() {721
return Err(ExportError::Incomplete(format!("{name} ontbreekt")));722
}723
verify_regular_file(&path, root)?;724
read_bounded_file(&path, MAX_MANIFEST_BYTES)725
}726
727
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()))?;729
if meta.len() > max {730
return Err(ExportError::Resource(format!(731
"{} groter dan {max} bytes",732
path.display()733
)));734
}735
let file = fs::File::open(path).map_err(|e| ExportError::Io(e.to_string()))?;736
let mut bytes = Vec::new();737
file.take(max + 1)738
.read_to_end(&mut bytes)739
.map_err(|e| ExportError::Io(e.to_string()))?;740
if bytes.len() as u64 > max {741
return Err(ExportError::Resource(format!(742
"{} groter dan {max} bytes",743
path.display()744
)));745
}746
Ok(bytes)747
}748
749
fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {750
let clean = project::portable_relative(relative).map_err(ExportError::Traversal)?;751
let path = root.join(&clean);752
// Lexical rejection of oddities already handled; still verify canonical containment.753
Ok(path)754
}755
756
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()))?;758
if meta.file_type().is_symlink() {759
return Err(ExportError::Traversal(format!(760
"symlink geweigerd: {}",761
path.display()762
)));763
}764
if !meta.is_file() {765
return Err(ExportError::Io(format!(766
"geen regulier bestand: {}",767
path.display()768
)));769
}770
let root_real = root771
.canonicalize()772
.map_err(|e| ExportError::Io(e.to_string()))?;773
let path_real = path774
.canonicalize()775
.map_err(|e| ExportError::Io(e.to_string()))?;776
if !path_real.starts_with(&root_real) {777
return Err(ExportError::Traversal(format!(778
"pad verlaat runmap: {}",779
path.display()780
)));781
}782
Ok(())783
}784
785
/// Build the identity object embedded in complete.json (shared with production).786
pub fn complete_identity_json(render: &serde_json::Value) -> Result<serde_json::Value, String> {787
let project = render.get("project").ok_or("render.json mist project")?;788
Ok(serde_json::json!({789
"project_id": project.get("id").ok_or("project.id")?,790
"project_name": project.get("name").ok_or("project.name")?,791
"sex": render.get("sex").ok_or("sex")?,792
"set": render.get("set").ok_or("set")?,793
"body_model_sha256": render.pointer("/body_model/sha256").ok_or("body sha")?,794
"equipment_preset_sha256": render.pointer("/equipment_preset/sha256").cloned().unwrap_or(serde_json::Value::Null),795
"target": render.get("target").ok_or("target")?,796
"fps": render.get("fps").ok_or("fps")?,797
"framing": render.get("framing").ok_or("framing")?,798
"pivot_source": render.get("pivot_source").ok_or("pivot")?,799
"near": render.get("near").ok_or("near")?,800
"far": render.get("far").ok_or("far")?,801
"clips": render.get("clips").ok_or("clips")?,802
"saved_maps": render.get("saved_maps").ok_or("maps")?,803
}))804
}805
806
#[cfg(test)]807
mod tests {808
use super::*;809
use crate::production::{write_depth, write_rgba};810
use crate::render_job::ImageBuf;811
use std::sync::atomic::{AtomicU64, Ordering};812
813
struct Temp(PathBuf);814
impl Temp {815
fn new() -> Self {816
static N: AtomicU64 = AtomicU64::new(0);817
let id = N.fetch_add(1, Ordering::Relaxed);818
let path = std::env::temp_dir().join(format!(819
"paperdoll-export-set-{}-{}",820
std::process::id(),821
id822
));823
let _ = fs::remove_dir_all(&path);824
fs::create_dir_all(&path).unwrap();825
Self(path)826
}827
}828
impl Drop for Temp {829
fn drop(&mut self) {830
let _ = fs::remove_dir_all(&self.0);831
}832
}833
834
fn framing() -> CameraFraming {835
CameraFraming {836
height_milli: 2600,837
center_y_milli: 1000,838
pivot_y_milli: Some(1460),839
}840
}841
842
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {843
let data = serde_json::to_vec_pretty(value).unwrap();844
let path = root.join(name);845
let temp = path.with_extension(format!("part-{}", std::process::id()));846
fs::write(&temp, &data).unwrap();847
fs::hard_link(&temp, &path).unwrap();848
let _ = fs::remove_file(temp);849
}850
851
fn base_render(png_files: u32) -> serde_json::Value {852
serde_json::json!({853
"format_version": 3,854
"status": "incomplete",855
"project": {"id": "demo", "name": "Demo"},856
"sex": "male",857
"set": "body",858
"body_model": {"path": "assets/body/x.glb", "sha256": digest::sha256_hex(b"body")},859
"equipment_preset": null,860
"target": [1, 1],861
"fps": 12,862
"highres_scale": 8,863
"framing": framing(),864
"pivot_source": {"kind": "manual"},865
"near": NEAR,866
"far": FAR,867
"camera_distance": 10.0,868
"yaw_zero": "camera on -Z",869
"normal_space": "view (+Y up), PNG rows top-down",870
"pitches": [-30, 0, 30],871
"yaw_step": 30,872
"sampling": "test",873
"alpha": "straight",874
"diffuse": "unlit",875
"depth": "16-bit",876
"specular": "scalar",877
"pass_order": PASSES,878
"saved_maps": ["diffuse", "depth", "normal", "specular"],879
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],880
"images": 1,881
"passes": 6,882
"png_files": png_files883
})884
}885
886
fn publish_four(root: &Path) -> BTreeMap<String, String> {887
let mut map = BTreeMap::new();888
let rels = [889
(890
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",891
"diffuse",892
),893
(894
"male/body/clip000-bind/f000000/p-30_y000_depth.png",895
"depth",896
),897
(898
"male/body/clip000-bind/f000000/p-30_y000_normal.png",899
"normal",900
),901
(902
"male/body/clip000-bind/f000000/p-30_y000_specular.png",903
"specular",904
),905
];906
for (rel, kind) in rels {907
let path = Path::new(rel);908
match kind {909
"depth" => write_depth(root, path, 1, 1, &[20000, 65535]).unwrap(),910
"normal" => write_rgba(911
root,912
path,913
&ImageBuf {914
width: 1,915
height: 1,916
rgba: vec![128, 128, 255, 255],917
},918
)919
.unwrap(),920
"specular" => write_rgba(921
root,922
path,923
&ImageBuf {924
width: 1,925
height: 1,926
rgba: vec![0, 0, 0, 255],927
},928
)929
.unwrap(),930
_ => write_rgba(931
root,932
path,933
&ImageBuf {934
width: 1,935
height: 1,936
rgba: vec![10, 20, 30, 255],937
},938
)939
.unwrap(),940
};941
let bytes = fs::read(root.join(rel)).unwrap();942
map.insert(rel.to_string(), digest::sha256_hex(&bytes));943
}944
map945
}946
947
#[test]948
fn opens_completed_run_and_reads_maps() {949
let tmp = Temp::new();950
let checksums = publish_four(&tmp.0);951
let render = base_render(4);952
write_atomic_json(&tmp.0, "render.json", &render);953
let identity = complete_identity_json(&render).unwrap();954
write_atomic_json(955
&tmp.0,956
"complete.json",957
&serde_json::json!({958
"format_version": 3,959
"status": "complete",960
"identity": identity,961
"passes": 6,962
"png_files": 4,963
"png_sha256": checksums964
}),965
);966
let run = ExportRun::open(&tmp.0).unwrap();967
let diffuse = run968
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)969
.unwrap();970
match diffuse {971
MapImage::Rgba8 {972
width,973
height,974
rgba,975
} => {976
assert_eq!((width, height), (1, 1));977
assert_eq!(rgba, [10, 20, 30, 255]);978
}979
_ => panic!("expected rgba"),980
}981
let depth = run982
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)983
.unwrap();984
match depth {985
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),986
_ => panic!("expected depth"),987
}988
}989
990
#[test]991
fn rejects_missing_complete_and_checksum_mismatch() {992
let tmp = Temp::new();993
let render = base_render(4);994
write_atomic_json(&tmp.0, "render.json", &render);995
assert!(matches!(996
ExportRun::open(&tmp.0),997
Err(ExportError::Incomplete(_))998
));999
let mut checksums = publish_four(&tmp.0);1000
*checksums.values_mut().next().unwrap() = digest::sha256_hex(b"tampered");1001
let identity = complete_identity_json(&render).unwrap();1002
write_atomic_json(1003
&tmp.0,1004
"complete.json",1005
&serde_json::json!({1006
"format_version": 3,1007
"status": "complete",1008
"identity": identity,1009
"passes": 6,1010
"png_files": 4,1011
"png_sha256": checksums1012
}),1013
);1014
assert!(matches!(1015
ExportRun::open(&tmp.0),1016
Err(ExportError::Corrupt(_))1017
));1018
}1019
1020
#[test]1021
fn rejects_path_traversal_and_symlink_escape() {1022
let tmp = Temp::new();1023
let mut checksums = publish_four(&tmp.0);1024
checksums.insert("../escape.png".into(), digest::sha256_hex(b"x"));1025
let render = base_render(5);1026
write_atomic_json(&tmp.0, "render.json", &render);1027
let identity = complete_identity_json(&render).unwrap();1028
write_atomic_json(1029
&tmp.0,1030
"complete.json",1031
&serde_json::json!({1032
"format_version": 3,1033
"status": "complete",1034
"identity": identity,1035
"passes": 6,1036
"png_files": 5,1037
"png_sha256": checksums1038
}),1039
);1040
assert!(matches!(1041
ExportRun::open(&tmp.0),1042
Err(ExportError::Traversal(_))1043
));1044
1045
#[cfg(unix)]1046
{1047
let tmp = Temp::new();1048
let outside = Temp::new();1049
let checksums = publish_four(&tmp.0);1050
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";1051
let _ = fs::remove_file(tmp.0.join(evil));1052
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();1053
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();1054
let render = base_render(4);1055
write_atomic_json(&tmp.0, "render.json", &render);1056
let identity = complete_identity_json(&render).unwrap();1057
let mut checksums = checksums;1058
checksums.insert(evil.into(), digest::sha256_hex(b"not-a-png"));1059
write_atomic_json(1060
&tmp.0,1061
"complete.json",1062
&serde_json::json!({1063
"format_version": 3,1064
"status": "complete",1065
"identity": identity,1066
"passes": 6,1067
"png_files": 4,1068
"png_sha256": checksums1069
}),1070
);1071
assert!(matches!(1072
ExportRun::open(&tmp.0),1073
Err(ExportError::Traversal(_))1074
));1075
}1076
}1077
1078
#[test]1079
fn rejects_stale_schema_and_identity_drift() {1080
let tmp = Temp::new();1081
let checksums = publish_four(&tmp.0);1082
let mut render = base_render(4);1083
render["format_version"] = serde_json::json!(2);1084
write_atomic_json(&tmp.0, "render.json", &render);1085
write_atomic_json(1086
&tmp.0,1087
"complete.json",1088
&serde_json::json!({1089
"format_version": 3,1090
"status": "complete",1091
"identity": {1092
"project_id": "demo",1093
"project_name": "Demo",1094
"sex": "male",1095
"set": "body",1096
"body_model_sha256": digest::sha256_hex(b"body"),1097
"equipment_preset_sha256": null,1098
"target": [1,1],1099
"fps": 12,1100
"framing": framing(),1101
"pivot_source": {"kind": "manual"},1102
"near": NEAR,1103
"far": FAR,1104
"clips": [{"name":"","token":"clip000-bind","frames":1,"duration":0.0}],1105
"saved_maps": ["diffuse","depth","normal","specular"]1106
},1107
"passes": 6,1108
"png_files": 4,1109
"png_sha256": checksums1110
}),1111
);1112
assert!(matches!(1113
ExportRun::open(&tmp.0),1114
Err(ExportError::Identity(_))1115
));1116
}1117
1118
#[test]1119
fn rejects_bad_normal_and_wrong_depth_type() {1120
let tmp = Temp::new();1121
// Broken normal: transparent with non-zero RGB1122
write_rgba(1123
&tmp.0,1124
Path::new("bad_normal.png"),1125
&ImageBuf {1126
width: 1,1127
height: 1,1128
rgba: vec![10, 0, 0, 0],1129
},1130
)1131
.unwrap();1132
let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();1133
assert!(matches!(1134
decode_map(&bytes, MapKind::Normal, 1, 1),1135
Err(ExportError::Corrupt(_))1136
));1137
// RGBA written as depth should fail color type check1138
write_rgba(1139
&tmp.0,1140
Path::new("fake_depth.png"),1141
&ImageBuf {1142
width: 1,1143
height: 1,1144
rgba: vec![0, 0, 0, 255],1145
},1146
)1147
.unwrap();1148
let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();1149
assert!(matches!(1150
decode_map(&bytes, MapKind::Depth, 1, 1),1151
Err(ExportError::Corrupt(_))1152
));1153
}1154
}@@ -2,11 +2,15 @@
2
2
//!
3
3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4
4
//! draws skinned body/equipment previews, and streams production target maps.
5
//! Completed export runs are opened through `export_set` with checksum/identity
6
//! validation for later compositing.
5
7
6
8
pub mod camera;
7
9
pub mod dialogs;
10
pub mod digest;
8
11
pub mod editor;
9
12
pub mod equipment;
13
pub mod export_set;
10
14
pub mod file_assoc;
11
15
pub mod foundation;
12
16
pub mod icon;
@@ -2,11 +2,15 @@2
//!2
//!3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,4
//! draws skinned body/equipment previews, and streams production target maps.4
//! draws skinned body/equipment previews, and streams production target maps.5
//! Completed export runs are opened through `export_set` with checksum/identity6
//! validation for later compositing.5
7
6
pub mod camera;8
pub mod camera;7
pub mod dialogs;9
pub mod dialogs;10
pub mod digest;8
pub mod editor;11
pub mod editor;9
pub mod equipment;12
pub mod equipment;13
pub mod export_set;10
pub mod file_assoc;14
pub mod file_assoc;11
pub mod foundation;15
pub mod foundation;12
pub mod icon;16
pub mod icon;@@ -68,6 +68,13 @@ pub struct Target {
68
68
pub depth: Vec<u16>,
69
69
pub depth_preview: ImageBuf,
70
70
}
71
/// Content fingerprint of a source file used for a production job.
72
#[derive(Debug, Clone, PartialEq, Eq)]
73
pub struct SourceFingerprint {
74
pub path: String,
75
pub sha256: String,
76
}
77
71
78
#[derive(Clone)]
72
79
pub struct RenderJob {
73
80
pub character: PreviewCharacter,
@@ -78,6 +85,11 @@ pub struct RenderJob {
78
85
pub fps: u32,
79
86
pub framing: CameraFraming,
80
87
pub output: PathBuf,
88
pub project_id: String,
89
pub project_name: String,
90
pub body_model: SourceFingerprint,
91
/// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
92
pub preset_sha256: Option<String>,
81
93
}
82
94
#[derive(Debug, Clone, PartialEq)]
83
95
pub struct ClipPlan {
@@ -1059,7 +1071,7 @@ fn atomic_file(
1059
1071
}
1060
1072
result
1061
1073
}
1062
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> {
1074
fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1063
1075
if img.width == 0
1064
1076
|| img.height == 0
1065
1077
|| img.width > MAX_HIGHRES / SCALE
@@ -1068,32 +1080,24 @@ pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), St
1068
1080
{
1069
1081
return Err("ongeldige PNG-afmetingen".into());
1070
1082
}
1071
atomic_file(root, relative, |file| {
1072
let mut encoder = png::Encoder::new(file, img.width, img.height);
1083
let mut cursor = std::io::Cursor::new(Vec::new());
1084
{
1085
let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);
1073
1086
encoder.set_color(png::ColorType::Rgba);
1074
1087
encoder.set_depth(png::BitDepth::Eight);
1075
if relative
1076
.file_name()
1077
.and_then(|p| p.to_str())
1078
.map(|p| p.ends_with("_diffuse.png"))
1079
.unwrap_or(false)
1080
{
1088
if diffuse_srgb {
1081
1089
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1082
1090
}
1083
1091
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1084
1092
writer
1085
1093
.write_image_data(&img.rgba)
1086
1094
.map_err(|e| e.to_string())?;
1087
writer.finish().map_err(|e| e.to_string())
1088
})
1095
writer.finish().map_err(|e| e.to_string())?;
1096
}
1097
Ok(cursor.into_inner())
1089
1098
}
1090
pub fn write_depth(
1091
root: &Path,
1092
relative: &Path,
1093
w: u32,
1094
h: u32,
1095
depth: &[u16],
1096
) -> Result<(), String> {
1099
1100
fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {
1097
1101
if w == 0
1098
1102
|| h == 0
1099
1103
|| w > MAX_HIGHRES / SCALE
@@ -1102,16 +1106,54 @@ pub fn write_depth(
1102
1106
{
1103
1107
return Err("ongeldige 16-bit depthafmetingen".into());
1104
1108
}
1105
let bytes: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1106
atomic_file(root, relative, |file| {
1107
let mut encoder = png::Encoder::new(file, w, h);
1109
let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1110
let mut cursor = std::io::Cursor::new(Vec::new());
1111
{
1112
let mut encoder = png::Encoder::new(&mut cursor, w, h);
1108
1113
encoder.set_color(png::ColorType::GrayscaleAlpha);
1109
1114
encoder.set_depth(png::BitDepth::Sixteen);
1110
1115
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1111
writer.write_image_data(&bytes).map_err(|e| e.to_string())?;
1112
writer.finish().map_err(|e| e.to_string())
1113
})
1116
writer
1117
.write_image_data(&samples)
1118
.map_err(|e| e.to_string())?;
1119
writer.finish().map_err(|e| e.to_string())?;
1120
}
1121
Ok(cursor.into_inner())
1114
1122
}
1123
1124
/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.
1125
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {
1126
let diffuse_srgb = relative
1127
.file_name()
1128
.and_then(|p| p.to_str())
1129
.map(|p| p.ends_with("_diffuse.png"))
1130
.unwrap_or(false);
1131
let bytes = encode_rgba_png(img, diffuse_srgb)?;
1132
let digest = crate::digest::sha256_hex(&bytes);
1133
atomic_file(root, relative, |file| {
1134
use std::io::Write;
1135
file.write_all(&bytes).map_err(|e| e.to_string())
1136
})?;
1137
Ok(digest)
1138
}
1139
1140
/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1141
pub fn write_depth(
1142
root: &Path,
1143
relative: &Path,
1144
w: u32,
1145
h: u32,
1146
depth: &[u16],
1147
) -> Result<String, String> {
1148
let bytes = encode_depth_png(w, h, depth)?;
1149
let digest = crate::digest::sha256_hex(&bytes);
1150
atomic_file(root, relative, |file| {
1151
use std::io::Write;
1152
file.write_all(&bytes).map_err(|e| e.to_string())
1153
})?;
1154
Ok(digest)
1155
}
1156
1115
1157
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1116
1158
use std::io::Write;
1117
1159
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
@@ -1256,16 +1298,79 @@ fn execute(
1256
1298
.map(|p| p.name.as_str())
1257
1299
.unwrap_or("body")
1258
1300
.to_string();
1259
write_json(
1260
&root,
1261
"render.json",
1262
serde_json::json!({"format_version":2,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"pivot_source":pivot.source,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),
1263
)?;
1301
if job.project_id.is_empty() || job.project_id.len() > 128 {
1302
return Err("project.id ongeldig voor export-identiteit".into());
1303
}
1304
if job.project_name.len() > 256 {
1305
return Err("project.name te lang voor export-identiteit".into());
1306
}
1307
if !crate::digest::is_sha256_hex(&job.body_model.sha256) {
1308
return Err("body_model.sha256 ongeldig".into());
1309
}
1310
crate::project::portable_relative(&job.body_model.path)?;
1311
match (&job.preset, &job.preset_sha256) {
1312
(None, None) => {}
1313
(Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}
1314
_ => return Err("equipment fingerprint past niet bij de job".into()),
1315
}
1316
let equipment_preset = job.preset.as_ref().map(|p| {
1317
serde_json::json!({
1318
"name": p.name,
1319
"sha256": job.preset_sha256.as_ref().expect("checked"),
1320
})
1321
});
1322
let clips_json = plan
1323
.clips
1324
.iter()
1325
.enumerate()
1326
.map(|(i, c)| {
1327
serde_json::json!({
1328
"name": c.source_name,
1329
"token": c.token,
1330
"frames": c.frames,
1331
"duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),
1332
})
1333
})
1334
.collect::<Vec<_>>();
1335
let render_doc = serde_json::json!({
1336
"format_version": crate::export_set::OUTPUT_META_VERSION,
1337
"status": "incomplete",
1338
"project": {"id": job.project_id, "name": job.project_name},
1339
"sex": job.sex,
1340
"set": set,
1341
"body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},
1342
"equipment_preset": equipment_preset,
1343
"target": [job.target_width, job.target_height],
1344
"fps": job.fps,
1345
"highres_scale": SCALE,
1346
"framing": job.framing,
1347
"pivot_source": pivot.source,
1348
"near": NEAR,
1349
"far": FAR,
1350
"normal_space": "view (+Y up), PNG rows top-down",
1351
"camera_distance": 10,
1352
"yaw_zero": "camera on -Z",
1353
"pitches": [-30, 0, 30],
1354
"yaw_step": 30,
1355
"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",
1356
"alpha": "straight, opaque/cutout, coverage resolve",
1357
"diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1358
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
1359
"specular": "linear scalar; body defaults zero",
1360
"pass_order": PASSES,
1361
"saved_maps": ["diffuse", "depth", "normal", "specular"],
1362
"clips": clips_json,
1363
"images": plan.images,
1364
"passes": plan.passes,
1365
"png_files": plan.files,
1366
});
1367
write_json(&root, "render.json", render_doc.clone())?;
1264
1368
// Internal unique names select every source clip, even duplicate display names.
1265
1369
for (i, c) in job.character.clips.iter_mut().enumerate() {
1266
1370
c.name = format!("render-clip-{i}");
1267
1371
}
1268
1372
let mut completed = 0u32;
1373
let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1269
1374
let mut cache = equipment::TextureCache::default();
1270
1375
let result = (|| {
1271
1376
for sample in samples(&plan) {
@@ -1362,17 +1467,19 @@ fn execute(
1362
1467
label: format!("{pose} · {map}"),
1363
1468
});
1364
1469
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;
1365
if map == "depth" {
1470
let digest = if map == "depth" {
1366
1471
write_depth(
1367
1472
&root,
1368
1473
&path,
1369
1474
job.target_width,
1370
1475
job.target_height,
1371
1476
&target.depth,
1372
)?;
1477
)?
1373
1478
} else {
1374
write_rgba(&root, &path, &preview)?;
1375
}
1479
write_rgba(&root, &path, &preview)?
1480
};
1481
let key = path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
1482
png_sha256.insert(key, digest);
1376
1483
completed += 1;
1377
1484
emit(RenderEvent::Pass {
1378
1485
index: completed - 1,
@@ -1393,10 +1500,25 @@ fn execute(
1393
1500
})();
1394
1501
match result {
1395
1502
Ok(()) => {
1503
if png_sha256.len() as u32 != plan.files {
1504
return Err(format!(
1505
"checksumtelling {} wijkt af van geplande {} PNGs",
1506
png_sha256.len(),
1507
plan.files
1508
));
1509
}
1510
let identity = crate::export_set::complete_identity_json(&render_doc)?;
1396
1511
write_json(
1397
1512
&root,
1398
1513
"complete.json",
1399
serde_json::json!({"status":"complete","passes":completed,"png_files":plan.files}),
1514
serde_json::json!({
1515
"format_version": crate::export_set::OUTPUT_META_VERSION,
1516
"status": "complete",
1517
"identity": identity,
1518
"passes": completed,
1519
"png_files": plan.files,
1520
"png_sha256": png_sha256,
1521
}),
1400
1522
)?;
1401
1523
emit(RenderEvent::Finished {
1402
1524
saved: Some(root.display().to_string()),
@@ -1798,6 +1920,13 @@ mod tests {
1798
1920
pivot_y_milli: Some(1200),
1799
1921
},
1800
1922
output: temp.0.clone(),
1923
project_id: "test-project".into(),
1924
project_name: "Test".into(),
1925
body_model: SourceFingerprint {
1926
path: "assets/body/test.glb".into(),
1927
sha256: crate::digest::sha256_hex(b"test-body"),
1928
},
1929
preset_sha256: None,
1801
1930
};
1802
1931
let mut cache = equipment::TextureCache::default();
1803
1932
let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
@@ -1913,6 +2042,13 @@ mod tests {
1913
2042
fps: 12,
1914
2043
framing: CameraFraming::default(),
1915
2044
output: temp.0.clone(),
2045
project_id: "test-project".into(),
2046
project_name: "Test".into(),
2047
body_model: SourceFingerprint {
2048
path: "assets/body/test.glb".into(),
2049
sha256: crate::digest::sha256_hex(b"test-body"),
2050
},
2051
preset_sha256: None,
1916
2052
};
1917
2053
assert!(
1918
2054
scene(
@@ -1943,6 +2079,13 @@ mod tests {
1943
2079
fps: 12,
1944
2080
framing: CameraFraming::default(),
1945
2081
output: tmp.0.clone(),
2082
project_id: "test-project".into(),
2083
project_name: "Test".into(),
2084
body_model: SourceFingerprint {
2085
path: "assets/body/test.glb".into(),
2086
sha256: crate::digest::sha256_hex(b"test-body"),
2087
},
2088
preset_sha256: None,
1946
2089
};
1947
2090
let flag = AtomicBool::new(false);
1948
2091
let (tx, rx) = mpsc::channel::<GpuRequest>();
@@ -1977,7 +2120,12 @@ mod tests {
1977
2120
let meta: serde_json::Value =
1978
2121
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
1979
2122
.unwrap();
1980
assert_eq!(meta["format_version"], 2);
2123
assert_eq!(meta["format_version"], 3);
2124
assert_eq!(meta["project"]["id"], "test-project");
2125
assert_eq!(
2126
meta["body_model"]["sha256"],
2127
crate::digest::sha256_hex(b"test-body")
2128
);
1981
2129
assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
1982
2130
assert_eq!(meta["pivot_source"]["kind"], "neck");
1983
2131
assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
@@ -1990,6 +2138,33 @@ mod tests {
1990
2138
.collect::<Vec<_>>(),
1991
2139
["diffuse", "depth", "normal", "specular"]
1992
2140
);
2141
if !stop {
2142
let complete: serde_json::Value = serde_json::from_str(
2143
&std::fs::read_to_string(run.join("complete.json")).unwrap(),
2144
)
2145
.unwrap();
2146
assert_eq!(complete["format_version"], 3);
2147
assert_eq!(complete["status"], "complete");
2148
assert_eq!(complete["png_files"], 144);
2149
let checksums = complete["png_sha256"].as_object().unwrap();
2150
assert_eq!(checksums.len(), 144);
2151
for (rel, digest) in checksums {
2152
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2153
let bytes = std::fs::read(run.join(rel)).unwrap();
2154
assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2155
}
2156
let opened = crate::export_set::ExportRun::open(&run).unwrap();
2157
let sample = opened
2158
.load_map(
2159
"clip000-bind",
2160
0,
2161
-30,
2162
0,
2163
crate::export_set::MapKind::Diffuse,
2164
)
2165
.unwrap();
2166
assert_eq!(sample.width(), 1);
2167
}
1993
2168
let frame = run.join("male/body/clip000-bind/f000000");
1994
2169
assert!(frame.join("p-30_y000_diffuse.png").exists());
1995
2170
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
@@ -68,6 +68,13 @@ pub struct Target {68
pub depth: Vec<u16>,68
pub depth: Vec<u16>,69
pub depth_preview: ImageBuf,69
pub depth_preview: ImageBuf,70
}70
}71
/// Content fingerprint of a source file used for a production job.72
#[derive(Debug, Clone, PartialEq, Eq)]73
pub struct SourceFingerprint {74
pub path: String,75
pub sha256: String,76
}77
71
#[derive(Clone)]78
#[derive(Clone)]72
pub struct RenderJob {79
pub struct RenderJob {73
pub character: PreviewCharacter,80
pub character: PreviewCharacter,@@ -78,6 +85,11 @@ pub struct RenderJob {78
pub fps: u32,85
pub fps: u32,79
pub framing: CameraFraming,86
pub framing: CameraFraming,80
pub output: PathBuf,87
pub output: PathBuf,88
pub project_id: String,89
pub project_name: String,90
pub body_model: SourceFingerprint,91
/// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.92
pub preset_sha256: Option<String>,81
}93
}82
#[derive(Debug, Clone, PartialEq)]94
#[derive(Debug, Clone, PartialEq)]83
pub struct ClipPlan {95
pub struct ClipPlan {@@ -1059,7 +1071,7 @@ fn atomic_file(1059
}1071
}1060
result1072
result1061
}1073
}1062
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> {1074
fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {1063
if img.width == 01075
if img.width == 01064
|| img.height == 01076
|| img.height == 01065
|| img.width > MAX_HIGHRES / SCALE1077
|| img.width > MAX_HIGHRES / SCALE@@ -1068,32 +1080,24 @@ pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), St1068
{1080
{1069
return Err("ongeldige PNG-afmetingen".into());1081
return Err("ongeldige PNG-afmetingen".into());1070
}1082
}1071
atomic_file(root, relative, |file| {1083
let mut cursor = std::io::Cursor::new(Vec::new());1072
let mut encoder = png::Encoder::new(file, img.width, img.height);1084
{1085
let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);1073
encoder.set_color(png::ColorType::Rgba);1086
encoder.set_color(png::ColorType::Rgba);1074
encoder.set_depth(png::BitDepth::Eight);1087
encoder.set_depth(png::BitDepth::Eight);1075
if relative1088
if diffuse_srgb {1076
.file_name()1077
.and_then(|p| p.to_str())1078
.map(|p| p.ends_with("_diffuse.png"))1079
.unwrap_or(false)1080
{1081
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);1089
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);1082
}1090
}1083
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1091
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1084
writer1092
writer1085
.write_image_data(&img.rgba)1093
.write_image_data(&img.rgba)1086
.map_err(|e| e.to_string())?;1094
.map_err(|e| e.to_string())?;1087
writer.finish().map_err(|e| e.to_string())1095
writer.finish().map_err(|e| e.to_string())?;1088
})1096
}1097
Ok(cursor.into_inner())1089
}1098
}1090
pub fn write_depth(1099
1091
root: &Path,1100
fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {1092
relative: &Path,1093
w: u32,1094
h: u32,1095
depth: &[u16],1096
) -> Result<(), String> {1097
if w == 01101
if w == 01098
|| h == 01102
|| h == 01099
|| w > MAX_HIGHRES / SCALE1103
|| w > MAX_HIGHRES / SCALE@@ -1102,16 +1106,54 @@ pub fn write_depth(1102
{1106
{1103
return Err("ongeldige 16-bit depthafmetingen".into());1107
return Err("ongeldige 16-bit depthafmetingen".into());1104
}1108
}1105
let bytes: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();1109
let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();1106
atomic_file(root, relative, |file| {1110
let mut cursor = std::io::Cursor::new(Vec::new());1107
let mut encoder = png::Encoder::new(file, w, h);1111
{1112
let mut encoder = png::Encoder::new(&mut cursor, w, h);1108
encoder.set_color(png::ColorType::GrayscaleAlpha);1113
encoder.set_color(png::ColorType::GrayscaleAlpha);1109
encoder.set_depth(png::BitDepth::Sixteen);1114
encoder.set_depth(png::BitDepth::Sixteen);1110
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1115
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1111
writer.write_image_data(&bytes).map_err(|e| e.to_string())?;1116
writer1112
writer.finish().map_err(|e| e.to_string())1117
.write_image_data(&samples)1113
})1118
.map_err(|e| e.to_string())?;1119
writer.finish().map_err(|e| e.to_string())?;1120
}1121
Ok(cursor.into_inner())1114
}1122
}1123
1124
/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.1125
pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {1126
let diffuse_srgb = relative1127
.file_name()1128
.and_then(|p| p.to_str())1129
.map(|p| p.ends_with("_diffuse.png"))1130
.unwrap_or(false);1131
let bytes = encode_rgba_png(img, diffuse_srgb)?;1132
let digest = crate::digest::sha256_hex(&bytes);1133
atomic_file(root, relative, |file| {1134
use std::io::Write;1135
file.write_all(&bytes).map_err(|e| e.to_string())1136
})?;1137
Ok(digest)1138
}1139
1140
/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.1141
pub fn write_depth(1142
root: &Path,1143
relative: &Path,1144
w: u32,1145
h: u32,1146
depth: &[u16],1147
) -> Result<String, String> {1148
let bytes = encode_depth_png(w, h, depth)?;1149
let digest = crate::digest::sha256_hex(&bytes);1150
atomic_file(root, relative, |file| {1151
use std::io::Write;1152
file.write_all(&bytes).map_err(|e| e.to_string())1153
})?;1154
Ok(digest)1155
}1156
1115
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {1157
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {1116
use std::io::Write;1158
use std::io::Write;1117
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;1159
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;@@ -1256,16 +1298,79 @@ fn execute(1256
.map(|p| p.name.as_str())1298
.map(|p| p.name.as_str())1257
.unwrap_or("body")1299
.unwrap_or("body")1258
.to_string();1300
.to_string();1259
write_json(1301
if job.project_id.is_empty() || job.project_id.len() > 128 {1260
&root,1302
return Err("project.id ongeldig voor export-identiteit".into());1261
"render.json",1303
}1262
serde_json::json!({"format_version":2,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"pivot_source":pivot.source,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::<Vec<_>>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}),1304
if job.project_name.len() > 256 {1263
)?;1305
return Err("project.name te lang voor export-identiteit".into());1306
}1307
if !crate::digest::is_sha256_hex(&job.body_model.sha256) {1308
return Err("body_model.sha256 ongeldig".into());1309
}1310
crate::project::portable_relative(&job.body_model.path)?;1311
match (&job.preset, &job.preset_sha256) {1312
(None, None) => {}1313
(Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}1314
_ => return Err("equipment fingerprint past niet bij de job".into()),1315
}1316
let equipment_preset = job.preset.as_ref().map(|p| {1317
serde_json::json!({1318
"name": p.name,1319
"sha256": job.preset_sha256.as_ref().expect("checked"),1320
})1321
});1322
let clips_json = plan1323
.clips1324
.iter()1325
.enumerate()1326
.map(|(i, c)| {1327
serde_json::json!({1328
"name": c.source_name,1329
"token": c.token,1330
"frames": c.frames,1331
"duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),1332
})1333
})1334
.collect::<Vec<_>>();1335
let render_doc = serde_json::json!({1336
"format_version": crate::export_set::OUTPUT_META_VERSION,1337
"status": "incomplete",1338
"project": {"id": job.project_id, "name": job.project_name},1339
"sex": job.sex,1340
"set": set,1341
"body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},1342
"equipment_preset": equipment_preset,1343
"target": [job.target_width, job.target_height],1344
"fps": job.fps,1345
"highres_scale": SCALE,1346
"framing": job.framing,1347
"pivot_source": pivot.source,1348
"near": NEAR,1349
"far": FAR,1350
"normal_space": "view (+Y up), PNG rows top-down",1351
"camera_distance": 10,1352
"yaw_zero": "camera on -Z",1353
"pitches": [-30, 0, 30],1354
"yaw_step": 30,1355
"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",1356
"alpha": "straight, opaque/cutout, coverage resolve",1357
"diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",1358
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",1359
"specular": "linear scalar; body defaults zero",1360
"pass_order": PASSES,1361
"saved_maps": ["diffuse", "depth", "normal", "specular"],1362
"clips": clips_json,1363
"images": plan.images,1364
"passes": plan.passes,1365
"png_files": plan.files,1366
});1367
write_json(&root, "render.json", render_doc.clone())?;1264
// Internal unique names select every source clip, even duplicate display names.1368
// Internal unique names select every source clip, even duplicate display names.1265
for (i, c) in job.character.clips.iter_mut().enumerate() {1369
for (i, c) in job.character.clips.iter_mut().enumerate() {1266
c.name = format!("render-clip-{i}");1370
c.name = format!("render-clip-{i}");1267
}1371
}1268
let mut completed = 0u32;1372
let mut completed = 0u32;1373
let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();1269
let mut cache = equipment::TextureCache::default();1374
let mut cache = equipment::TextureCache::default();1270
let result = (|| {1375
let result = (|| {1271
for sample in samples(&plan) {1376
for sample in samples(&plan) {@@ -1362,17 +1467,19 @@ fn execute(1362
label: format!("{pose} · {map}"),1467
label: format!("{pose} · {map}"),1363
});1468
});1364
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;1469
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;1365
if map == "depth" {1470
let digest = if map == "depth" {1366
write_depth(1471
write_depth(1367
&root,1472
&root,1368
&path,1473
&path,1369
job.target_width,1474
job.target_width,1370
job.target_height,1475
job.target_height,1371
&target.depth,1476
&target.depth,1372
)?;1477
)?1373
} else {1478
} else {1374
write_rgba(&root, &path, &preview)?;1479
write_rgba(&root, &path, &preview)?1375
}1480
};1481
let key = path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");1482
png_sha256.insert(key, digest);1376
completed += 1;1483
completed += 1;1377
emit(RenderEvent::Pass {1484
emit(RenderEvent::Pass {1378
index: completed - 1,1485
index: completed - 1,@@ -1393,10 +1500,25 @@ fn execute(1393
})();1500
})();1394
match result {1501
match result {1395
Ok(()) => {1502
Ok(()) => {1503
if png_sha256.len() as u32 != plan.files {1504
return Err(format!(1505
"checksumtelling {} wijkt af van geplande {} PNGs",1506
png_sha256.len(),1507
plan.files1508
));1509
}1510
let identity = crate::export_set::complete_identity_json(&render_doc)?;1396
write_json(1511
write_json(1397
&root,1512
&root,1398
"complete.json",1513
"complete.json",1399
serde_json::json!({"status":"complete","passes":completed,"png_files":plan.files}),1514
serde_json::json!({1515
"format_version": crate::export_set::OUTPUT_META_VERSION,1516
"status": "complete",1517
"identity": identity,1518
"passes": completed,1519
"png_files": plan.files,1520
"png_sha256": png_sha256,1521
}),1400
)?;1522
)?;1401
emit(RenderEvent::Finished {1523
emit(RenderEvent::Finished {1402
saved: Some(root.display().to_string()),1524
saved: Some(root.display().to_string()),@@ -1798,6 +1920,13 @@ mod tests {1798
pivot_y_milli: Some(1200),1920
pivot_y_milli: Some(1200),1799
},1921
},1800
output: temp.0.clone(),1922
output: temp.0.clone(),1923
project_id: "test-project".into(),1924
project_name: "Test".into(),1925
body_model: SourceFingerprint {1926
path: "assets/body/test.glb".into(),1927
sha256: crate::digest::sha256_hex(b"test-body"),1928
},1929
preset_sha256: None,1801
};1930
};1802
let mut cache = equipment::TextureCache::default();1931
let mut cache = equipment::TextureCache::default();1803
let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();1932
let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();@@ -1913,6 +2042,13 @@ mod tests {1913
fps: 12,2042
fps: 12,1914
framing: CameraFraming::default(),2043
framing: CameraFraming::default(),1915
output: temp.0.clone(),2044
output: temp.0.clone(),2045
project_id: "test-project".into(),2046
project_name: "Test".into(),2047
body_model: SourceFingerprint {2048
path: "assets/body/test.glb".into(),2049
sha256: crate::digest::sha256_hex(b"test-body"),2050
},2051
preset_sha256: None,1916
};2052
};1917
assert!(2053
assert!(1918
scene(2054
scene(@@ -1943,6 +2079,13 @@ mod tests {1943
fps: 12,2079
fps: 12,1944
framing: CameraFraming::default(),2080
framing: CameraFraming::default(),1945
output: tmp.0.clone(),2081
output: tmp.0.clone(),2082
project_id: "test-project".into(),2083
project_name: "Test".into(),2084
body_model: SourceFingerprint {2085
path: "assets/body/test.glb".into(),2086
sha256: crate::digest::sha256_hex(b"test-body"),2087
},2088
preset_sha256: None,1946
};2089
};1947
let flag = AtomicBool::new(false);2090
let flag = AtomicBool::new(false);1948
let (tx, rx) = mpsc::channel::<GpuRequest>();2091
let (tx, rx) = mpsc::channel::<GpuRequest>();@@ -1977,7 +2120,12 @@ mod tests {1977
let meta: serde_json::Value =2120
let meta: serde_json::Value =1978
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2121
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())1979
.unwrap();2122
.unwrap();1980
assert_eq!(meta["format_version"], 2);2123
assert_eq!(meta["format_version"], 3);2124
assert_eq!(meta["project"]["id"], "test-project");2125
assert_eq!(2126
meta["body_model"]["sha256"],2127
crate::digest::sha256_hex(b"test-body")2128
);1981
assert_eq!(meta["framing"]["pivot_y_milli"], 1460);2129
assert_eq!(meta["framing"]["pivot_y_milli"], 1460);1982
assert_eq!(meta["pivot_source"]["kind"], "neck");2130
assert_eq!(meta["pivot_source"]["kind"], "neck");1983
assert_eq!(meta["pivot_source"]["joint_name"], "Neck");2131
assert_eq!(meta["pivot_source"]["joint_name"], "Neck");@@ -1990,6 +2138,33 @@ mod tests {1990
.collect::<Vec<_>>(),2138
.collect::<Vec<_>>(),1991
["diffuse", "depth", "normal", "specular"]2139
["diffuse", "depth", "normal", "specular"]1992
);2140
);2141
if !stop {2142
let complete: serde_json::Value = serde_json::from_str(2143
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2144
)2145
.unwrap();2146
assert_eq!(complete["format_version"], 3);2147
assert_eq!(complete["status"], "complete");2148
assert_eq!(complete["png_files"], 144);2149
let checksums = complete["png_sha256"].as_object().unwrap();2150
assert_eq!(checksums.len(), 144);2151
for (rel, digest) in checksums {2152
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));2153
let bytes = std::fs::read(run.join(rel)).unwrap();2154
assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));2155
}2156
let opened = crate::export_set::ExportRun::open(&run).unwrap();2157
let sample = opened2158
.load_map(2159
"clip000-bind",2160
0,2161
-30,2162
0,2163
crate::export_set::MapKind::Diffuse,2164
)2165
.unwrap();2166
assert_eq!(sample.width(), 1);2167
}1993
let frame = run.join("male/body/clip000-bind/f000000");2168
let frame = run.join("male/body/clip000-bind/f000000");1994
assert!(frame.join("p-30_y000_diffuse.png").exists());2169
assert!(frame.join("p-30_y000_diffuse.png").exists());1995
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);2170
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);