BlueGit

golden-age paperdoll-renderer

Merge feature/clean-final-folder: consumer-only result folder (Tim t993u/t994u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
570d2616902c3c0059b891d964a77134453ae511
Pipeline
Passed
8 files changed +387 −88
README.md +22 −5
@@ -111,7 +111,7 @@ Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.
111 111 - `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.
112 112 - `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).
113 113 - `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.
114 Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.
114 Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang; 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.
115 115
116 116 ```text
117 117 renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
@@ -119,16 +119,33 @@ renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -spec
119 119 renders/male-body-atlas/male-body-atlas.ppda
120 120 ```
121 121
122 Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) betekent een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
122 Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding.
123
124 Een voltooide resultaatmap bevat alleen uitvoer voor de gebruiker:
125
126 ```
127 renders/male-body-atlas/
128 male-body-atlas.ppda
129 male-body-diffuse-atlas.png
130 male-body-normal-atlas.png
131 male-body-depth-atlas.png
132 male-body-specular-atlas.png
133 raw files/
134 male/body/<clip>/f000000/p-60_y000_diffuse.png …
135 ```
136
137 Alle losse beelden per frame staan gegroepeerd onder `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` en de markers van eerdere pogingen bestaan alleen in de tijdelijke `inprogress-*`-map; ze worden verwijderd nadat de render gecontroleerd is en vóór de map op zijn plaats komt. Wat daarna nog nodig is (het exportcontract en de SHA-256 van iedere losse PNG) staat in de `.ppda`. Alleen na beschadiging tijdens een hervatte render kan er nog een bewaard `.corrupt-aNNN-K`-bestand naast staan. Oudere resultaatmappen (losse beelden en `render.json`/`complete.json` direct in de map) worden nog gelezen.
138
139 Tijdens het bouwen: `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. In de tijdelijke map betekent alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` leest een voltooide resultaatmap via de `.ppda` (oudere mappen via hun `render.json`/`complete.json`), opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
123 140
124 141 ### Atlassen en .ppda
125 142
126 Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
143 Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json` (alleen in de tijdelijke map).
127 144
128 145 - Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.
129 146 - De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.
130 147 - Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.
131 - `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 3 (versie 2 wordt nog gelezen als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels).
148 - `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 4 (versies 2–3 worden nog gelezen; versie 2 als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels) en `export` (vanaf versie 4: het volledige exportcontract, voorheen `render.json`, het aantal passes en `png_sha256`, de SHA-256 per losse PNG onder `raw files/`).
132 149 - De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.
133 150
134 151 ### Hervatten
@@ -139,7 +156,7 @@ Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de r
139 156 - Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
140 157 - Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
141 158 - Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).
142 - De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.
159 - De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven. Daarna worden deze administratiebestanden opgeruimd (`complete.json` als laatste) en komt de map op zijn plaats. Een crash tussen opruimen en plaatsen wordt bij de volgende render hersteld: de voltooide map wordt alsnog opgeschoond en geplaatst.
143 160
144 161 ![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
145 162
@@ -111,7 +111,7 @@ Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.
111- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.
111- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.
112- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).
112- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).
113- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.
113- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.
114Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.
114Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang; 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.
115
115
116```text
116```text
117renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
117renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
@@ -119,16 +119,33 @@ renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -spec
119renders/male-body-atlas/male-body-atlas.ppda
119renders/male-body-atlas/male-body-atlas.ppda
120```
120```
121
121
122Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) betekent een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
122Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding.
123
124Een voltooide resultaatmap bevat alleen uitvoer voor de gebruiker:
125
126```
127renders/male-body-atlas/
128 male-body-atlas.ppda
129 male-body-diffuse-atlas.png
130 male-body-normal-atlas.png
131 male-body-depth-atlas.png
132 male-body-specular-atlas.png
133 raw files/
134 male/body/<clip>/f000000/p-60_y000_diffuse.png …
135```
136
137Alle losse beelden per frame staan gegroepeerd onder `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` en de markers van eerdere pogingen bestaan alleen in de tijdelijke `inprogress-*`-map; ze worden verwijderd nadat de render gecontroleerd is en vóór de map op zijn plaats komt. Wat daarna nog nodig is (het exportcontract en de SHA-256 van iedere losse PNG) staat in de `.ppda`. Alleen na beschadiging tijdens een hervatte render kan er nog een bewaard `.corrupt-aNNN-K`-bestand naast staan. Oudere resultaatmappen (losse beelden en `render.json`/`complete.json` direct in de map) worden nog gelezen.
138
139Tijdens het bouwen: `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. In de tijdelijke map betekent alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` leest een voltooide resultaatmap via de `.ppda` (oudere mappen via hun `render.json`/`complete.json`), opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
123
140
124### Atlassen en .ppda
141### Atlassen en .ppda
125
142
126Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
143Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json` (alleen in de tijdelijke map).
127
144
128- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.
145- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.
129- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.
146- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.
130- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.
147- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.
131- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 3 (versie 2 wordt nog gelezen als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels).
148- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 4 (versies 2–3 worden nog gelezen; versie 2 als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels) en `export` (vanaf versie 4: het volledige exportcontract, voorheen `render.json`, het aantal passes en `png_sha256`, de SHA-256 per losse PNG onder `raw files/`).
132- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.
149- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.
133
150
134### Hervatten
151### Hervatten
@@ -139,7 +156,7 @@ Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de r
139- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
156- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
140- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
157- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
141- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).
158- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).
142- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.
159- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven. Daarna worden deze administratiebestanden opgeruimd (`complete.json` als laatste) en komt de map op zijn plaats. Een crash tussen opruimen en plaatsen wordt bij de volgende render hersteld: de voltooide map wordt alsnog opgeschoond en geplaatst.
143
160
144![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
161![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
145
162
examples/preview2d_smoke.rs +1 −1
@@ -154,7 +154,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
154 154 session.save()?;
155 155 install_body_export(&project)?;
156 156 // Prove the preview reads the atlases: remove every loose PNG.
157 fs::remove_dir_all(project.join("renders/male-body-atlas/male"))?;
157 fs::remove_dir_all(project.join("renders/male-body-atlas/raw files"))?;
158 158 project
159 159 }
160 160 };
@@ -154,7 +154,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
154 session.save()?;
154 session.save()?;
155 install_body_export(&project)?;
155 install_body_export(&project)?;
156 // Prove the preview reads the atlases: remove every loose PNG.
156 // Prove the preview reads the atlases: remove every loose PNG.
157 fs::remove_dir_all(project.join("renders/male-body-atlas/male"))?;
157 fs::remove_dir_all(project.join("renders/male-body-atlas/raw files"))?;
158 project
158 project
159 }
159 }
160 };
160 };
src/atlas.rs +86 −11
@@ -31,7 +31,7 @@ use std::{
31 31 /// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32 32 /// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33 33 /// still read (16-bit gray).
34 pub const PPDA_FORMAT_VERSION: u32 = 3;
34 pub const PPDA_FORMAT_VERSION: u32 = 4;
35 35 /// Largest atlas side in pixels (common GPU texture limit). Larger exports are
36 36 /// refused up front with a clear message; atlases are never split.
37 37 pub const MAX_ATLAS_DIM: u32 = 16384;
@@ -159,6 +159,22 @@ pub struct Ppda {
159 159 pub yaws: Vec<u32>,
160 160 pub maps: BTreeMap<String, PpdaMap>,
161 161 pub clips: Vec<PpdaClip>,
162 /// Format 4 (Tim t993u/t994u): the export contract (former `render.json`)
163 /// and the SHA-256 of every loose map under `raw files/`, so a finished
164 /// folder needs no other JSON. Absent in formats 2–3.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub export: Option<PpdaExport>,
167 }
168
169 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170 #[serde(deny_unknown_fields)]
171 pub struct PpdaExport {
172 /// The full render contract document (what `render.json` held).
173 pub render: serde_json::Value,
174 /// Planned passes of the run.
175 pub passes: u32,
176 /// Relative loose map path (`raw files/...`) → SHA-256.
177 pub png_sha256: BTreeMap<String, String>,
162 178 }
163 179
164 180 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -533,6 +549,7 @@ pub fn build_ppda(
533 549 yaws: production::production_yaws().to_vec(),
534 550 maps,
535 551 clips,
552 export: None,
536 553 })
537 554 }
538 555
@@ -555,7 +572,7 @@ pub fn write_atlases(
555 572 digests.insert(kind.token().to_string(), digest.clone());
556 573 maps.insert(kind.token().to_string(), (name, digest));
557 574 }
558 let ppda = build_ppda(
575 let mut ppda = build_ppda(
559 576 job.render_doc,
560 577 job.plan,
561 578 &layout,
@@ -563,6 +580,11 @@ pub fn write_atlases(
563 580 job.set,
564 581 &digests,
565 582 )?;
583 ppda.export = Some(PpdaExport {
584 render: job.render_doc.clone(),
585 passes: job.plan.passes,
586 png_sha256: job.png_sha256.clone(),
587 });
566 588 let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
567 589 bytes.push(b'\n');
568 590 let name = ppda_name(job.sex, job.set);
@@ -601,6 +623,17 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
601 623 p.format_version
602 624 )));
603 625 }
626 if (p.format_version >= 4) != p.export.is_some() {
627 return Err(ident(".ppda: export past niet bij format_version"));
628 }
629 if let Some(export) = &p.export {
630 if export.render.get("sex").and_then(|v| v.as_str()) != Some(p.sex.as_str())
631 || export.render.get("set").and_then(|v| v.as_str()) != Some(p.settype.as_str())
632 || export.png_sha256.values().any(|h| !is_sha256_hex(h))
633 {
634 return Err(ident(".ppda: exportgegevens passen niet bij de set"));
635 }
636 }
604 637 if !["male", "female"].contains(&p.sex.as_str()) {
605 638 return Err(ident(".ppda: ongeldig geslacht"));
606 639 }
@@ -1145,6 +1178,7 @@ mod tests {
1145 1178 rewrite_ppda(&tmp.0, |d| {
1146 1179 d["format_version"] = 2.into();
1147 1180 d.as_object_mut().unwrap().remove("depth_bits");
1181 d.as_object_mut().unwrap().remove("export");
1148 1182 d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1149 1183 });
1150 1184 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
@@ -1185,8 +1219,38 @@ mod tests {
1185 1219 let tmp = fixture();
1186 1220 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1187 1221 let p = &set.ppda;
1188 assert_eq!(p.format_version, 3);
1222 assert_eq!(p.format_version, 4);
1189 1223 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1224 // Format 4 embeds the export contract and every loose-map checksum.
1225 let export = p.export.as_ref().unwrap();
1226 assert_eq!(export.render["sex"], "female");
1227 assert!(!export.png_sha256.is_empty());
1228 assert!(export
1229 .png_sha256
1230 .keys()
1231 .all(|k| k.starts_with("raw files/female/")));
1232 // The finished folder holds only consumer output.
1233 let mut names: Vec<String> = fs::read_dir(&tmp.0)
1234 .unwrap()
1235 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1236 .collect();
1237 names.sort();
1238 assert_eq!(
1239 names,
1240 [
1241 "female-body-atlas.ppda",
1242 "female-body-depth-atlas.png",
1243 "female-body-diffuse-atlas.png",
1244 "female-body-normal-atlas.png",
1245 "female-body-specular-atlas.png",
1246 "raw files",
1247 ]
1248 );
1249 let raw: Vec<String> = fs::read_dir(tmp.0.join("raw files"))
1250 .unwrap()
1251 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1252 .collect();
1253 assert_eq!(raw, ["female"]);
1190 1254 let (near, far) = crate::camera::depth_range(p.identity.framing);
1191 1255 assert_eq!((p.depth_near, p.depth_far), (near, far));
1192 1256 assert_eq!((p.identity.near, p.identity.far), (near, far));
@@ -1259,7 +1323,15 @@ mod tests {
1259 1323 Err(ExportError::Identity(_))
1260 1324 ));
1261 1325 restore();
1262 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1326 rewrite_ppda(&tmp.0, |d| d["format_version"] = 5.into());
1327 assert!(matches!(
1328 AtlasSet::open(&ppda),
1329 Err(ExportError::Identity(_))
1330 ));
1331 restore();
1332 rewrite_ppda(&tmp.0, |d| {
1333 d.as_object_mut().unwrap().remove("export");
1334 });
1263 1335 assert!(matches!(
1264 1336 AtlasSet::open(&ppda),
1265 1337 Err(ExportError::Identity(_))
@@ -1351,12 +1423,15 @@ mod tests {
1351 1423 #[test]
1352 1424 fn atlases_never_clobber_and_are_refused_when_too_large() {
1353 1425 let tmp = fixture();
1354 let render: serde_json::Value =
1355 serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1356 let complete: serde_json::Value =
1357 serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1358 let sums: BTreeMap<String, String> =
1359 serde_json::from_value(complete["png_sha256"].clone()).unwrap();
1426 let export = AtlasSet::open_run(&tmp.0).unwrap().ppda.export.unwrap();
1427 let render = export.render.clone();
1428 let sums = export.png_sha256.clone();
1429 let ppda_sha = crate::export_set::ExportRun::open(&tmp.0)
1430 .unwrap()
1431 .complete
1432 .atlas
1433 .ppda_sha256;
1434 let complete = serde_json::json!({"atlas": {"ppda_sha256": ppda_sha}});
1360 1435 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1361 1436 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1362 1437 let plan = spec.plan();
@@ -1382,7 +1457,7 @@ mod tests {
1382 1457 );
1383 1458 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1384 1459 // A loose map changed after verification is refused.
1385 let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1460 let rel = "raw files/female/body/clip000-walk/f000001/p+30_y090_specular.png";
1386 1461 let mut tampered = sums.clone();
1387 1462 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1388 1463 let err = write_atlases(
@@ -31,7 +31,7 @@ use std::{
31/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
31/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
32/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33/// still read (16-bit gray).
33/// still read (16-bit gray).
34pub const PPDA_FORMAT_VERSION: u32 = 3;
34pub const PPDA_FORMAT_VERSION: u32 = 4;
35/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
35/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
36/// refused up front with a clear message; atlases are never split.
36/// refused up front with a clear message; atlases are never split.
37pub const MAX_ATLAS_DIM: u32 = 16384;
37pub const MAX_ATLAS_DIM: u32 = 16384;
@@ -159,6 +159,22 @@ pub struct Ppda {
159 pub yaws: Vec<u32>,
159 pub yaws: Vec<u32>,
160 pub maps: BTreeMap<String, PpdaMap>,
160 pub maps: BTreeMap<String, PpdaMap>,
161 pub clips: Vec<PpdaClip>,
161 pub clips: Vec<PpdaClip>,
162 /// Format 4 (Tim t993u/t994u): the export contract (former `render.json`)
163 /// and the SHA-256 of every loose map under `raw files/`, so a finished
164 /// folder needs no other JSON. Absent in formats 2–3.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub export: Option<PpdaExport>,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct PpdaExport {
172 /// The full render contract document (what `render.json` held).
173 pub render: serde_json::Value,
174 /// Planned passes of the run.
175 pub passes: u32,
176 /// Relative loose map path (`raw files/...`) → SHA-256.
177 pub png_sha256: BTreeMap<String, String>,
162}
178}
163
179
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -533,6 +549,7 @@ pub fn build_ppda(
533 yaws: production::production_yaws().to_vec(),
549 yaws: production::production_yaws().to_vec(),
534 maps,
550 maps,
535 clips,
551 clips,
552 export: None,
536 })
553 })
537}
554}
538
555
@@ -555,7 +572,7 @@ pub fn write_atlases(
555 digests.insert(kind.token().to_string(), digest.clone());
572 digests.insert(kind.token().to_string(), digest.clone());
556 maps.insert(kind.token().to_string(), (name, digest));
573 maps.insert(kind.token().to_string(), (name, digest));
557 }
574 }
558 let ppda = build_ppda(
575 let mut ppda = build_ppda(
559 job.render_doc,
576 job.render_doc,
560 job.plan,
577 job.plan,
561 &layout,
578 &layout,
@@ -563,6 +580,11 @@ pub fn write_atlases(
563 job.set,
580 job.set,
564 &digests,
581 &digests,
565 )?;
582 )?;
583 ppda.export = Some(PpdaExport {
584 render: job.render_doc.clone(),
585 passes: job.plan.passes,
586 png_sha256: job.png_sha256.clone(),
587 });
566 let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
588 let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
567 bytes.push(b'\n');
589 bytes.push(b'\n');
568 let name = ppda_name(job.sex, job.set);
590 let name = ppda_name(job.sex, job.set);
@@ -601,6 +623,17 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
601 p.format_version
623 p.format_version
602 )));
624 )));
603 }
625 }
626 if (p.format_version >= 4) != p.export.is_some() {
627 return Err(ident(".ppda: export past niet bij format_version"));
628 }
629 if let Some(export) = &p.export {
630 if export.render.get("sex").and_then(|v| v.as_str()) != Some(p.sex.as_str())
631 || export.render.get("set").and_then(|v| v.as_str()) != Some(p.settype.as_str())
632 || export.png_sha256.values().any(|h| !is_sha256_hex(h))
633 {
634 return Err(ident(".ppda: exportgegevens passen niet bij de set"));
635 }
636 }
604 if !["male", "female"].contains(&p.sex.as_str()) {
637 if !["male", "female"].contains(&p.sex.as_str()) {
605 return Err(ident(".ppda: ongeldig geslacht"));
638 return Err(ident(".ppda: ongeldig geslacht"));
606 }
639 }
@@ -1145,6 +1178,7 @@ mod tests {
1145 rewrite_ppda(&tmp.0, |d| {
1178 rewrite_ppda(&tmp.0, |d| {
1146 d["format_version"] = 2.into();
1179 d["format_version"] = 2.into();
1147 d.as_object_mut().unwrap().remove("depth_bits");
1180 d.as_object_mut().unwrap().remove("depth_bits");
1181 d.as_object_mut().unwrap().remove("export");
1148 d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1182 d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1149 });
1183 });
1150 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1184 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
@@ -1185,8 +1219,38 @@ mod tests {
1185 let tmp = fixture();
1219 let tmp = fixture();
1186 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1220 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1187 let p = &set.ppda;
1221 let p = &set.ppda;
1188 assert_eq!(p.format_version, 3);
1222 assert_eq!(p.format_version, 4);
1189 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1223 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1224 // Format 4 embeds the export contract and every loose-map checksum.
1225 let export = p.export.as_ref().unwrap();
1226 assert_eq!(export.render["sex"], "female");
1227 assert!(!export.png_sha256.is_empty());
1228 assert!(export
1229 .png_sha256
1230 .keys()
1231 .all(|k| k.starts_with("raw files/female/")));
1232 // The finished folder holds only consumer output.
1233 let mut names: Vec<String> = fs::read_dir(&tmp.0)
1234 .unwrap()
1235 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1236 .collect();
1237 names.sort();
1238 assert_eq!(
1239 names,
1240 [
1241 "female-body-atlas.ppda",
1242 "female-body-depth-atlas.png",
1243 "female-body-diffuse-atlas.png",
1244 "female-body-normal-atlas.png",
1245 "female-body-specular-atlas.png",
1246 "raw files",
1247 ]
1248 );
1249 let raw: Vec<String> = fs::read_dir(tmp.0.join("raw files"))
1250 .unwrap()
1251 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1252 .collect();
1253 assert_eq!(raw, ["female"]);
1190 let (near, far) = crate::camera::depth_range(p.identity.framing);
1254 let (near, far) = crate::camera::depth_range(p.identity.framing);
1191 assert_eq!((p.depth_near, p.depth_far), (near, far));
1255 assert_eq!((p.depth_near, p.depth_far), (near, far));
1192 assert_eq!((p.identity.near, p.identity.far), (near, far));
1256 assert_eq!((p.identity.near, p.identity.far), (near, far));
@@ -1259,7 +1323,15 @@ mod tests {
1259 Err(ExportError::Identity(_))
1323 Err(ExportError::Identity(_))
1260 ));
1324 ));
1261 restore();
1325 restore();
1262 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1326 rewrite_ppda(&tmp.0, |d| d["format_version"] = 5.into());
1327 assert!(matches!(
1328 AtlasSet::open(&ppda),
1329 Err(ExportError::Identity(_))
1330 ));
1331 restore();
1332 rewrite_ppda(&tmp.0, |d| {
1333 d.as_object_mut().unwrap().remove("export");
1334 });
1263 assert!(matches!(
1335 assert!(matches!(
1264 AtlasSet::open(&ppda),
1336 AtlasSet::open(&ppda),
1265 Err(ExportError::Identity(_))
1337 Err(ExportError::Identity(_))
@@ -1351,12 +1423,15 @@ mod tests {
1351 #[test]
1423 #[test]
1352 fn atlases_never_clobber_and_are_refused_when_too_large() {
1424 fn atlases_never_clobber_and_are_refused_when_too_large() {
1353 let tmp = fixture();
1425 let tmp = fixture();
1354 let render: serde_json::Value =
1426 let export = AtlasSet::open_run(&tmp.0).unwrap().ppda.export.unwrap();
1355 serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1427 let render = export.render.clone();
1356 let complete: serde_json::Value =
1428 let sums = export.png_sha256.clone();
1357 serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1429 let ppda_sha = crate::export_set::ExportRun::open(&tmp.0)
1358 let sums: BTreeMap<String, String> =
1430 .unwrap()
1359 serde_json::from_value(complete["png_sha256"].clone()).unwrap();
1431 .complete
1432 .atlas
1433 .ppda_sha256;
1434 let complete = serde_json::json!({"atlas": {"ppda_sha256": ppda_sha}});
1360 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1435 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1361 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1436 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1362 let plan = spec.plan();
1437 let plan = spec.plan();
@@ -1382,7 +1457,7 @@ mod tests {
1382 );
1457 );
1383 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1458 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1384 // A loose map changed after verification is refused.
1459 // A loose map changed after verification is refused.
1385 let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1460 let rel = "raw files/female/body/clip000-walk/f000001/p+30_y090_specular.png";
1386 let mut tampered = sums.clone();
1461 let mut tampered = sums.clone();
1387 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1462 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1388 let err = write_atlases(
1463 let err = write_atlases(
src/export_fixture.rs +3 −1
@@ -221,7 +221,9 @@ impl SyntheticRun {
221 221 "png_sha256": sums,
222 222 "atlas": atlas.to_json(),
223 223 }),
224 )
224 )?;
225 // Same final layout as production: only .ppda, atlases and `raw files/`.
226 crate::resume::strip_bookkeeping(root)
225 227 }
226 228 }
227 229
@@ -221,7 +221,9 @@ impl SyntheticRun {
221 "png_sha256": sums,
221 "png_sha256": sums,
222 "atlas": atlas.to_json(),
222 "atlas": atlas.to_json(),
223 }),
223 }),
224 )
224 )?;
225 // Same final layout as production: only .ppda, atlases and `raw files/`.
226 crate::resume::strip_bookkeeping(root)
225 }
227 }
226}
228}
227
229
src/export_set.rs +75 −11
@@ -251,16 +251,26 @@ impl ExportRun {
251 251 "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
252 252 ));
253 253 }
254 let render_bytes = read_manifest_file(&root, "render.json")?;
255 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
256 if matches!(err, ExportError::Incomplete(_)) {
257 ExportError::Incomplete(
258 "complete.json ontbreekt; de exportset is niet volledig".into(),
259 )
260 } else {
261 err
262 }
263 })?;
254 // Finished folders (t993u/t994u) have no JSON besides the `.ppda`,
255 // which embeds the export contract and per-PNG checksums.
256 let ppda_export = crate::atlas::AtlasSet::open_run(&root)
257 .ok()
258 .is_some_and(|set| set.ppda.export.is_some());
259 let (render_bytes, complete_bytes) = if !ppda_export {
260 let render_bytes = read_manifest_file(&root, "render.json")?;
261 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
262 if matches!(err, ExportError::Incomplete(_)) {
263 ExportError::Incomplete(
264 "complete.json ontbreekt; de exportset is niet volledig".into(),
265 )
266 } else {
267 err
268 }
269 })?;
270 (render_bytes, complete_bytes)
271 } else {
272 manifests_from_ppda(&root)?
273 };
264 274 let render: RenderManifest = serde_json::from_slice(&render_bytes)
265 275 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
266 276 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
@@ -321,10 +331,19 @@ impl ExportRun {
321 331 kind.token(),
322 332 )
323 333 .map_err(ExportError::Identity)?;
324 let key = relative
334 let mut key = relative
325 335 .to_str()
326 336 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
327 337 .replace('\\', "/");
338 if !self.complete.png_sha256.contains_key(&key) {
339 // Older exports stored the loose maps at the run root.
340 let prefix = format!("{}/", crate::resume::RAW_DIR);
341 if let Some(legacy) = key.strip_prefix(&prefix) {
342 if self.complete.png_sha256.contains_key(legacy) {
343 key = legacy.to_string();
344 }
345 }
346 }
328 347 if !self.complete.png_sha256.contains_key(&key) {
329 348 return Err(ExportError::Incomplete(format!(
330 349 "map '{key}' staat niet in complete.json"
@@ -895,6 +914,51 @@ fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
895 914 .map_err(|e| ExportError::Io(e.to_string()))
896 915 }
897 916
917 /// Rebuild the `render.json` / `complete.json` documents from the `.ppda`'s
918 /// embedded `export` section (finished result folders, Tim t994u).
919 fn manifests_from_ppda(root: &Path) -> Result<(Vec<u8>, Vec<u8>), ExportError> {
920 let set = crate::atlas::AtlasSet::open_run(root).map_err(|e| match e {
921 ExportError::Incomplete(_) => ExportError::Incomplete(
922 "complete.json ontbreekt en er is geen .ppda; de exportset is niet volledig".into(),
923 ),
924 other => other,
925 })?;
926 let export = set.ppda.export.as_ref().ok_or_else(|| {
927 ExportError::Incomplete(".ppda bevat geen exportgegevens en complete.json ontbreekt".into())
928 })?;
929 let ppda_name = crate::atlas::ppda_name(&set.ppda.sex, &set.ppda.settype);
930 let ppda_bytes = read_bounded_file(&root.join(&ppda_name), crate::atlas::MAX_PPDA_BYTES)?;
931 let identity = complete_identity_json(&export.render).map_err(ExportError::Identity)?;
932 let maps: serde_json::Map<String, serde_json::Value> = set
933 .ppda
934 .maps
935 .iter()
936 .map(|(k, m)| {
937 (
938 k.clone(),
939 serde_json::json!({"file": m.file, "sha256": m.sha256}),
940 )
941 })
942 .collect();
943 let complete = serde_json::json!({
944 "format_version": export.render.get("format_version").cloned().unwrap_or_default(),
945 "status": "complete",
946 "identity": identity,
947 "passes": export.passes,
948 "png_files": export.png_sha256.len(),
949 "png_sha256": export.png_sha256,
950 "atlas": {
951 "ppda": ppda_name,
952 "ppda_sha256": digest::sha256_hex(&ppda_bytes),
953 "maps": maps,
954 },
955 });
956 let to = |v: &serde_json::Value| {
957 serde_json::to_vec(v).map_err(|e| ExportError::Corrupt(e.to_string()))
958 };
959 Ok((to(&export.render)?, to(&complete)?))
960 }
961
898 962 fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
899 963 let path = root.join(name);
900 964 if !path.exists() {
@@ -251,16 +251,26 @@ impl ExportRun {
251 "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
251 "run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig".into(),
252 ));
252 ));
253 }
253 }
254 let render_bytes = read_manifest_file(&root, "render.json")?;
254 // Finished folders (t993u/t994u) have no JSON besides the `.ppda`,
255 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
255 // which embeds the export contract and per-PNG checksums.
256 if matches!(err, ExportError::Incomplete(_)) {
256 let ppda_export = crate::atlas::AtlasSet::open_run(&root)
257 ExportError::Incomplete(
257 .ok()
258 "complete.json ontbreekt; de exportset is niet volledig".into(),
258 .is_some_and(|set| set.ppda.export.is_some());
259 )
259 let (render_bytes, complete_bytes) = if !ppda_export {
260 } else {
260 let render_bytes = read_manifest_file(&root, "render.json")?;
261 err
261 let complete_bytes = read_manifest_file(&root, "complete.json").map_err(|err| {
262 }
262 if matches!(err, ExportError::Incomplete(_)) {
263 })?;
263 ExportError::Incomplete(
264 "complete.json ontbreekt; de exportset is niet volledig".into(),
265 )
266 } else {
267 err
268 }
269 })?;
270 (render_bytes, complete_bytes)
271 } else {
272 manifests_from_ppda(&root)?
273 };
264 let render: RenderManifest = serde_json::from_slice(&render_bytes)
274 let render: RenderManifest = serde_json::from_slice(&render_bytes)
265 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
275 .map_err(|e| ExportError::Corrupt(format!("render.json: {e}")))?;
266 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
276 let complete: CompleteManifest = serde_json::from_slice(&complete_bytes)
@@ -321,10 +331,19 @@ impl ExportRun {
321 kind.token(),
331 kind.token(),
322 )
332 )
323 .map_err(ExportError::Identity)?;
333 .map_err(ExportError::Identity)?;
324 let key = relative
334 let mut key = relative
325 .to_str()
335 .to_str()
326 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
336 .ok_or_else(|| ExportError::Identity("ongeldig map-pad".into()))?
327 .replace('\\', "/");
337 .replace('\\', "/");
338 if !self.complete.png_sha256.contains_key(&key) {
339 // Older exports stored the loose maps at the run root.
340 let prefix = format!("{}/", crate::resume::RAW_DIR);
341 if let Some(legacy) = key.strip_prefix(&prefix) {
342 if self.complete.png_sha256.contains_key(legacy) {
343 key = legacy.to_string();
344 }
345 }
346 }
328 if !self.complete.png_sha256.contains_key(&key) {
347 if !self.complete.png_sha256.contains_key(&key) {
329 return Err(ExportError::Incomplete(format!(
348 return Err(ExportError::Incomplete(format!(
330 "map '{key}' staat niet in complete.json"
349 "map '{key}' staat niet in complete.json"
@@ -895,6 +914,51 @@ fn canonicalize_dir(path: &Path) -> Result<PathBuf, ExportError> {
895 .map_err(|e| ExportError::Io(e.to_string()))
914 .map_err(|e| ExportError::Io(e.to_string()))
896}
915}
897
916
917/// Rebuild the `render.json` / `complete.json` documents from the `.ppda`'s
918/// embedded `export` section (finished result folders, Tim t994u).
919fn manifests_from_ppda(root: &Path) -> Result<(Vec<u8>, Vec<u8>), ExportError> {
920 let set = crate::atlas::AtlasSet::open_run(root).map_err(|e| match e {
921 ExportError::Incomplete(_) => ExportError::Incomplete(
922 "complete.json ontbreekt en er is geen .ppda; de exportset is niet volledig".into(),
923 ),
924 other => other,
925 })?;
926 let export = set.ppda.export.as_ref().ok_or_else(|| {
927 ExportError::Incomplete(".ppda bevat geen exportgegevens en complete.json ontbreekt".into())
928 })?;
929 let ppda_name = crate::atlas::ppda_name(&set.ppda.sex, &set.ppda.settype);
930 let ppda_bytes = read_bounded_file(&root.join(&ppda_name), crate::atlas::MAX_PPDA_BYTES)?;
931 let identity = complete_identity_json(&export.render).map_err(ExportError::Identity)?;
932 let maps: serde_json::Map<String, serde_json::Value> = set
933 .ppda
934 .maps
935 .iter()
936 .map(|(k, m)| {
937 (
938 k.clone(),
939 serde_json::json!({"file": m.file, "sha256": m.sha256}),
940 )
941 })
942 .collect();
943 let complete = serde_json::json!({
944 "format_version": export.render.get("format_version").cloned().unwrap_or_default(),
945 "status": "complete",
946 "identity": identity,
947 "passes": export.passes,
948 "png_files": export.png_sha256.len(),
949 "png_sha256": export.png_sha256,
950 "atlas": {
951 "ppda": ppda_name,
952 "ppda_sha256": digest::sha256_hex(&ppda_bytes),
953 "maps": maps,
954 },
955 });
956 let to = |v: &serde_json::Value| {
957 serde_json::to_vec(v).map_err(|e| ExportError::Corrupt(e.to_string()))
958 };
959 Ok((to(&export.render)?, to(&complete)?))
960}
961
898fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
962fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
899 let path = root.join(name);
963 let path = root.join(name);
900 if !path.exists() {
964 if !path.exists() {
src/preview2d.rs +1 −1
@@ -1210,7 +1210,7 @@ mod tests {
1210 1210 )
1211 1211 })
1212 1212 .unwrap();
1213 fs::remove_dir_all(dir.join("male")).unwrap();
1213 fs::remove_dir_all(dir.join("raw files")).unwrap();
1214 1214 let catalog = discover_completed_runs(&project.0);
1215 1215 assert_eq!(catalog.len(), 1);
1216 1216 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
@@ -1210,7 +1210,7 @@ mod tests {
1210 )
1210 )
1211 })
1211 })
1212 .unwrap();
1212 .unwrap();
1213 fs::remove_dir_all(dir.join("male")).unwrap();
1213 fs::remove_dir_all(dir.join("raw files")).unwrap();
1214 let catalog = discover_completed_runs(&project.0);
1214 let catalog = discover_completed_runs(&project.0);
1215 assert_eq!(catalog.len(), 1);
1215 assert_eq!(catalog.len(), 1);
1216 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1216 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
src/production.rs +141 −51
@@ -228,7 +228,8 @@ pub fn output_path(
228 228 {
229 229 return Err("ongeldig uitvoerpad/token".into());
230 230 }
231 Ok(PathBuf::from(sex)
231 Ok(PathBuf::from(crate::resume::RAW_DIR)
232 .join(sex)
232 233 .join(set)
233 234 .join(clip)
234 235 .join(format!("f{frame:06}"))
@@ -1813,9 +1814,12 @@ fn execute_target(
1813 1814 match result {
1814 1815 Ok(atlas) => {
1815 1816 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1817 // In-progress completion marker only; removed again by
1818 // strip_bookkeeping once the .ppda (which embeds the export
1819 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1816 1820 write_json(
1817 1821 &root,
1818 "complete.json",
1822 crate::resume::COMPLETE_MARKER,
1819 1823 serde_json::json!({
1820 1824 "format_version": crate::export_set::OUTPUT_META_VERSION,
1821 1825 "status": "complete",
@@ -1843,7 +1847,11 @@ fn execute_target(
1843 1847 // Verify the finished build before it replaces the last good result.
1844 1848 crate::atlas::AtlasSet::open_run(&root)
1845 1849 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1850 crate::export_set::ExportRun::open(&root)
1851 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1846 1852 drop(session);
1853 // Lock, journal and resume bookkeeping stay out of the result folder.
1854 crate::resume::strip_bookkeeping(&root)?;
1847 1855 let final_dir = crate::resume::promote(
1848 1856 &job.output,
1849 1857 &root,
@@ -2110,7 +2118,8 @@ mod tests {
2110 2118 }
2111 2119 #[test]
2112 2120 fn output_tokens_only_four_target_maps() {
2113 let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2121 let expected =
2122 PathBuf::from("raw files/male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2114 2123 assert_eq!(
2115 2124 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2116 2125 expected
@@ -2485,13 +2494,27 @@ mod tests {
2485 2494 .unwrap()
2486 2495 .unwrap()
2487 2496 .path();
2488 assert!(run.join("render.json").exists());
2489 assert_eq!(run.join("complete.json").exists(), !stop);
2497 // A finished folder has no bookkeeping JSON; a cancelled build keeps it.
2498 assert_eq!(run.join("render.json").exists(), stop);
2499 assert!(!run.join("complete.json").exists());
2500 assert_eq!(run.join(crate::resume::JOURNAL).exists(), stop);
2501 assert_eq!(run.join(crate::resume::LOCK).exists(), stop);
2490 2502 assert_eq!(run.join("cancelled.json").exists(), stop);
2491 2503 assert_eq!(result.is_ok(), !stop);
2492 let meta: serde_json::Value =
2493 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2494 .unwrap();
2504 let ppda_export = (!stop).then(|| {
2505 crate::atlas::AtlasSet::open_run(&run)
2506 .unwrap()
2507 .ppda
2508 .export
2509 .unwrap()
2510 });
2511 let meta: serde_json::Value = match &ppda_export {
2512 Some(export) => export.render.clone(),
2513 None => {
2514 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2515 .unwrap()
2516 }
2517 };
2495 2518 assert_eq!(meta["format_version"], 6);
2496 2519 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2497 2520 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
@@ -2499,13 +2522,6 @@ mod tests {
2499 2522 assert_eq!(meta["near"], meta["depth_near"]);
2500 2523 assert_eq!(meta["depth_encoding"], "gray");
2501 2524 assert_eq!(meta["depth_bits"], 16);
2502 if !stop {
2503 let done: serde_json::Value = serde_json::from_str(
2504 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2505 )
2506 .unwrap();
2507 assert_eq!(done["depth_clamped"]["views"], 0);
2508 }
2509 2525 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2510 2526 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2511 2527 assert_eq!(meta["fps"], 12);
@@ -2527,20 +2543,21 @@ mod tests {
2527 2543 ["diffuse", "depth", "normal", "specular"]
2528 2544 );
2529 2545 if !stop {
2530 let complete: serde_json::Value = serde_json::from_str(
2531 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2532 )
2533 .unwrap();
2534 assert_eq!(complete["format_version"], 6);
2535 assert_eq!(complete["status"], "complete");
2536 assert_eq!(complete["png_files"], 240);
2537 let checksums = complete["png_sha256"].as_object().unwrap();
2546 let checksums = &ppda_export.as_ref().unwrap().png_sha256;
2538 2547 assert_eq!(checksums.len(), 240);
2539 2548 for (rel, digest) in checksums {
2540 assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2549 assert!(rel.starts_with("raw files/"), "{rel}");
2550 assert!(crate::digest::is_sha256_hex(digest));
2541 2551 let bytes = std::fs::read(run.join(rel)).unwrap();
2542 assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2552 assert_eq!(*digest, crate::digest::sha256_hex(&bytes));
2543 2553 }
2554 let mut top: Vec<String> = std::fs::read_dir(&run)
2555 .unwrap()
2556 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2557 .filter(|n| !n.ends_with(".png"))
2558 .collect();
2559 top.sort();
2560 assert_eq!(top, ["male-body-atlas.ppda", "raw files"]);
2544 2561 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2545 2562 let sample = opened
2546 2563 .load_map(
@@ -2556,7 +2573,7 @@ mod tests {
2556 2573 for map in ["diffuse", "normal", "depth", "specular"] {
2557 2574 let name = format!("male-body-{map}-atlas.png");
2558 2575 assert!(run.join(&name).is_file(), "{name}");
2559 assert_eq!(complete["atlas"]["maps"][map]["file"], name);
2576 assert_eq!(opened.complete.atlas.maps[map].file, name);
2560 2577 }
2561 2578 assert!(!stop);
2562 2579 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
@@ -2577,7 +2594,7 @@ mod tests {
2577 2594 assert!(!run.join("male-body-atlas.ppda").exists());
2578 2595 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2579 2596 }
2580 let frame = run.join("male/body/clip000-bind/f000000");
2597 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2581 2598 assert!(frame.join("p-60_y000_diffuse.png").exists());
2582 2599 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2583 2600 assert!(!frame.join("p-60_y000_highres.png").exists());
@@ -2673,9 +2690,12 @@ mod tests {
2673 2690 v
2674 2691 }
2675 2692 fn checksums(run: &Path) -> serde_json::Value {
2676 let c: serde_json::Value =
2677 serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();
2678 c["png_sha256"].clone()
2693 let export = crate::atlas::AtlasSet::open_run(run)
2694 .unwrap()
2695 .ppda
2696 .export
2697 .unwrap();
2698 serde_json::to_value(export.png_sha256).unwrap()
2679 2699 }
2680 2700 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2681 2701 let mut out = std::collections::BTreeMap::new();
@@ -2724,7 +2744,7 @@ mod tests {
2724 2744 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2725 2745 assert!(done_first > 0 && done_first < 240);
2726 2746 assert_eq!(cancelled["attempt"], 0);
2727 let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2747 let first_file = run.join("raw files/male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2728 2748 let first_inode = inode(&first_file);
2729 2749 assert!(crate::export_set::ExportRun::open(&run).is_err());
2730 2750
@@ -2777,13 +2797,21 @@ mod tests {
2777 2797 );
2778 2798 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2779 2799 let run = done;
2780 assert!(!run.join("cancelled.json").exists());
2781 assert!(run.join("attempt-001-cancelled.json").exists());
2800 // Resume bookkeeping (markers, logs, journal, lock) stays out of the result.
2801 for name in [
2802 "cancelled.json",
2803 "attempt-001-cancelled.json",
2804 "render.json",
2805 "complete.json",
2806 crate::resume::RESUME_LOG,
2807 crate::resume::JOURNAL,
2808 crate::resume::LOCK,
2809 ] {
2810 assert!(!run.join(name).exists(), "{name}");
2811 }
2782 2812 assert_eq!(inode(&first_file), first_inode);
2783 2813 assert_eq!(checksums(&run), reference);
2784 2814 crate::export_set::ExportRun::open(&run).unwrap();
2785 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2786 assert_eq!(log.lines().count(), 2);
2787 2815 // A completed run is no longer offered and cannot be resumed.
2788 2816 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2789 2817 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
@@ -2818,7 +2846,7 @@ mod tests {
2818 2846 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2819 2847 let torn_inode = inode(&torn_path);
2820 2848 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2821 let frame = run.join("male/body/clip000-bind/f000000");
2849 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2822 2850 let corrupt = frame.join("p-60_y030_normal.png");
2823 2851 std::fs::write(&corrupt, b"not a png").unwrap();
2824 2852 let missing = frame.join("p-60_y060_depth.png");
@@ -2855,19 +2883,7 @@ mod tests {
2855 2883 torn_inode,
2856 2884 "identical unjournaled file was replaced"
2857 2885 );
2858 let log: serde_json::Value = serde_json::from_str(
2859 std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))
2860 .unwrap()
2861 .lines()
2862 .next()
2863 .unwrap(),
2864 )
2865 .unwrap();
2866 assert_eq!(log["previous_marker"], serde_json::Value::Null);
2867 assert_eq!(log["damaged_journal_lines"], 1);
2868 assert_eq!(log["unjournaled_existing"], 1);
2869 assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);
2870 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2886 assert!(!run.join(crate::resume::RESUME_LOG).exists());
2871 2887 }
2872 2888
2873 2889 #[test]
@@ -2886,6 +2902,64 @@ mod tests {
2886 2902 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2887 2903 }
2888 2904
2905 #[test]
2906 #[cfg(unix)]
2907 fn recover_cleans_and_places_builds_that_crashed_after_completion() {
2908 let tmp = Temp::new();
2909 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2910 let reference = tree(&runs(&tmp.0)[0]);
2911 let consumer = |dir: &Path| {
2912 let mut names: Vec<String> = std::fs::read_dir(dir)
2913 .unwrap()
2914 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2915 .collect();
2916 names.sort();
2917 names
2918 };
2919 let clean = consumer(&runs(&tmp.0)[0]);
2920 assert_eq!(
2921 clean,
2922 [
2923 "male-body-atlas.ppda",
2924 "male-body-depth-atlas.png",
2925 "male-body-diffuse-atlas.png",
2926 "male-body-normal-atlas.png",
2927 "male-body-specular-atlas.png",
2928 "raw files",
2929 ]
2930 );
2931 // (a) crash after complete.json, before cleanup: all bookkeeping present.
2932 // (b) crash after cleanup, before the swap: only consumer output.
2933 for with_bookkeeping in [true, false] {
2934 let built = tmp
2935 .0
2936 .join(crate::resume::in_progress_name(7, 7, "male", "body"));
2937 std::fs::rename(tmp.0.join("male-body-atlas"), &built).unwrap();
2938 if with_bookkeeping {
2939 for name in [
2940 "render.json",
2941 "complete.json",
2942 crate::resume::JOURNAL,
2943 crate::resume::LOCK,
2944 crate::resume::RESUME_LOG,
2945 "attempt-000-cancelled.json",
2946 ] {
2947 std::fs::write(built.join(name), b"{}").unwrap();
2948 }
2949 }
2950 let notes = crate::resume::recover(&tmp.0).unwrap();
2951 assert_eq!(notes.len(), 1, "{notes:?}");
2952 let done = tmp.0.join("male-body-atlas");
2953 assert!(!built.exists());
2954 assert_eq!(consumer(&done), clean);
2955 let rebase = |t: std::collections::BTreeMap<PathBuf, String>| {
2956 t.into_values().collect::<Vec<_>>()
2957 };
2958 assert_eq!(rebase(tree(&done)), rebase(reference.clone()));
2959 crate::export_set::ExportRun::open(&done).unwrap();
2960 }
2961 }
2962
2889 2963 #[test]
2890 2964 #[cfg(unix)]
2891 2965 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
@@ -2902,7 +2976,23 @@ mod tests {
2902 2976 .0
2903 2977 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2904 2978 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2905 std::fs::remove_file(run.join("complete.json")).unwrap();
2979 // Recreate the in-progress bookkeeping that existed before complete.json.
2980 let export = crate::atlas::AtlasSet::open_run(&run)
2981 .unwrap()
2982 .ppda
2983 .export
2984 .unwrap();
2985 std::fs::write(
2986 run.join("render.json"),
2987 serde_json::to_vec_pretty(&export.render).unwrap(),
2988 )
2989 .unwrap();
2990 let journal: String = export
2991 .png_sha256
2992 .iter()
2993 .map(|(path, sha)| serde_json::json!({"path": path, "sha256": sha}).to_string() + "\n")
2994 .collect();
2995 std::fs::write(run.join(crate::resume::JOURNAL), journal).unwrap();
2906 2996 let diffuse = run.join("male-body-diffuse-atlas.png");
2907 2997 let normal = run.join("male-body-normal-atlas.png");
2908 2998 let normal_inode = inode(&normal);
@@ -2997,7 +3087,7 @@ mod tests {
2997 3087 .is_err());
2998 3088 let run = runs(&tmp.0)[0].clone();
2999 3089 // A planned PNG appears that the journal never recorded and that differs.
3000 let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");
3090 let foreign = run.join("raw files/male/body/clip000-bind/f000000/p-60_y030_specular.png");
3001 3091 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
3002 3092 std::fs::write(&foreign, b"foreign").unwrap();
3003 3093 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
@@ -228,7 +228,8 @@ pub fn output_path(
228 {
228 {
229 return Err("ongeldig uitvoerpad/token".into());
229 return Err("ongeldig uitvoerpad/token".into());
230 }
230 }
231 Ok(PathBuf::from(sex)
231 Ok(PathBuf::from(crate::resume::RAW_DIR)
232 .join(sex)
232 .join(set)
233 .join(set)
233 .join(clip)
234 .join(clip)
234 .join(format!("f{frame:06}"))
235 .join(format!("f{frame:06}"))
@@ -1813,9 +1814,12 @@ fn execute_target(
1813 match result {
1814 match result {
1814 Ok(atlas) => {
1815 Ok(atlas) => {
1815 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1816 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1817 // In-progress completion marker only; removed again by
1818 // strip_bookkeeping once the .ppda (which embeds the export
1819 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1816 write_json(
1820 write_json(
1817 &root,
1821 &root,
1818 "complete.json",
1822 crate::resume::COMPLETE_MARKER,
1819 serde_json::json!({
1823 serde_json::json!({
1820 "format_version": crate::export_set::OUTPUT_META_VERSION,
1824 "format_version": crate::export_set::OUTPUT_META_VERSION,
1821 "status": "complete",
1825 "status": "complete",
@@ -1843,7 +1847,11 @@ fn execute_target(
1843 // Verify the finished build before it replaces the last good result.
1847 // Verify the finished build before it replaces the last good result.
1844 crate::atlas::AtlasSet::open_run(&root)
1848 crate::atlas::AtlasSet::open_run(&root)
1845 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1849 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1850 crate::export_set::ExportRun::open(&root)
1851 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1846 drop(session);
1852 drop(session);
1853 // Lock, journal and resume bookkeeping stay out of the result folder.
1854 crate::resume::strip_bookkeeping(&root)?;
1847 let final_dir = crate::resume::promote(
1855 let final_dir = crate::resume::promote(
1848 &job.output,
1856 &job.output,
1849 &root,
1857 &root,
@@ -2110,7 +2118,8 @@ mod tests {
2110 }
2118 }
2111 #[test]
2119 #[test]
2112 fn output_tokens_only_four_target_maps() {
2120 fn output_tokens_only_four_target_maps() {
2113 let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2121 let expected =
2122 PathBuf::from("raw files/male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2114 assert_eq!(
2123 assert_eq!(
2115 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2124 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2116 expected
2125 expected
@@ -2485,13 +2494,27 @@ mod tests {
2485 .unwrap()
2494 .unwrap()
2486 .unwrap()
2495 .unwrap()
2487 .path();
2496 .path();
2488 assert!(run.join("render.json").exists());
2497 // A finished folder has no bookkeeping JSON; a cancelled build keeps it.
2489 assert_eq!(run.join("complete.json").exists(), !stop);
2498 assert_eq!(run.join("render.json").exists(), stop);
2499 assert!(!run.join("complete.json").exists());
2500 assert_eq!(run.join(crate::resume::JOURNAL).exists(), stop);
2501 assert_eq!(run.join(crate::resume::LOCK).exists(), stop);
2490 assert_eq!(run.join("cancelled.json").exists(), stop);
2502 assert_eq!(run.join("cancelled.json").exists(), stop);
2491 assert_eq!(result.is_ok(), !stop);
2503 assert_eq!(result.is_ok(), !stop);
2492 let meta: serde_json::Value =
2504 let ppda_export = (!stop).then(|| {
2493 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2505 crate::atlas::AtlasSet::open_run(&run)
2494 .unwrap();
2506 .unwrap()
2507 .ppda
2508 .export
2509 .unwrap()
2510 });
2511 let meta: serde_json::Value = match &ppda_export {
2512 Some(export) => export.render.clone(),
2513 None => {
2514 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2515 .unwrap()
2516 }
2517 };
2495 assert_eq!(meta["format_version"], 6);
2518 assert_eq!(meta["format_version"], 6);
2496 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2519 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2497 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
2520 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
@@ -2499,13 +2522,6 @@ mod tests {
2499 assert_eq!(meta["near"], meta["depth_near"]);
2522 assert_eq!(meta["near"], meta["depth_near"]);
2500 assert_eq!(meta["depth_encoding"], "gray");
2523 assert_eq!(meta["depth_encoding"], "gray");
2501 assert_eq!(meta["depth_bits"], 16);
2524 assert_eq!(meta["depth_bits"], 16);
2502 if !stop {
2503 let done: serde_json::Value = serde_json::from_str(
2504 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2505 )
2506 .unwrap();
2507 assert_eq!(done["depth_clamped"]["views"], 0);
2508 }
2509 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2525 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2510 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2526 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2511 assert_eq!(meta["fps"], 12);
2527 assert_eq!(meta["fps"], 12);
@@ -2527,20 +2543,21 @@ mod tests {
2527 ["diffuse", "depth", "normal", "specular"]
2543 ["diffuse", "depth", "normal", "specular"]
2528 );
2544 );
2529 if !stop {
2545 if !stop {
2530 let complete: serde_json::Value = serde_json::from_str(
2546 let checksums = &ppda_export.as_ref().unwrap().png_sha256;
2531 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2532 )
2533 .unwrap();
2534 assert_eq!(complete["format_version"], 6);
2535 assert_eq!(complete["status"], "complete");
2536 assert_eq!(complete["png_files"], 240);
2537 let checksums = complete["png_sha256"].as_object().unwrap();
2538 assert_eq!(checksums.len(), 240);
2547 assert_eq!(checksums.len(), 240);
2539 for (rel, digest) in checksums {
2548 for (rel, digest) in checksums {
2540 assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2549 assert!(rel.starts_with("raw files/"), "{rel}");
2550 assert!(crate::digest::is_sha256_hex(digest));
2541 let bytes = std::fs::read(run.join(rel)).unwrap();
2551 let bytes = std::fs::read(run.join(rel)).unwrap();
2542 assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2552 assert_eq!(*digest, crate::digest::sha256_hex(&bytes));
2543 }
2553 }
2554 let mut top: Vec<String> = std::fs::read_dir(&run)
2555 .unwrap()
2556 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2557 .filter(|n| !n.ends_with(".png"))
2558 .collect();
2559 top.sort();
2560 assert_eq!(top, ["male-body-atlas.ppda", "raw files"]);
2544 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2561 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2545 let sample = opened
2562 let sample = opened
2546 .load_map(
2563 .load_map(
@@ -2556,7 +2573,7 @@ mod tests {
2556 for map in ["diffuse", "normal", "depth", "specular"] {
2573 for map in ["diffuse", "normal", "depth", "specular"] {
2557 let name = format!("male-body-{map}-atlas.png");
2574 let name = format!("male-body-{map}-atlas.png");
2558 assert!(run.join(&name).is_file(), "{name}");
2575 assert!(run.join(&name).is_file(), "{name}");
2559 assert_eq!(complete["atlas"]["maps"][map]["file"], name);
2576 assert_eq!(opened.complete.atlas.maps[map].file, name);
2560 }
2577 }
2561 assert!(!stop);
2578 assert!(!stop);
2562 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2579 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
@@ -2577,7 +2594,7 @@ mod tests {
2577 assert!(!run.join("male-body-atlas.ppda").exists());
2594 assert!(!run.join("male-body-atlas.ppda").exists());
2578 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2595 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2579 }
2596 }
2580 let frame = run.join("male/body/clip000-bind/f000000");
2597 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2581 assert!(frame.join("p-60_y000_diffuse.png").exists());
2598 assert!(frame.join("p-60_y000_diffuse.png").exists());
2582 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2599 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2583 assert!(!frame.join("p-60_y000_highres.png").exists());
2600 assert!(!frame.join("p-60_y000_highres.png").exists());
@@ -2673,9 +2690,12 @@ mod tests {
2673 v
2690 v
2674 }
2691 }
2675 fn checksums(run: &Path) -> serde_json::Value {
2692 fn checksums(run: &Path) -> serde_json::Value {
2676 let c: serde_json::Value =
2693 let export = crate::atlas::AtlasSet::open_run(run)
2677 serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();
2694 .unwrap()
2678 c["png_sha256"].clone()
2695 .ppda
2696 .export
2697 .unwrap();
2698 serde_json::to_value(export.png_sha256).unwrap()
2679 }
2699 }
2680 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2700 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2681 let mut out = std::collections::BTreeMap::new();
2701 let mut out = std::collections::BTreeMap::new();
@@ -2724,7 +2744,7 @@ mod tests {
2724 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2744 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2725 assert!(done_first > 0 && done_first < 240);
2745 assert!(done_first > 0 && done_first < 240);
2726 assert_eq!(cancelled["attempt"], 0);
2746 assert_eq!(cancelled["attempt"], 0);
2727 let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2747 let first_file = run.join("raw files/male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2728 let first_inode = inode(&first_file);
2748 let first_inode = inode(&first_file);
2729 assert!(crate::export_set::ExportRun::open(&run).is_err());
2749 assert!(crate::export_set::ExportRun::open(&run).is_err());
2730
2750
@@ -2777,13 +2797,21 @@ mod tests {
2777 );
2797 );
2778 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2798 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2779 let run = done;
2799 let run = done;
2780 assert!(!run.join("cancelled.json").exists());
2800 // Resume bookkeeping (markers, logs, journal, lock) stays out of the result.
2781 assert!(run.join("attempt-001-cancelled.json").exists());
2801 for name in [
2802 "cancelled.json",
2803 "attempt-001-cancelled.json",
2804 "render.json",
2805 "complete.json",
2806 crate::resume::RESUME_LOG,
2807 crate::resume::JOURNAL,
2808 crate::resume::LOCK,
2809 ] {
2810 assert!(!run.join(name).exists(), "{name}");
2811 }
2782 assert_eq!(inode(&first_file), first_inode);
2812 assert_eq!(inode(&first_file), first_inode);
2783 assert_eq!(checksums(&run), reference);
2813 assert_eq!(checksums(&run), reference);
2784 crate::export_set::ExportRun::open(&run).unwrap();
2814 crate::export_set::ExportRun::open(&run).unwrap();
2785 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2786 assert_eq!(log.lines().count(), 2);
2787 // A completed run is no longer offered and cannot be resumed.
2815 // A completed run is no longer offered and cannot be resumed.
2788 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2816 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2789 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2817 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
@@ -2818,7 +2846,7 @@ mod tests {
2818 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2846 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2819 let torn_inode = inode(&torn_path);
2847 let torn_inode = inode(&torn_path);
2820 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2848 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2821 let frame = run.join("male/body/clip000-bind/f000000");
2849 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2822 let corrupt = frame.join("p-60_y030_normal.png");
2850 let corrupt = frame.join("p-60_y030_normal.png");
2823 std::fs::write(&corrupt, b"not a png").unwrap();
2851 std::fs::write(&corrupt, b"not a png").unwrap();
2824 let missing = frame.join("p-60_y060_depth.png");
2852 let missing = frame.join("p-60_y060_depth.png");
@@ -2855,19 +2883,7 @@ mod tests {
2855 torn_inode,
2883 torn_inode,
2856 "identical unjournaled file was replaced"
2884 "identical unjournaled file was replaced"
2857 );
2885 );
2858 let log: serde_json::Value = serde_json::from_str(
2886 assert!(!run.join(crate::resume::RESUME_LOG).exists());
2859 std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))
2860 .unwrap()
2861 .lines()
2862 .next()
2863 .unwrap(),
2864 )
2865 .unwrap();
2866 assert_eq!(log["previous_marker"], serde_json::Value::Null);
2867 assert_eq!(log["damaged_journal_lines"], 1);
2868 assert_eq!(log["unjournaled_existing"], 1);
2869 assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);
2870 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2871 }
2887 }
2872
2888
2873 #[test]
2889 #[test]
@@ -2886,6 +2902,64 @@ mod tests {
2886 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2902 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2887 }
2903 }
2888
2904
2905 #[test]
2906 #[cfg(unix)]
2907 fn recover_cleans_and_places_builds_that_crashed_after_completion() {
2908 let tmp = Temp::new();
2909 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2910 let reference = tree(&runs(&tmp.0)[0]);
2911 let consumer = |dir: &Path| {
2912 let mut names: Vec<String> = std::fs::read_dir(dir)
2913 .unwrap()
2914 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2915 .collect();
2916 names.sort();
2917 names
2918 };
2919 let clean = consumer(&runs(&tmp.0)[0]);
2920 assert_eq!(
2921 clean,
2922 [
2923 "male-body-atlas.ppda",
2924 "male-body-depth-atlas.png",
2925 "male-body-diffuse-atlas.png",
2926 "male-body-normal-atlas.png",
2927 "male-body-specular-atlas.png",
2928 "raw files",
2929 ]
2930 );
2931 // (a) crash after complete.json, before cleanup: all bookkeeping present.
2932 // (b) crash after cleanup, before the swap: only consumer output.
2933 for with_bookkeeping in [true, false] {
2934 let built = tmp
2935 .0
2936 .join(crate::resume::in_progress_name(7, 7, "male", "body"));
2937 std::fs::rename(tmp.0.join("male-body-atlas"), &built).unwrap();
2938 if with_bookkeeping {
2939 for name in [
2940 "render.json",
2941 "complete.json",
2942 crate::resume::JOURNAL,
2943 crate::resume::LOCK,
2944 crate::resume::RESUME_LOG,
2945 "attempt-000-cancelled.json",
2946 ] {
2947 std::fs::write(built.join(name), b"{}").unwrap();
2948 }
2949 }
2950 let notes = crate::resume::recover(&tmp.0).unwrap();
2951 assert_eq!(notes.len(), 1, "{notes:?}");
2952 let done = tmp.0.join("male-body-atlas");
2953 assert!(!built.exists());
2954 assert_eq!(consumer(&done), clean);
2955 let rebase = |t: std::collections::BTreeMap<PathBuf, String>| {
2956 t.into_values().collect::<Vec<_>>()
2957 };
2958 assert_eq!(rebase(tree(&done)), rebase(reference.clone()));
2959 crate::export_set::ExportRun::open(&done).unwrap();
2960 }
2961 }
2962
2889 #[test]
2963 #[test]
2890 #[cfg(unix)]
2964 #[cfg(unix)]
2891 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
2965 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
@@ -2902,7 +2976,23 @@ mod tests {
2902 .0
2976 .0
2903 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2977 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2904 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2978 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2905 std::fs::remove_file(run.join("complete.json")).unwrap();
2979 // Recreate the in-progress bookkeeping that existed before complete.json.
2980 let export = crate::atlas::AtlasSet::open_run(&run)
2981 .unwrap()
2982 .ppda
2983 .export
2984 .unwrap();
2985 std::fs::write(
2986 run.join("render.json"),
2987 serde_json::to_vec_pretty(&export.render).unwrap(),
2988 )
2989 .unwrap();
2990 let journal: String = export
2991 .png_sha256
2992 .iter()
2993 .map(|(path, sha)| serde_json::json!({"path": path, "sha256": sha}).to_string() + "\n")
2994 .collect();
2995 std::fs::write(run.join(crate::resume::JOURNAL), journal).unwrap();
2906 let diffuse = run.join("male-body-diffuse-atlas.png");
2996 let diffuse = run.join("male-body-diffuse-atlas.png");
2907 let normal = run.join("male-body-normal-atlas.png");
2997 let normal = run.join("male-body-normal-atlas.png");
2908 let normal_inode = inode(&normal);
2998 let normal_inode = inode(&normal);
@@ -2997,7 +3087,7 @@ mod tests {
2997 .is_err());
3087 .is_err());
2998 let run = runs(&tmp.0)[0].clone();
3088 let run = runs(&tmp.0)[0].clone();
2999 // A planned PNG appears that the journal never recorded and that differs.
3089 // A planned PNG appears that the journal never recorded and that differs.
3000 let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");
3090 let foreign = run.join("raw files/male/body/clip000-bind/f000000/p-60_y030_specular.png");
3001 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
3091 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
3002 std::fs::write(&foreign, b"foreign").unwrap();
3092 std::fs::write(&foreign, b"foreign").unwrap();
3003 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3093 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
src/resume.rs +58 −7
@@ -29,6 +29,57 @@ pub const IN_PROGRESS_PREFIX: &str = "inprogress-";
29 29 /// Prefix of a previous result folder moved aside during the final swap.
30 30 pub const REPLACED_PREFIX: &str = ".replaced-";
31 31 const MAX_RENDER_JSON: u64 = 1024 * 1024;
32 /// Subfolder of a result folder with every loose per-frame map folder (Tim t993u).
33 pub const RAW_DIR: &str = "raw files";
34 /// Completion marker of a build; lives only in the in-progress folder.
35 pub const COMPLETE_MARKER: &str = "complete.json";
36
37 fn has_ppda(dir: &Path) -> bool {
38 fs::read_dir(dir).is_ok_and(|read| {
39 read.flatten()
40 .any(|e| e.file_name().to_string_lossy().ends_with("-atlas.ppda"))
41 })
42 }
43
44 /// True when `dir` holds a completed build: its `complete.json` marker, or a
45 /// build already stripped of bookkeeping (no `render.json`, a `.ppda` present).
46 pub fn is_complete(dir: &Path) -> bool {
47 fs::symlink_metadata(dir.join(COMPLETE_MARKER)).is_ok()
48 || (fs::symlink_metadata(dir.join("render.json")).is_err() && has_ppda(dir))
49 }
50
51 /// Remove render/resume/lock bookkeeping from a completed, unlocked build so
52 /// the result folder holds only consumer output (`.ppda`, atlases,
53 /// `raw files/`). The `.ppda` carries everything the reader still needs.
54 /// Idempotent and crash-safe: `complete.json` is removed last.
55 pub fn strip_bookkeeping(root: &Path) -> Result<(), String> {
56 if !is_complete(root) {
57 return Err("opschonen geweigerd: render is niet voltooid".into());
58 }
59 if is_locked(root) {
60 return Err("opschonen geweigerd: run.lock is bezet".into());
61 }
62 for entry in fs::read_dir(root).map_err(io)? {
63 let entry = entry.map_err(io)?;
64 let name = entry.file_name().to_string_lossy().to_string();
65 let bookkeeping = name == "render.json"
66 || name == JOURNAL
67 || name == RESUME_LOG
68 || name == LOCK
69 || (name.starts_with("attempt-") && name.ends_with(".json"));
70 if bookkeeping && entry.file_type().map_err(io)?.is_file() {
71 fs::remove_file(entry.path()).map_err(|e| format!("opschonen {name}: {e}"))?;
72 }
73 }
74 sync_dir(root);
75 match fs::remove_file(root.join(COMPLETE_MARKER)) {
76 Ok(()) => {}
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
78 Err(e) => return Err(format!("opschonen complete.json: {e}")),
79 }
80 sync_dir(root);
81 Ok(())
82 }
32 83 const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
33 84 const MAX_CANDIDATES: usize = 20;
34 85 const MAX_SCANNED_RUNS: usize = 2000;
@@ -284,7 +335,7 @@ impl RunSession {
284 335 progress: &mut dyn FnMut(usize, usize),
285 336 ) -> Result<Self, String> {
286 337 let root = run_root(output, dir)?;
287 if fs::symlink_metadata(root.join("complete.json")).is_ok() {
338 if is_complete(&root) {
288 339 return Err("deze run is al voltooid (complete.json bestaat)".into());
289 340 }
290 341 let lock = lock_run(&root)?;
@@ -463,7 +514,7 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
463 514 continue;
464 515 }
465 516 let dir = entry.path();
466 if fs::symlink_metadata(dir.join("complete.json")).is_ok() {
517 if is_complete(&dir) {
467 518 continue;
468 519 }
469 520 let Ok(existing) = read_render(&dir) else {
@@ -611,17 +662,17 @@ pub fn recover(output: &Path) -> Result<Vec<String>, String> {
611 662 built.sort();
612 663 for name in built {
613 664 let dir = output.join(&name);
614 if fs::symlink_metadata(dir.join("complete.json")).is_err() || is_locked(&dir) {
665 if !is_complete(&dir) || is_locked(&dir) {
615 666 continue;
616 667 }
617 let Ok(doc) = read_render(&dir) else { continue };
618 let (Some(sex), Some(set)) = (doc["sex"].as_str(), doc["set"].as_str()) else {
668 let Ok(atlas) = crate::atlas::AtlasSet::open_run(&dir) else {
619 669 continue;
620 670 };
621 if crate::atlas::AtlasSet::open_run(&dir).is_err() {
671 if crate::export_set::ExportRun::open(&dir).is_err() {
622 672 continue;
623 673 }
624 let final_name = result_dir_name(sex, set);
674 strip_bookkeeping(&dir)?;
675 let final_name = result_dir_name(&atlas.ppda.sex, &atlas.ppda.settype);
625 676 promote(output, &dir, &final_name)?;
626 677 notes.push(format!(
627 678 "voltooide render {name} alsnog geplaatst als {final_name}"
@@ -29,6 +29,57 @@ pub const IN_PROGRESS_PREFIX: &str = "inprogress-";
29/// Prefix of a previous result folder moved aside during the final swap.
29/// Prefix of a previous result folder moved aside during the final swap.
30pub const REPLACED_PREFIX: &str = ".replaced-";
30pub const REPLACED_PREFIX: &str = ".replaced-";
31const MAX_RENDER_JSON: u64 = 1024 * 1024;
31const MAX_RENDER_JSON: u64 = 1024 * 1024;
32/// Subfolder of a result folder with every loose per-frame map folder (Tim t993u).
33pub const RAW_DIR: &str = "raw files";
34/// Completion marker of a build; lives only in the in-progress folder.
35pub const COMPLETE_MARKER: &str = "complete.json";
36
37fn has_ppda(dir: &Path) -> bool {
38 fs::read_dir(dir).is_ok_and(|read| {
39 read.flatten()
40 .any(|e| e.file_name().to_string_lossy().ends_with("-atlas.ppda"))
41 })
42}
43
44/// True when `dir` holds a completed build: its `complete.json` marker, or a
45/// build already stripped of bookkeeping (no `render.json`, a `.ppda` present).
46pub fn is_complete(dir: &Path) -> bool {
47 fs::symlink_metadata(dir.join(COMPLETE_MARKER)).is_ok()
48 || (fs::symlink_metadata(dir.join("render.json")).is_err() && has_ppda(dir))
49}
50
51/// Remove render/resume/lock bookkeeping from a completed, unlocked build so
52/// the result folder holds only consumer output (`.ppda`, atlases,
53/// `raw files/`). The `.ppda` carries everything the reader still needs.
54/// Idempotent and crash-safe: `complete.json` is removed last.
55pub fn strip_bookkeeping(root: &Path) -> Result<(), String> {
56 if !is_complete(root) {
57 return Err("opschonen geweigerd: render is niet voltooid".into());
58 }
59 if is_locked(root) {
60 return Err("opschonen geweigerd: run.lock is bezet".into());
61 }
62 for entry in fs::read_dir(root).map_err(io)? {
63 let entry = entry.map_err(io)?;
64 let name = entry.file_name().to_string_lossy().to_string();
65 let bookkeeping = name == "render.json"
66 || name == JOURNAL
67 || name == RESUME_LOG
68 || name == LOCK
69 || (name.starts_with("attempt-") && name.ends_with(".json"));
70 if bookkeeping && entry.file_type().map_err(io)?.is_file() {
71 fs::remove_file(entry.path()).map_err(|e| format!("opschonen {name}: {e}"))?;
72 }
73 }
74 sync_dir(root);
75 match fs::remove_file(root.join(COMPLETE_MARKER)) {
76 Ok(()) => {}
77 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
78 Err(e) => return Err(format!("opschonen complete.json: {e}")),
79 }
80 sync_dir(root);
81 Ok(())
82}
32const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
83const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
33const MAX_CANDIDATES: usize = 20;
84const MAX_CANDIDATES: usize = 20;
34const MAX_SCANNED_RUNS: usize = 2000;
85const MAX_SCANNED_RUNS: usize = 2000;
@@ -284,7 +335,7 @@ impl RunSession {
284 progress: &mut dyn FnMut(usize, usize),
335 progress: &mut dyn FnMut(usize, usize),
285 ) -> Result<Self, String> {
336 ) -> Result<Self, String> {
286 let root = run_root(output, dir)?;
337 let root = run_root(output, dir)?;
287 if fs::symlink_metadata(root.join("complete.json")).is_ok() {
338 if is_complete(&root) {
288 return Err("deze run is al voltooid (complete.json bestaat)".into());
339 return Err("deze run is al voltooid (complete.json bestaat)".into());
289 }
340 }
290 let lock = lock_run(&root)?;
341 let lock = lock_run(&root)?;
@@ -463,7 +514,7 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
463 continue;
514 continue;
464 }
515 }
465 let dir = entry.path();
516 let dir = entry.path();
466 if fs::symlink_metadata(dir.join("complete.json")).is_ok() {
517 if is_complete(&dir) {
467 continue;
518 continue;
468 }
519 }
469 let Ok(existing) = read_render(&dir) else {
520 let Ok(existing) = read_render(&dir) else {
@@ -611,17 +662,17 @@ pub fn recover(output: &Path) -> Result<Vec<String>, String> {
611 built.sort();
662 built.sort();
612 for name in built {
663 for name in built {
613 let dir = output.join(&name);
664 let dir = output.join(&name);
614 if fs::symlink_metadata(dir.join("complete.json")).is_err() || is_locked(&dir) {
665 if !is_complete(&dir) || is_locked(&dir) {
615 continue;
666 continue;
616 }
667 }
617 let Ok(doc) = read_render(&dir) else { continue };
668 let Ok(atlas) = crate::atlas::AtlasSet::open_run(&dir) else {
618 let (Some(sex), Some(set)) = (doc["sex"].as_str(), doc["set"].as_str()) else {
619 continue;
669 continue;
620 };
670 };
621 if crate::atlas::AtlasSet::open_run(&dir).is_err() {
671 if crate::export_set::ExportRun::open(&dir).is_err() {
622 continue;
672 continue;
623 }
673 }
624 let final_name = result_dir_name(sex, set);
674 strip_bookkeeping(&dir)?;
675 let final_name = result_dir_name(&atlas.ppda.sex, &atlas.ppda.settype);
625 promote(output, &dir, &final_name)?;
676 promote(output, &dir, &final_name)?;
626 notes.push(format!(
677 notes.push(format!(
627 "voltooide render {name} alsnog geplaatst als {final_name}"
678 "voltooide render {name} alsnog geplaatst als {final_name}"