BlueGit

golden-age paperdoll-renderer

Merge feature/atlas-result-folder: renders/<sex>-<settype>-atlas newest-only result folder (9i)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
8692da48491f5805c04a46efd1f2dccb74f3f289
6 files changed +295 −32
README.md +7 −7
@@ -42,7 +42,7 @@ Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL
42 42
43 43 ## 2D-preview
44 44
45 Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide exports in `renders/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
45 Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over de resultaatmappen `renders/<geslacht>-<settype>-atlas/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
46 46
47 47 - Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
48 48 - Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
@@ -92,16 +92,16 @@ De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de e
92 92 Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). 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.
93 93
94 94 ```text
95 renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
96 renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
97 renders/run-<tijd>-<pid>/male-body-atlas.ppda
95 renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
96 renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
97 renders/male-body-atlas/male-body-atlas.ppda
98 98 ```
99 99
100 Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 5; met `depth_near`, `depth_far`, `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. Exports met een ouder schema (o.a. schema 4 met 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-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
100 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 5; met `depth_near`, `depth_far`, `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. Exports met een ouder schema (o.a. schema 4 met 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-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
101 101
102 102 ### Atlassen en .ppda
103 103
104 Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
104 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`.
105 105
106 106 - 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; depth blijft 16-bit grijs+alpha.
107 107 - 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.
@@ -111,7 +111,7 @@ Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de re
111 111
112 112 ### Hervatten
113 113
114 Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaangeroerd.
114 Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is.
115 115
116 116 - Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
117 117 - 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.
@@ -42,7 +42,7 @@ Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL
42
42
43## 2D-preview
43## 2D-preview
44
44
45Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide exports in `renders/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
45Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over de resultaatmappen `renders/<geslacht>-<settype>-atlas/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
46
46
47- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
47- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
48- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
48- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
@@ -92,16 +92,16 @@ De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de e
92Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). 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.
92Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). 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.
93
93
94```text
94```text
95renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
95renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
96renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
96renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
97renders/run-<tijd>-<pid>/male-body-atlas.ppda
97renders/male-body-atlas/male-body-atlas.ppda
98```
98```
99
99
100Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 5; met `depth_near`, `depth_far`, `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. Exports met een ouder schema (o.a. schema 4 met 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-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
100Per 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 5; met `depth_near`, `depth_far`, `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. Exports met een ouder schema (o.a. schema 4 met 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-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
101
101
102### Atlassen en .ppda
102### Atlassen en .ppda
103
103
104Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
104Als 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`.
105
105
106- 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; depth blijft 16-bit grijs+alpha.
106- 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; depth blijft 16-bit grijs+alpha.
107- 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.
107- 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.
@@ -111,7 +111,7 @@ Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de re
111
111
112### Hervatten
112### Hervatten
113
113
114Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaangeroerd.
114Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is.
115
115
116- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
116- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
117- 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.
117- 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.
design.md +2 −0
@@ -402,3 +402,5 @@ A **Render alles** button renders the whole project in one batch: every configur
402 402 ## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only
403 403
404 404 Exports no longer pile up as `renders/run-<id>/` folders. Each result lives in `renders/<sex>-<settype>-atlas/` and holds only the newest complete render (loose PNGs, atlases, `.ppda`, manifests). A new render is built in a temporary in-progress folder inside `renders/` (also used for resume). Only after it is fully complete and verified is the previous `<sex>-<settype>-atlas/` removed and the new folder renamed into place. A crash or cancel therefore never loses the last good result, and an unfinished run can still be resumed. Older `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a notice. The 2D preview and **Render alles** use these fixed folders.
405
406 Implementation (2026-11-07): the build folder is `renders/inprogress-<stamp>-<pid>-<sex>-<settype>/` (the only resumable kind). After `complete.json` the run is re-validated with `AtlasSet::open_run`, its lock released, the old result renamed to `.replaced-<name>-<stamp>`, the build renamed to `<sex>-<settype>-atlas`, then the old one removed (restored if the rename fails). `resume::recover` runs before every render: a `.replaced-*` folder is restored when its result folder is missing (else removed), and a completed, unlocked, validating `inprogress-*` folder is promoted. The 2D preview only opens `*-atlas` folders whose name matches their `.ppda` sex/settype; `run-*` folders are skipped with a notice; `inprogress-*`/`.replaced-*` are ignored silently.
@@ -402,3 +402,5 @@ A **Render alles** button renders the whole project in one batch: every configur
402## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only
402## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only
403
403
404Exports no longer pile up as `renders/run-<id>/` folders. Each result lives in `renders/<sex>-<settype>-atlas/` and holds only the newest complete render (loose PNGs, atlases, `.ppda`, manifests). A new render is built in a temporary in-progress folder inside `renders/` (also used for resume). Only after it is fully complete and verified is the previous `<sex>-<settype>-atlas/` removed and the new folder renamed into place. A crash or cancel therefore never loses the last good result, and an unfinished run can still be resumed. Older `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a notice. The 2D preview and **Render alles** use these fixed folders.
404Exports no longer pile up as `renders/run-<id>/` folders. Each result lives in `renders/<sex>-<settype>-atlas/` and holds only the newest complete render (loose PNGs, atlases, `.ppda`, manifests). A new render is built in a temporary in-progress folder inside `renders/` (also used for resume). Only after it is fully complete and verified is the previous `<sex>-<settype>-atlas/` removed and the new folder renamed into place. A crash or cancel therefore never loses the last good result, and an unfinished run can still be resumed. Older `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a notice. The 2D preview and **Render alles** use these fixed folders.
405
406Implementation (2026-11-07): the build folder is `renders/inprogress-<stamp>-<pid>-<sex>-<settype>/` (the only resumable kind). After `complete.json` the run is re-validated with `AtlasSet::open_run`, its lock released, the old result renamed to `.replaced-<name>-<stamp>`, the build renamed to `<sex>-<settype>-atlas`, then the old one removed (restored if the rename fails). `resume::recover` runs before every render: a `.replaced-*` folder is restored when its result folder is missing (else removed), and a completed, unlocked, validating `inprogress-*` folder is promoted. The 2D preview only opens `*-atlas` folders whose name matches their `.ppda` sex/settype; `run-*` folders are skipped with a notice; `inprogress-*`/`.replaced-*` are ignored silently.
examples/preview2d_smoke.rs +2 −2
@@ -121,7 +121,7 @@ fn install_body_export(project: &Path) -> Result<(), String> {
121 121 spec.target = [2, 2];
122 122 spec.clips = vec![("walk".into(), 2, 0.5)];
123 123 spec.write(
124 &project.join("renders/run-preview2d-body"),
124 &project.join("renders/male-body-atlas"),
125 125 |_, frame, pitch, yaw| Pixel {
126 126 diffuse: [
127 127 40u8.wrapping_add(frame as u8 * 80),
@@ -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/run-preview2d-body/male"))?;
157 fs::remove_dir_all(project.join("renders/male-body-atlas/male"))?;
158 158 project
159 159 }
160 160 };
@@ -121,7 +121,7 @@ fn install_body_export(project: &Path) -> Result<(), String> {
121 spec.target = [2, 2];
121 spec.target = [2, 2];
122 spec.clips = vec![("walk".into(), 2, 0.5)];
122 spec.clips = vec![("walk".into(), 2, 0.5)];
123 spec.write(
123 spec.write(
124 &project.join("renders/run-preview2d-body"),
124 &project.join("renders/male-body-atlas"),
125 |_, frame, pitch, yaw| Pixel {
125 |_, frame, pitch, yaw| Pixel {
126 diffuse: [
126 diffuse: [
127 40u8.wrapping_add(frame as u8 * 80),
127 40u8.wrapping_add(frame as u8 * 80),
@@ -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/run-preview2d-body/male"))?;
157 fs::remove_dir_all(project.join("renders/male-body-atlas/male"))?;
158 project
158 project
159 }
159 }
160 };
160 };
src/preview2d.rs +42 −10
@@ -184,7 +184,7 @@ impl CatalogEntry {
184 184 /// Run directories under `renders/` that were skipped, with the reason.
185 185 pub type Skipped = Vec<(PathBuf, String)>;
186 186
187 /// Scan `project_root/renders/*/` and open every export whose `.ppda` and
187 /// Scan `project_root/renders/<sex>-<set>-atlas/` and open every export whose `.ppda` and
188 188 /// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
189 189 /// damaged) are skipped.
190 190 pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
@@ -204,8 +204,37 @@ pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
204 204 if !path.is_dir() {
205 205 continue;
206 206 }
207 let name = entry.file_name().to_string_lossy().to_string();
208 if name.starts_with(crate::resume::IN_PROGRESS_PREFIX)
209 || name.starts_with(crate::resume::REPLACED_PREFIX)
210 {
211 continue; // unfinished build / swap leftover, never a result
212 }
213 if name.starts_with("run-") {
214 skipped.push((
215 path,
216 "oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar <sekse>-<settype>-atlas)".into(),
217 ));
218 continue;
219 }
220 if !name.ends_with("-atlas") {
221 continue;
222 }
207 223 match AtlasSet::open_run(&path) {
208 Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),
224 Ok(set) => {
225 let entry = CatalogEntry::from_atlas(path.clone(), &set);
226 if name == crate::resume::result_dir_name(&entry.sex, &entry.set) {
227 out.push(entry);
228 } else {
229 skipped.push((
230 path,
231 format!(
232 "mapnaam past niet bij inhoud ({})",
233 crate::resume::result_dir_name(&entry.sex, &entry.set)
234 ),
235 ));
236 }
237 }
209 238 Err(err) => skipped.push((path, err.to_string())),
210 239 }
211 240 }
@@ -874,7 +903,7 @@ mod tests {
874 903 #[test]
875 904 fn missing_frame_is_diagnostic_not_silent_substitute() {
876 905 let project = Temp::new();
877 let dir = project.0.join("renders/run-body");
906 let dir = project.0.join("renders/male-body-atlas");
878 907 write_run(
879 908 &dir,
880 909 "demo",
@@ -922,7 +951,7 @@ mod tests {
922 951 let clips = [("bind", 1u32, 0.0f32)];
923 952 let body_bytes = b"shared-body";
924 953 write_run(
925 &renders.join("run-body"),
954 &renders.join("male-body-atlas"),
926 955 "proj-a",
927 956 "body",
928 957 None,
@@ -931,7 +960,7 @@ mod tests {
931 960 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
932 961 );
933 962 write_run(
934 &renders.join("run-sword"),
963 &renders.join("male-sword-atlas"),
935 964 "proj-a",
936 965 "sword",
937 966 Some(&digest::sha256_hex(b"sword")),
@@ -946,7 +975,7 @@ mod tests {
946 975 );
947 976 // Different body fingerprint → incompatible.
948 977 write_run(
949 &renders.join("run-bad"),
978 &renders.join("male-helm-atlas"),
950 979 "proj-a",
951 980 "helm",
952 981 Some(&digest::sha256_hex(b"helm")),
@@ -1052,7 +1081,7 @@ mod tests {
1052 1081 let project = Temp::new();
1053 1082 // Distinct channels so modes are distinguishable; scalar specular.
1054 1083 write_run(
1055 &project.0.join("renders/run-body"),
1084 &project.0.join("renders/male-body-atlas"),
1056 1085 "demo",
1057 1086 "body",
1058 1087 None,
@@ -1138,14 +1167,17 @@ mod tests {
1138 1167 fn discover_skips_incomplete_and_wrong_schema() {
1139 1168 let project = Temp::new();
1140 1169 let renders = project.0.join("renders");
1141 let incomplete = renders.join("run-incomplete");
1170 let incomplete = renders.join("male-body-atlas");
1142 1171 fs::create_dir_all(&incomplete).unwrap();
1143 1172 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1144 1173 // no .ppda at all
1174 fs::create_dir_all(renders.join("run-legacy")).unwrap();
1175 fs::create_dir_all(renders.join("inprogress-1-1-male-body")).unwrap();
1145 1176 let (catalog, skipped) = discover_exports(&project.0);
1146 1177 assert!(catalog.is_empty());
1147 assert_eq!(skipped.len(), 1);
1178 assert_eq!(skipped.len(), 2, "{skipped:?}");
1148 1179 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1180 assert!(skipped[1].1.contains("oude run-map"), "{}", skipped[1].1);
1149 1181 }
1150 1182
1151 1183 #[test]
@@ -1153,7 +1185,7 @@ mod tests {
1153 1185 // Each view gets a distinct colour; the preview must return exactly the
1154 1186 // atlas cell even after every loose PNG is deleted.
1155 1187 let project = Temp::new();
1156 let dir = project.0.join("renders/run-body");
1188 let dir = project.0.join("renders/male-body-atlas");
1157 1189 let mut spec = SyntheticRun::body("demo", "male", b"b");
1158 1190 spec.clips = vec![("walk".into(), 2, 0.5)];
1159 1191 spec.target = [2, 3];
@@ -184,7 +184,7 @@ impl CatalogEntry {
184/// Run directories under `renders/` that were skipped, with the reason.
184/// Run directories under `renders/` that were skipped, with the reason.
185pub type Skipped = Vec<(PathBuf, String)>;
185pub type Skipped = Vec<(PathBuf, String)>;
186
186
187/// Scan `project_root/renders/*/` and open every export whose `.ppda` and
187/// Scan `project_root/renders/<sex>-<set>-atlas/` and open every export whose `.ppda` and
188/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
188/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
189/// damaged) are skipped.
189/// damaged) are skipped.
190pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
190pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
@@ -204,8 +204,37 @@ pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
204 if !path.is_dir() {
204 if !path.is_dir() {
205 continue;
205 continue;
206 }
206 }
207 let name = entry.file_name().to_string_lossy().to_string();
208 if name.starts_with(crate::resume::IN_PROGRESS_PREFIX)
209 || name.starts_with(crate::resume::REPLACED_PREFIX)
210 {
211 continue; // unfinished build / swap leftover, never a result
212 }
213 if name.starts_with("run-") {
214 skipped.push((
215 path,
216 "oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar <sekse>-<settype>-atlas)".into(),
217 ));
218 continue;
219 }
220 if !name.ends_with("-atlas") {
221 continue;
222 }
207 match AtlasSet::open_run(&path) {
223 match AtlasSet::open_run(&path) {
208 Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),
224 Ok(set) => {
225 let entry = CatalogEntry::from_atlas(path.clone(), &set);
226 if name == crate::resume::result_dir_name(&entry.sex, &entry.set) {
227 out.push(entry);
228 } else {
229 skipped.push((
230 path,
231 format!(
232 "mapnaam past niet bij inhoud ({})",
233 crate::resume::result_dir_name(&entry.sex, &entry.set)
234 ),
235 ));
236 }
237 }
209 Err(err) => skipped.push((path, err.to_string())),
238 Err(err) => skipped.push((path, err.to_string())),
210 }
239 }
211 }
240 }
@@ -874,7 +903,7 @@ mod tests {
874 #[test]
903 #[test]
875 fn missing_frame_is_diagnostic_not_silent_substitute() {
904 fn missing_frame_is_diagnostic_not_silent_substitute() {
876 let project = Temp::new();
905 let project = Temp::new();
877 let dir = project.0.join("renders/run-body");
906 let dir = project.0.join("renders/male-body-atlas");
878 write_run(
907 write_run(
879 &dir,
908 &dir,
880 "demo",
909 "demo",
@@ -922,7 +951,7 @@ mod tests {
922 let clips = [("bind", 1u32, 0.0f32)];
951 let clips = [("bind", 1u32, 0.0f32)];
923 let body_bytes = b"shared-body";
952 let body_bytes = b"shared-body";
924 write_run(
953 write_run(
925 &renders.join("run-body"),
954 &renders.join("male-body-atlas"),
926 "proj-a",
955 "proj-a",
927 "body",
956 "body",
928 None,
957 None,
@@ -931,7 +960,7 @@ mod tests {
931 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
960 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
932 );
961 );
933 write_run(
962 write_run(
934 &renders.join("run-sword"),
963 &renders.join("male-sword-atlas"),
935 "proj-a",
964 "proj-a",
936 "sword",
965 "sword",
937 Some(&digest::sha256_hex(b"sword")),
966 Some(&digest::sha256_hex(b"sword")),
@@ -946,7 +975,7 @@ mod tests {
946 );
975 );
947 // Different body fingerprint → incompatible.
976 // Different body fingerprint → incompatible.
948 write_run(
977 write_run(
949 &renders.join("run-bad"),
978 &renders.join("male-helm-atlas"),
950 "proj-a",
979 "proj-a",
951 "helm",
980 "helm",
952 Some(&digest::sha256_hex(b"helm")),
981 Some(&digest::sha256_hex(b"helm")),
@@ -1052,7 +1081,7 @@ mod tests {
1052 let project = Temp::new();
1081 let project = Temp::new();
1053 // Distinct channels so modes are distinguishable; scalar specular.
1082 // Distinct channels so modes are distinguishable; scalar specular.
1054 write_run(
1083 write_run(
1055 &project.0.join("renders/run-body"),
1084 &project.0.join("renders/male-body-atlas"),
1056 "demo",
1085 "demo",
1057 "body",
1086 "body",
1058 None,
1087 None,
@@ -1138,14 +1167,17 @@ mod tests {
1138 fn discover_skips_incomplete_and_wrong_schema() {
1167 fn discover_skips_incomplete_and_wrong_schema() {
1139 let project = Temp::new();
1168 let project = Temp::new();
1140 let renders = project.0.join("renders");
1169 let renders = project.0.join("renders");
1141 let incomplete = renders.join("run-incomplete");
1170 let incomplete = renders.join("male-body-atlas");
1142 fs::create_dir_all(&incomplete).unwrap();
1171 fs::create_dir_all(&incomplete).unwrap();
1143 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1172 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1144 // no .ppda at all
1173 // no .ppda at all
1174 fs::create_dir_all(renders.join("run-legacy")).unwrap();
1175 fs::create_dir_all(renders.join("inprogress-1-1-male-body")).unwrap();
1145 let (catalog, skipped) = discover_exports(&project.0);
1176 let (catalog, skipped) = discover_exports(&project.0);
1146 assert!(catalog.is_empty());
1177 assert!(catalog.is_empty());
1147 assert_eq!(skipped.len(), 1);
1178 assert_eq!(skipped.len(), 2, "{skipped:?}");
1148 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1179 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1180 assert!(skipped[1].1.contains("oude run-map"), "{}", skipped[1].1);
1149 }
1181 }
1150
1182
1151 #[test]
1183 #[test]
@@ -1153,7 +1185,7 @@ mod tests {
1153 // Each view gets a distinct colour; the preview must return exactly the
1185 // Each view gets a distinct colour; the preview must return exactly the
1154 // atlas cell even after every loose PNG is deleted.
1186 // atlas cell even after every loose PNG is deleted.
1155 let project = Temp::new();
1187 let project = Temp::new();
1156 let dir = project.0.join("renders/run-body");
1188 let dir = project.0.join("renders/male-body-atlas");
1157 let mut spec = SyntheticRun::body("demo", "male", b"b");
1189 let mut spec = SyntheticRun::body("demo", "male", b"b");
1158 spec.clips = vec![("walk".into(), 2, 0.5)];
1190 spec.clips = vec![("walk".into(), 2, 0.5)];
1159 spec.target = [2, 3];
1191 spec.target = [2, 3];
src/production.rs +112 −9
@@ -1491,15 +1491,25 @@ fn execute_target(
1491 1491 } = contract(job)?;
1492 1492 let planned = planned_paths(&plan, &job.sex, &set)?;
1493 1493 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1494 for note in crate::resume::recover(&job.output)? {
1495 emit(RenderEvent::Step {
1496 index: 0,
1497 total: plan.passes,
1498 label: format!("Herstel: {note}"),
1499 });
1500 }
1494 1501 let mut session = match target {
1495 1502 RunTarget::Fresh => {
1496 1503 let stamp = std::time::SystemTime::now()
1497 1504 .duration_since(std::time::UNIX_EPOCH)
1498 1505 .map_err(|e| e.to_string())?
1499 1506 .as_nanos();
1500 let root = job
1501 .output
1502 .join(format!("run-{stamp}-{}", std::process::id()));
1507 let root = job.output.join(crate::resume::in_progress_name(
1508 stamp,
1509 std::process::id(),
1510 &job.sex,
1511 &set,
1512 ));
1503 1513 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1504 1514 let session = crate::resume::RunSession::fresh(&root)?;
1505 1515 write_json(&root, "render.json", render_doc.clone())?;
@@ -1748,8 +1758,17 @@ fn execute_target(
1748 1758 ),
1749 1759 });
1750 1760 }
1761 // Verify the finished build before it replaces the last good result.
1762 crate::atlas::AtlasSet::open_run(&root)
1763 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1764 drop(session);
1765 let final_dir = crate::resume::promote(
1766 &job.output,
1767 &root,
1768 &crate::resume::result_dir_name(&job.sex, &set),
1769 )?;
1751 1770 emit(RenderEvent::Finished {
1752 saved: Some(root.display().to_string()),
1771 saved: Some(final_dir.display().to_string()),
1753 1772 });
1754 1773 Ok(())
1755 1774 }
@@ -2654,9 +2673,22 @@ mod tests {
2654 2673 "verified PNG was rewritten"
2655 2674 );
2656 2675
2657 // Third attempt completes.
2676 // Cancelled/unfinished builds never touch the last good result.
2677 assert_eq!(tree(&earlier), earlier_tree);
2678 // Third attempt completes and replaces the result folder.
2658 2679 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2659 2680 third.unwrap();
2681 let built = run.clone();
2682 let done = tmp.0.join("male-body-atlas");
2683 assert_eq!(earlier, done);
2684 assert!(!built.exists());
2685 assert_eq!(
2686 runs(&tmp.0),
2687 vec![done.clone()],
2688 "only the newest result remains"
2689 );
2690 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2691 let run = done;
2660 2692 assert!(!run.join("cancelled.json").exists());
2661 2693 assert!(run.join("attempt-001-cancelled.json").exists());
2662 2694 assert_eq!(inode(&first_file), first_inode);
@@ -2664,12 +2696,13 @@ mod tests {
2664 2696 crate::export_set::ExportRun::open(&run).unwrap();
2665 2697 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2666 2698 assert_eq!(log.lines().count(), 2);
2667 assert_eq!(tree(&earlier), earlier_tree);
2668 2699 // A completed run is no longer offered and cannot be resumed.
2669 2700 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2670 2701 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2671 2702 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2672 assert!(again.unwrap_err().contains("al voltooid"));
2703 assert!(again.unwrap_err().contains("uitvoermap"));
2704 let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None);
2705 assert!(gone.is_err());
2673 2706 }
2674 2707
2675 2708 #[test]
@@ -2712,6 +2745,15 @@ mod tests {
2712 2745 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2713 2746 .0
2714 2747 .unwrap();
2748 let done = tmp.0.join("male-body-atlas");
2749 let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap());
2750 let (frame, missing, stale, torn_path) = (
2751 rebase(&frame),
2752 rebase(&missing),
2753 rebase(&stale),
2754 rebase(&torn_path),
2755 );
2756 let run = done.clone();
2715 2757 assert_eq!(checksums(&run), reference);
2716 2758 crate::export_set::ExportRun::open(&run).unwrap();
2717 2759 assert!(missing.exists());
@@ -2766,9 +2808,12 @@ mod tests {
2766 2808 let reference_run = runs(&reference_dir.0)[0].clone();
2767 2809 let tmp = Temp::new();
2768 2810 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2769 let run = runs(&tmp.0)[0].clone();
2770 2811 // Simulate a crash after the atlases/.ppda were published but before
2771 // complete.json, with one atlas damaged afterwards.
2812 // complete.json (so still the in-progress build), one atlas damaged.
2813 let run = tmp
2814 .0
2815 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2816 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2772 2817 std::fs::remove_file(run.join("complete.json")).unwrap();
2773 2818 let diffuse = run.join("male-body-diffuse-atlas.png");
2774 2819 let normal = run.join("male-body-normal-atlas.png");
@@ -2779,6 +2824,13 @@ mod tests {
2779 2824 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2780 2825 .0
2781 2826 .unwrap();
2827 let built = run;
2828 let run = tmp.0.join("male-body-atlas");
2829 let (normal, diffuse) = (
2830 run.join(normal.file_name().unwrap()),
2831 run.join(diffuse.file_name().unwrap()),
2832 );
2833 assert!(!built.exists());
2782 2834 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2783 2835 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2784 2836 assert_eq!(
@@ -2801,6 +2853,54 @@ mod tests {
2801 2853 }
2802 2854 }
2803 2855
2856 #[test]
2857 fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() {
2858 let tmp = Temp::new();
2859 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2860 let done = tmp.0.join("male-body-atlas");
2861 let good = tree(&done);
2862 // A legacy run-* folder is left alone.
2863 let legacy = tmp.0.join("run-123-4");
2864 std::fs::create_dir(&legacy).unwrap();
2865 std::fs::write(legacy.join("x"), b"old").unwrap();
2866 // Cancelled re-render: last good result untouched, build stays resumable.
2867 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2868 .0
2869 .is_err());
2870 assert_eq!(tree(&done), good);
2871 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2872 assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1);
2873 // Crash after moving the old result aside but before placing the new one:
2874 // the old result is restored.
2875 let aside = tmp.0.join(".replaced-male-body-atlas-77");
2876 std::fs::rename(&done, &aside).unwrap();
2877 let notes = crate::resume::recover(&tmp.0).unwrap();
2878 assert_eq!(notes.len(), 1, "{notes:?}");
2879 assert!(!aside.exists());
2880 assert_eq!(tree(&done), good);
2881 // Crash after completing a build but before the swap: promoted, old removed.
2882 let fresh = Temp::new();
2883 drive(resume_job(&fresh.0), RunTarget::Fresh, None)
2884 .0
2885 .unwrap();
2886 let built = tmp
2887 .0
2888 .join(crate::resume::in_progress_name(9, 9, "male", "body"));
2889 std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap();
2890 std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap();
2891 let notes = crate::resume::recover(&tmp.0).unwrap();
2892 assert_eq!(notes.len(), 1, "{notes:?}");
2893 assert!(!built.exists());
2894 assert!(!done.join("marker-old").exists());
2895 crate::atlas::AtlasSet::open_run(&done).unwrap();
2896 assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e
2897 .unwrap()
2898 .file_name()
2899 .to_string_lossy()
2900 .starts_with(".replaced-")));
2901 assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old");
2902 }
2903
2804 2904 #[test]
2805 2905 fn unjournaled_different_file_is_quarantined_not_overwritten() {
2806 2906 let tmp = Temp::new();
@@ -2815,6 +2915,9 @@ mod tests {
2815 2915 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2816 2916 .0
2817 2917 .unwrap();
2918 let done = tmp.0.join("male-body-atlas");
2919 let foreign = done.join(foreign.strip_prefix(&run).unwrap());
2920 let run = done;
2818 2921 crate::export_set::ExportRun::open(&run).unwrap();
2819 2922 assert_eq!(
2820 2923 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
@@ -1491,15 +1491,25 @@ fn execute_target(
1491 } = contract(job)?;
1491 } = contract(job)?;
1492 let planned = planned_paths(&plan, &job.sex, &set)?;
1492 let planned = planned_paths(&plan, &job.sex, &set)?;
1493 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1493 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1494 for note in crate::resume::recover(&job.output)? {
1495 emit(RenderEvent::Step {
1496 index: 0,
1497 total: plan.passes,
1498 label: format!("Herstel: {note}"),
1499 });
1500 }
1494 let mut session = match target {
1501 let mut session = match target {
1495 RunTarget::Fresh => {
1502 RunTarget::Fresh => {
1496 let stamp = std::time::SystemTime::now()
1503 let stamp = std::time::SystemTime::now()
1497 .duration_since(std::time::UNIX_EPOCH)
1504 .duration_since(std::time::UNIX_EPOCH)
1498 .map_err(|e| e.to_string())?
1505 .map_err(|e| e.to_string())?
1499 .as_nanos();
1506 .as_nanos();
1500 let root = job
1507 let root = job.output.join(crate::resume::in_progress_name(
1501 .output
1508 stamp,
1502 .join(format!("run-{stamp}-{}", std::process::id()));
1509 std::process::id(),
1510 &job.sex,
1511 &set,
1512 ));
1503 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1513 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1504 let session = crate::resume::RunSession::fresh(&root)?;
1514 let session = crate::resume::RunSession::fresh(&root)?;
1505 write_json(&root, "render.json", render_doc.clone())?;
1515 write_json(&root, "render.json", render_doc.clone())?;
@@ -1748,8 +1758,17 @@ fn execute_target(
1748 ),
1758 ),
1749 });
1759 });
1750 }
1760 }
1761 // Verify the finished build before it replaces the last good result.
1762 crate::atlas::AtlasSet::open_run(&root)
1763 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1764 drop(session);
1765 let final_dir = crate::resume::promote(
1766 &job.output,
1767 &root,
1768 &crate::resume::result_dir_name(&job.sex, &set),
1769 )?;
1751 emit(RenderEvent::Finished {
1770 emit(RenderEvent::Finished {
1752 saved: Some(root.display().to_string()),
1771 saved: Some(final_dir.display().to_string()),
1753 });
1772 });
1754 Ok(())
1773 Ok(())
1755 }
1774 }
@@ -2654,9 +2673,22 @@ mod tests {
2654 "verified PNG was rewritten"
2673 "verified PNG was rewritten"
2655 );
2674 );
2656
2675
2657 // Third attempt completes.
2676 // Cancelled/unfinished builds never touch the last good result.
2677 assert_eq!(tree(&earlier), earlier_tree);
2678 // Third attempt completes and replaces the result folder.
2658 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2679 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2659 third.unwrap();
2680 third.unwrap();
2681 let built = run.clone();
2682 let done = tmp.0.join("male-body-atlas");
2683 assert_eq!(earlier, done);
2684 assert!(!built.exists());
2685 assert_eq!(
2686 runs(&tmp.0),
2687 vec![done.clone()],
2688 "only the newest result remains"
2689 );
2690 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2691 let run = done;
2660 assert!(!run.join("cancelled.json").exists());
2692 assert!(!run.join("cancelled.json").exists());
2661 assert!(run.join("attempt-001-cancelled.json").exists());
2693 assert!(run.join("attempt-001-cancelled.json").exists());
2662 assert_eq!(inode(&first_file), first_inode);
2694 assert_eq!(inode(&first_file), first_inode);
@@ -2664,12 +2696,13 @@ mod tests {
2664 crate::export_set::ExportRun::open(&run).unwrap();
2696 crate::export_set::ExportRun::open(&run).unwrap();
2665 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2697 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2666 assert_eq!(log.lines().count(), 2);
2698 assert_eq!(log.lines().count(), 2);
2667 assert_eq!(tree(&earlier), earlier_tree);
2668 // A completed run is no longer offered and cannot be resumed.
2699 // A completed run is no longer offered and cannot be resumed.
2669 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2700 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2670 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2701 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2671 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2702 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2672 assert!(again.unwrap_err().contains("al voltooid"));
2703 assert!(again.unwrap_err().contains("uitvoermap"));
2704 let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None);
2705 assert!(gone.is_err());
2673 }
2706 }
2674
2707
2675 #[test]
2708 #[test]
@@ -2712,6 +2745,15 @@ mod tests {
2712 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2745 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2713 .0
2746 .0
2714 .unwrap();
2747 .unwrap();
2748 let done = tmp.0.join("male-body-atlas");
2749 let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap());
2750 let (frame, missing, stale, torn_path) = (
2751 rebase(&frame),
2752 rebase(&missing),
2753 rebase(&stale),
2754 rebase(&torn_path),
2755 );
2756 let run = done.clone();
2715 assert_eq!(checksums(&run), reference);
2757 assert_eq!(checksums(&run), reference);
2716 crate::export_set::ExportRun::open(&run).unwrap();
2758 crate::export_set::ExportRun::open(&run).unwrap();
2717 assert!(missing.exists());
2759 assert!(missing.exists());
@@ -2766,9 +2808,12 @@ mod tests {
2766 let reference_run = runs(&reference_dir.0)[0].clone();
2808 let reference_run = runs(&reference_dir.0)[0].clone();
2767 let tmp = Temp::new();
2809 let tmp = Temp::new();
2768 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2810 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2769 let run = runs(&tmp.0)[0].clone();
2770 // Simulate a crash after the atlases/.ppda were published but before
2811 // Simulate a crash after the atlases/.ppda were published but before
2771 // complete.json, with one atlas damaged afterwards.
2812 // complete.json (so still the in-progress build), one atlas damaged.
2813 let run = tmp
2814 .0
2815 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2816 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2772 std::fs::remove_file(run.join("complete.json")).unwrap();
2817 std::fs::remove_file(run.join("complete.json")).unwrap();
2773 let diffuse = run.join("male-body-diffuse-atlas.png");
2818 let diffuse = run.join("male-body-diffuse-atlas.png");
2774 let normal = run.join("male-body-normal-atlas.png");
2819 let normal = run.join("male-body-normal-atlas.png");
@@ -2779,6 +2824,13 @@ mod tests {
2779 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2824 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2780 .0
2825 .0
2781 .unwrap();
2826 .unwrap();
2827 let built = run;
2828 let run = tmp.0.join("male-body-atlas");
2829 let (normal, diffuse) = (
2830 run.join(normal.file_name().unwrap()),
2831 run.join(diffuse.file_name().unwrap()),
2832 );
2833 assert!(!built.exists());
2782 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2834 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2783 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2835 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2784 assert_eq!(
2836 assert_eq!(
@@ -2801,6 +2853,54 @@ mod tests {
2801 }
2853 }
2802 }
2854 }
2803
2855
2856 #[test]
2857 fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() {
2858 let tmp = Temp::new();
2859 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2860 let done = tmp.0.join("male-body-atlas");
2861 let good = tree(&done);
2862 // A legacy run-* folder is left alone.
2863 let legacy = tmp.0.join("run-123-4");
2864 std::fs::create_dir(&legacy).unwrap();
2865 std::fs::write(legacy.join("x"), b"old").unwrap();
2866 // Cancelled re-render: last good result untouched, build stays resumable.
2867 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2868 .0
2869 .is_err());
2870 assert_eq!(tree(&done), good);
2871 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2872 assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1);
2873 // Crash after moving the old result aside but before placing the new one:
2874 // the old result is restored.
2875 let aside = tmp.0.join(".replaced-male-body-atlas-77");
2876 std::fs::rename(&done, &aside).unwrap();
2877 let notes = crate::resume::recover(&tmp.0).unwrap();
2878 assert_eq!(notes.len(), 1, "{notes:?}");
2879 assert!(!aside.exists());
2880 assert_eq!(tree(&done), good);
2881 // Crash after completing a build but before the swap: promoted, old removed.
2882 let fresh = Temp::new();
2883 drive(resume_job(&fresh.0), RunTarget::Fresh, None)
2884 .0
2885 .unwrap();
2886 let built = tmp
2887 .0
2888 .join(crate::resume::in_progress_name(9, 9, "male", "body"));
2889 std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap();
2890 std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap();
2891 let notes = crate::resume::recover(&tmp.0).unwrap();
2892 assert_eq!(notes.len(), 1, "{notes:?}");
2893 assert!(!built.exists());
2894 assert!(!done.join("marker-old").exists());
2895 crate::atlas::AtlasSet::open_run(&done).unwrap();
2896 assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e
2897 .unwrap()
2898 .file_name()
2899 .to_string_lossy()
2900 .starts_with(".replaced-")));
2901 assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old");
2902 }
2903
2804 #[test]
2904 #[test]
2805 fn unjournaled_different_file_is_quarantined_not_overwritten() {
2905 fn unjournaled_different_file_is_quarantined_not_overwritten() {
2806 let tmp = Temp::new();
2906 let tmp = Temp::new();
@@ -2815,6 +2915,9 @@ mod tests {
2815 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2915 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2816 .0
2916 .0
2817 .unwrap();
2917 .unwrap();
2918 let done = tmp.0.join("male-body-atlas");
2919 let foreign = done.join(foreign.strip_prefix(&run).unwrap());
2920 let run = done;
2818 crate::export_set::ExportRun::open(&run).unwrap();
2921 crate::export_set::ExportRun::open(&run).unwrap();
2819 assert_eq!(
2922 assert_eq!(
2820 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
2923 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
src/resume.rs +130 −4
@@ -24,6 +24,10 @@ use std::{
24 24 pub const JOURNAL: &str = "progress.jsonl";
25 25 pub const LOCK: &str = "run.lock";
26 26 pub const RESUME_LOG: &str = "resume.jsonl";
27 /// Prefix of the temporary build folder of a render (also the resume target).
28 pub const IN_PROGRESS_PREFIX: &str = "inprogress-";
29 /// Prefix of a previous result folder moved aside during the final swap.
30 pub const REPLACED_PREFIX: &str = ".replaced-";
27 31 const MAX_RENDER_JSON: u64 = 1024 * 1024;
28 32 const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
29 33 const MAX_CANDIDATES: usize = 20;
@@ -236,7 +240,7 @@ fn check_contract(root: &Path, render_doc: &Value) -> Result<(), String> {
236 240 Ok(())
237 241 }
238 242
239 /// Direct `run-*` child directory of the output directory, not a link.
243 /// Direct `inprogress-*` child directory of the output directory, not a link.
240 244 fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
241 245 let meta = fs::symlink_metadata(dir).map_err(io)?;
242 246 if !meta.is_dir() {
@@ -247,7 +251,7 @@ fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
247 251 let name_ok = root
248 252 .file_name()
249 253 .and_then(|n| n.to_str())
250 .is_some_and(|n| n.starts_with("run-"));
254 .is_some_and(|n| n.starts_with(IN_PROGRESS_PREFIX));
251 255 if root.parent() != Some(out.as_path()) || !name_ok {
252 256 return Err("runmap ligt niet direct in de uitvoermap".into());
253 257 }
@@ -434,7 +438,7 @@ pub struct ResumeCandidate {
434 438 }
435 439
436 440 fn run_stamp(name: &str) -> u128 {
437 name.strip_prefix("run-")
441 name.strip_prefix(IN_PROGRESS_PREFIX)
438 442 .and_then(|r| r.split('-').next())
439 443 .and_then(|n| n.parse().ok())
440 444 .unwrap_or(0)
@@ -455,7 +459,7 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
455 459 let Ok(kind) = entry.file_type() else {
456 460 continue;
457 461 };
458 if !name.starts_with("run-") || !kind.is_dir() {
462 if !name.starts_with(IN_PROGRESS_PREFIX) || !kind.is_dir() {
459 463 continue;
460 464 }
461 465 let dir = entry.path();
@@ -503,3 +507,125 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
503 507 found.truncate(MAX_CANDIDATES);
504 508 Ok(found)
505 509 }
510
511 /// Fixed result folder name: `<sex>-<settype>-atlas`.
512 pub fn result_dir_name(sex: &str, set: &str) -> String {
513 format!("{sex}-{set}-atlas")
514 }
515
516 /// Name for a new in-progress build folder (stamp first, so resume sorts by age).
517 pub fn in_progress_name(stamp: u128, pid: u32, sex: &str, set: &str) -> String {
518 format!("{IN_PROGRESS_PREFIX}{stamp}-{pid}-{sex}-{set}")
519 }
520
521 #[cfg(unix)]
522 fn sync_dir(dir: &Path) {
523 if let Ok(f) = File::open(dir) {
524 let _ = f.sync_all();
525 }
526 }
527 #[cfg(not(unix))]
528 fn sync_dir(_dir: &Path) {}
529
530 fn unique_stamp() -> u128 {
531 std::time::SystemTime::now()
532 .duration_since(std::time::UNIX_EPOCH)
533 .map(|d| d.as_nanos())
534 .unwrap_or(0)
535 }
536
537 /// Replace `output/<final_name>` by the completed build folder `built`.
538 ///
539 /// The previous result is first renamed aside (`.replaced-<name>-<stamp>`),
540 /// then the new folder is renamed into place, and only then the old one is
541 /// removed. If the second rename fails the old result is restored. A crash in
542 /// between is repaired by [`recover`]. The run lock must already be released.
543 pub fn promote(output: &Path, built: &Path, final_name: &str) -> Result<PathBuf, String> {
544 let target = output.join(final_name);
545 let retired = match fs::symlink_metadata(&target) {
546 Ok(_) => {
547 let aside = output.join(format!("{REPLACED_PREFIX}{final_name}-{}", unique_stamp()));
548 fs::rename(&target, &aside)
549 .map_err(|e| format!("oude resultaatmap opzijzetten: {e}"))?;
550 Some(aside)
551 }
552 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
553 Err(e) => return Err(e.to_string()),
554 };
555 if let Err(e) = fs::rename(built, &target) {
556 if let Some(aside) = &retired {
557 let _ = fs::rename(aside, &target);
558 }
559 sync_dir(output);
560 return Err(format!("nieuwe resultaatmap plaatsen: {e}"));
561 }
562 sync_dir(output);
563 if let Some(aside) = retired {
564 // Best effort: a leftover is removed by the next `recover`.
565 let _ = fs::remove_dir_all(&aside);
566 }
567 Ok(target)
568 }
569
570 /// Repair interrupted swaps in `output` (called before each render):
571 /// a moved-aside previous result is restored when its result folder is missing
572 /// (or removed when the new one is in place), and a fully completed in-progress
573 /// folder that never got swapped is promoted. Returns human-readable notes.
574 pub fn recover(output: &Path) -> Result<Vec<String>, String> {
575 let mut notes = Vec::new();
576 let read = match fs::read_dir(output) {
577 Ok(read) => read,
578 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(notes),
579 Err(e) => return Err(e.to_string()),
580 };
581 let mut replaced = Vec::new();
582 let mut built = Vec::new();
583 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
584 let name = entry.file_name().to_string_lossy().to_string();
585 let Ok(kind) = entry.file_type() else {
586 continue;
587 };
588 if !kind.is_dir() {
589 continue;
590 }
591 if name.starts_with(REPLACED_PREFIX) {
592 replaced.push(name);
593 } else if name.starts_with(IN_PROGRESS_PREFIX) {
594 built.push(name);
595 }
596 }
597 replaced.sort();
598 for name in replaced {
599 let rest = &name[REPLACED_PREFIX.len()..];
600 let Some((final_name, _)) = rest.rsplit_once('-') else {
601 continue;
602 };
603 let target = output.join(final_name);
604 if fs::symlink_metadata(&target).is_ok() {
605 let _ = fs::remove_dir_all(output.join(&name));
606 } else {
607 fs::rename(output.join(&name), &target).map_err(io)?;
608 notes.push(format!("vorige resultaatmap {final_name} hersteld"));
609 }
610 }
611 built.sort();
612 for name in built {
613 let dir = output.join(&name);
614 if fs::symlink_metadata(dir.join("complete.json")).is_err() || is_locked(&dir) {
615 continue;
616 }
617 let Ok(doc) = read_render(&dir) else { continue };
618 let (Some(sex), Some(set)) = (doc["sex"].as_str(), doc["set"].as_str()) else {
619 continue;
620 };
621 if crate::atlas::AtlasSet::open_run(&dir).is_err() {
622 continue;
623 }
624 let final_name = result_dir_name(sex, set);
625 promote(output, &dir, &final_name)?;
626 notes.push(format!(
627 "voltooide render {name} alsnog geplaatst als {final_name}"
628 ));
629 }
630 Ok(notes)
631 }
@@ -24,6 +24,10 @@ use std::{
24pub const JOURNAL: &str = "progress.jsonl";
24pub const JOURNAL: &str = "progress.jsonl";
25pub const LOCK: &str = "run.lock";
25pub const LOCK: &str = "run.lock";
26pub const RESUME_LOG: &str = "resume.jsonl";
26pub const RESUME_LOG: &str = "resume.jsonl";
27/// Prefix of the temporary build folder of a render (also the resume target).
28pub const IN_PROGRESS_PREFIX: &str = "inprogress-";
29/// Prefix of a previous result folder moved aside during the final swap.
30pub const REPLACED_PREFIX: &str = ".replaced-";
27const MAX_RENDER_JSON: u64 = 1024 * 1024;
31const MAX_RENDER_JSON: u64 = 1024 * 1024;
28const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
32const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
29const MAX_CANDIDATES: usize = 20;
33const MAX_CANDIDATES: usize = 20;
@@ -236,7 +240,7 @@ fn check_contract(root: &Path, render_doc: &Value) -> Result<(), String> {
236 Ok(())
240 Ok(())
237}
241}
238
242
239/// Direct `run-*` child directory of the output directory, not a link.
243/// Direct `inprogress-*` child directory of the output directory, not a link.
240fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
244fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
241 let meta = fs::symlink_metadata(dir).map_err(io)?;
245 let meta = fs::symlink_metadata(dir).map_err(io)?;
242 if !meta.is_dir() {
246 if !meta.is_dir() {
@@ -247,7 +251,7 @@ fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
247 let name_ok = root
251 let name_ok = root
248 .file_name()
252 .file_name()
249 .and_then(|n| n.to_str())
253 .and_then(|n| n.to_str())
250 .is_some_and(|n| n.starts_with("run-"));
254 .is_some_and(|n| n.starts_with(IN_PROGRESS_PREFIX));
251 if root.parent() != Some(out.as_path()) || !name_ok {
255 if root.parent() != Some(out.as_path()) || !name_ok {
252 return Err("runmap ligt niet direct in de uitvoermap".into());
256 return Err("runmap ligt niet direct in de uitvoermap".into());
253 }
257 }
@@ -434,7 +438,7 @@ pub struct ResumeCandidate {
434}
438}
435
439
436fn run_stamp(name: &str) -> u128 {
440fn run_stamp(name: &str) -> u128 {
437 name.strip_prefix("run-")
441 name.strip_prefix(IN_PROGRESS_PREFIX)
438 .and_then(|r| r.split('-').next())
442 .and_then(|r| r.split('-').next())
439 .and_then(|n| n.parse().ok())
443 .and_then(|n| n.parse().ok())
440 .unwrap_or(0)
444 .unwrap_or(0)
@@ -455,7 +459,7 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
455 let Ok(kind) = entry.file_type() else {
459 let Ok(kind) = entry.file_type() else {
456 continue;
460 continue;
457 };
461 };
458 if !name.starts_with("run-") || !kind.is_dir() {
462 if !name.starts_with(IN_PROGRESS_PREFIX) || !kind.is_dir() {
459 continue;
463 continue;
460 }
464 }
461 let dir = entry.path();
465 let dir = entry.path();
@@ -503,3 +507,125 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
503 found.truncate(MAX_CANDIDATES);
507 found.truncate(MAX_CANDIDATES);
504 Ok(found)
508 Ok(found)
505}
509}
510
511/// Fixed result folder name: `<sex>-<settype>-atlas`.
512pub fn result_dir_name(sex: &str, set: &str) -> String {
513 format!("{sex}-{set}-atlas")
514}
515
516/// Name for a new in-progress build folder (stamp first, so resume sorts by age).
517pub fn in_progress_name(stamp: u128, pid: u32, sex: &str, set: &str) -> String {
518 format!("{IN_PROGRESS_PREFIX}{stamp}-{pid}-{sex}-{set}")
519}
520
521#[cfg(unix)]
522fn sync_dir(dir: &Path) {
523 if let Ok(f) = File::open(dir) {
524 let _ = f.sync_all();
525 }
526}
527#[cfg(not(unix))]
528fn sync_dir(_dir: &Path) {}
529
530fn unique_stamp() -> u128 {
531 std::time::SystemTime::now()
532 .duration_since(std::time::UNIX_EPOCH)
533 .map(|d| d.as_nanos())
534 .unwrap_or(0)
535}
536
537/// Replace `output/<final_name>` by the completed build folder `built`.
538///
539/// The previous result is first renamed aside (`.replaced-<name>-<stamp>`),
540/// then the new folder is renamed into place, and only then the old one is
541/// removed. If the second rename fails the old result is restored. A crash in
542/// between is repaired by [`recover`]. The run lock must already be released.
543pub fn promote(output: &Path, built: &Path, final_name: &str) -> Result<PathBuf, String> {
544 let target = output.join(final_name);
545 let retired = match fs::symlink_metadata(&target) {
546 Ok(_) => {
547 let aside = output.join(format!("{REPLACED_PREFIX}{final_name}-{}", unique_stamp()));
548 fs::rename(&target, &aside)
549 .map_err(|e| format!("oude resultaatmap opzijzetten: {e}"))?;
550 Some(aside)
551 }
552 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
553 Err(e) => return Err(e.to_string()),
554 };
555 if let Err(e) = fs::rename(built, &target) {
556 if let Some(aside) = &retired {
557 let _ = fs::rename(aside, &target);
558 }
559 sync_dir(output);
560 return Err(format!("nieuwe resultaatmap plaatsen: {e}"));
561 }
562 sync_dir(output);
563 if let Some(aside) = retired {
564 // Best effort: a leftover is removed by the next `recover`.
565 let _ = fs::remove_dir_all(&aside);
566 }
567 Ok(target)
568}
569
570/// Repair interrupted swaps in `output` (called before each render):
571/// a moved-aside previous result is restored when its result folder is missing
572/// (or removed when the new one is in place), and a fully completed in-progress
573/// folder that never got swapped is promoted. Returns human-readable notes.
574pub fn recover(output: &Path) -> Result<Vec<String>, String> {
575 let mut notes = Vec::new();
576 let read = match fs::read_dir(output) {
577 Ok(read) => read,
578 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(notes),
579 Err(e) => return Err(e.to_string()),
580 };
581 let mut replaced = Vec::new();
582 let mut built = Vec::new();
583 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
584 let name = entry.file_name().to_string_lossy().to_string();
585 let Ok(kind) = entry.file_type() else {
586 continue;
587 };
588 if !kind.is_dir() {
589 continue;
590 }
591 if name.starts_with(REPLACED_PREFIX) {
592 replaced.push(name);
593 } else if name.starts_with(IN_PROGRESS_PREFIX) {
594 built.push(name);
595 }
596 }
597 replaced.sort();
598 for name in replaced {
599 let rest = &name[REPLACED_PREFIX.len()..];
600 let Some((final_name, _)) = rest.rsplit_once('-') else {
601 continue;
602 };
603 let target = output.join(final_name);
604 if fs::symlink_metadata(&target).is_ok() {
605 let _ = fs::remove_dir_all(output.join(&name));
606 } else {
607 fs::rename(output.join(&name), &target).map_err(io)?;
608 notes.push(format!("vorige resultaatmap {final_name} hersteld"));
609 }
610 }
611 built.sort();
612 for name in built {
613 let dir = output.join(&name);
614 if fs::symlink_metadata(dir.join("complete.json")).is_err() || is_locked(&dir) {
615 continue;
616 }
617 let Ok(doc) = read_render(&dir) else { continue };
618 let (Some(sex), Some(set)) = (doc["sex"].as_str(), doc["set"].as_str()) else {
619 continue;
620 };
621 if crate::atlas::AtlasSet::open_run(&dir).is_err() {
622 continue;
623 }
624 let final_name = result_dir_name(sex, set);
625 promote(output, &dir, &final_name)?;
626 notes.push(format!(
627 "voltooide render {name} alsnog geplaatst als {final_name}"
628 ));
629 }
630 Ok(notes)
631}