BlueGit

golden-age paperdoll-renderer

feat: export-set identity (schema 3) and safe map reader

Bump production render.json/complete.json to schema 3 with project
identity, body/equipment SHA-256 fingerprints, resolved camera/pivot
contract, clip inventory and per-PNG checksums. Add export_set reader
that opens only completed runs with matching identity, rejects
traversal/symlinks/stale schemas/corrupt maps, and decodes real RGBA8
and gray-alpha16 depth under production caps. Keep atomic no-clobber
hardlink publication; incomplete runs never get complete.json.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
3261fc91f13d5af3a86556b35fe0e5c2bd9a077a
Parent
f1fdc3a4
8 files changed +1661 −40
README.md +2 −2
@@ -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 **Opsla
57
57
58De 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.
58De 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
60Per 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.
60Per 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```text
62```text
63renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
63renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
64```
64```
65
65
66Ieder 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`.
66Ieder 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
68Highres 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.
68Highres 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
design.md +5 −3
@@ -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 algorithm
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## 10. Rendered 2D preview
181## 10. Rendered 2D preview
180
182
181Select 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.
183Select 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
183Left/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.
185Left/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- 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
218Current 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.
220Current 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 gates
222## 12. Incremental feature plan and acceptance gates
221
223
examples/neck_pivot_probe.rs +7 −0
@@ -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) }?;
src/digest.rs added +242 −0
@@ -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
4const 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)]
16pub struct Sha256 {
17 state: [u32; 8],
18 buffer: [u8; 64],
19 buffer_len: usize,
20 bit_len: u64,
21}
22
23impl Default for Sha256 {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl 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
151pub fn sha256(data: &[u8]) -> [u8; 32] {
152 let mut h = Sha256::new();
153 h.update(data);
154 h.finalize()
155}
156
157pub fn sha256_hex(data: &[u8]) -> String {
158 hex(&sha256(data))
159}
160
161pub 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
171pub 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.
179pub 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)]
207mod 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}
src/editor.rs +37 −0
@@ -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();
src/export_set.rs added +1154 −0
@@ -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) 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.
7use crate::{
8 digest::{self, is_sha256_hex},
9 production::{FAR, MAX_HIGHRES, NEAR, PASSES, SCALE},
10 project::{self, CameraFraming},
11};
12use serde::Deserialize;
13use 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.
21pub const OUTPUT_META_VERSION: u32 = 3;
22pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
23/// Hard ceiling for a single published PNG on disk (encoded).
24pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
25/// Same batch file ceiling as production planning.
26pub const MAX_PNG_FILES: u32 = 500_000;
27const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024;
28const MAX_CLIP_NAME: usize = 256;
29const MAX_CLIPS: usize = 4096;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ExportError {
33 Incomplete(String),
34 Identity(String),
35 Io(String),
36 Corrupt(String),
37 Resource(String),
38 Traversal(String),
39}
40
41impl 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
54impl std::error::Error for ExportError {}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum MapKind {
58 Diffuse,
59 Normal,
60 Specular,
61 Depth,
62}
63
64impl 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)]
86pub 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
100impl 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)]
114pub struct SourceRef {
115 pub path: String,
116 pub sha256: String,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120pub struct PresetRef {
121 pub name: String,
122 pub sha256: String,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126pub 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)]
134pub 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)]
145pub 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)]
171pub struct ProjectRef {
172 pub id: String,
173 pub name: String,
174}
175
176#[derive(Debug, Clone, Deserialize)]
177pub 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)]
196pub 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)]
206pub struct ExportRun {
207 root: PathBuf,
208 pub render: RenderManifest,
209 pub complete: CompleteManifest,
210}
211
212impl 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
364fn 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
476fn 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
522fn 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
574fn 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
661fn 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
685fn 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
696fn 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
709fn 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
718fn 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
727fn 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
749fn 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
756fn 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).
786pub 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)]
807mod 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}
src/lib.rs +4 −0
@@ -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/identity
6//! validation for later compositing.
5
7
6pub mod camera;
8pub mod camera;
7pub mod dialogs;
9pub mod dialogs;
10pub mod digest;
8pub mod editor;
11pub mod editor;
9pub mod equipment;
12pub mod equipment;
13pub mod export_set;
10pub mod file_assoc;
14pub mod file_assoc;
11pub mod foundation;
15pub mod foundation;
12pub mod icon;
16pub mod icon;
src/production.rs +210 −35
@@ -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)]
73pub struct SourceFingerprint {
74 pub path: String,
75 pub sha256: String,
76}
77
71#[derive(Clone)]
78#[derive(Clone)]
72pub struct RenderJob {
79pub 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)]
83pub struct ClipPlan {
95pub struct ClipPlan {
@@ -1059,7 +1071,7 @@ fn atomic_file(
1059 }
1071 }
1060 result
1072 result
1061}
1073}
1062pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> {
1074fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1063 if img.width == 0
1075 if img.width == 0
1064 || img.height == 0
1076 || img.height == 0
1065 || img.width > MAX_HIGHRES / SCALE
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 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 relative
1088 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 writer
1092 writer
1085 .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}
1090pub fn write_depth(
1099
1091 root: &Path,
1100fn 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 == 0
1101 if w == 0
1098 || h == 0
1102 || h == 0
1099 || w > MAX_HIGHRES / SCALE
1103 || 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 writer
1112 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.
1125pub 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.
1141pub 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
1115fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1157fn 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 = 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 // 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.files
1508 ));
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 = 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 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);