Merge feature/clean-final-folder: consumer-only result folder (Tim t993u/t994u)
@@ -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

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.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
```text116
```text117
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png117
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, -spec119
renders/male-body-atlas/male-body-atlas.ppda119
renders/male-body-atlas/male-body-atlas.ppda120
```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.ppda129
male-body-diffuse-atlas.png130
male-body-normal-atlas.png131
male-body-depth-atlas.png132
male-body-specular-atlas.png133
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
### Atlassen en .ppda141
### Atlassen en .ppda125
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
- 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
### Hervatten151
### Hervatten@@ -139,7 +156,7 @@ Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de r139
- 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
161
145
162
@@ -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
project158
project159
}159
}160
};160
};@@ -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 is32
/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is33
/// still read (16-bit gray).33
/// still read (16-bit gray).34
pub const PPDA_FORMAT_VERSION: u32 = 3;34
pub const PPDA_FORMAT_VERSION: u32 = 4;35
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are35
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are36
/// refused up front with a clear message; atlases are never split.36
/// refused up front with a clear message; atlases are never split.37
pub const MAX_ATLAS_DIM: u32 = 16384;37
pub 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 finished164
/// 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
#[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_version623
p.format_version602
)));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!(export1229
.png_sha2561230
.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
.complete1432
.atlas1433
.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(@@ -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
@@ -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
err261
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
err268
}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 = relative334
let mut key = relative325
.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`'s918
/// 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> = set933
.ppda934
.maps935
.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
fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {962
fn 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() {@@ -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);@@ -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 by1818
// strip_bookkeeping once the .ppda (which embeds the export1819
// 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
expected2125
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
.ppda2508
.export2509
.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 = opened2562
let sample = opened2546
.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
v2690
v2674
}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
.ppda2696
.export2697
.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
names2918
};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 = tmp2935
.02936
.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
.02976
.02903
.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
.ppda2983
.export2984
.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 = export2991
.png_sha2562992
.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)@@ -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.30
pub const REPLACED_PREFIX: &str = ".replaced-";30
pub const REPLACED_PREFIX: &str = ".replaced-";31
const MAX_RENDER_JSON: u64 = 1024 * 1024;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 a45
/// 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 so52
/// 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 == JOURNAL67
|| name == RESUME_LOG68
|| name == LOCK69
|| (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
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;83
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;33
const MAX_CANDIDATES: usize = 20;84
const MAX_CANDIDATES: usize = 20;34
const MAX_SCANNED_RUNS: usize = 2000;85
const 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<ResumeCandida463
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}"