Merge feature/atlas-result-folder: renders/<sex>-<settype>-atlas newest-only result folder (9i)
@@ -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+MTL42
42
43
## 2D-preview43
## 2D-preview44
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
- 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 e92
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.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
```text94
```text95
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png95
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png96
renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)96
renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)97
renders/run-<tijd>-<pid>/male-body-atlas.ppda97
renders/male-body-atlas/male-body-atlas.ppda98
```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
### Atlassen en .ppda102
### Atlassen en .ppda103
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
- 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 re111
111
112
### Hervatten112
### Hervatten113
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
- 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.@@ -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 configur402
## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only402
## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only403
403
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.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.@@ -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
project158
project159
}159
}160
};160
};@@ -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.185
pub type Skipped = Vec<(PathBuf, String)>;185
pub type Skipped = Vec<(PathBuf, String)>;186
186
187
/// Scan `project_root/renders/*/` and open every export whose `.ppda` and187
/// Scan `project_root/renders/<sex>-<set>-atlas/` and open every export whose `.ppda` and188
/// 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.190
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {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
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 result212
}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 all1173
// no .ppda at all1174
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 the1185
// Each view gets a distinct colour; the preview must return exactly the1154
// 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];@@ -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 = job1507
let root = job.output.join(crate::resume::in_progress_name(1501
.output1508
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
.02746
.02714
.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 before2811
// Simulate a crash after the atlases/.ppda were published but before2771
// complete.json, with one atlas damaged afterwards.2812
// complete.json (so still the in-progress build), one atlas damaged.2813
let run = tmp2814
.02815
.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
.02825
.02781
.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
.02869
.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
.02885
.unwrap();2886
let built = tmp2887
.02888
.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| !e2897
.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
.02916
.02817
.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(),@@ -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::{24
pub const JOURNAL: &str = "progress.jsonl";24
pub const JOURNAL: &str = "progress.jsonl";25
pub const LOCK: &str = "run.lock";25
pub const LOCK: &str = "run.lock";26
pub const RESUME_LOG: &str = "resume.jsonl";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
const MAX_RENDER_JSON: u64 = 1024 * 1024;31
const MAX_RENDER_JSON: u64 = 1024 * 1024;28
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;32
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;29
const MAX_CANDIDATES: usize = 20;33
const 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.240
fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {244
fn 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 = root251
let name_ok = root248
.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
436
fn run_stamp(name: &str) -> u128 {440
fn 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<ResumeCandida455
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<ResumeCandida503
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`.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 is541
/// removed. If the second rename fails the old result is restored. A crash in542
/// 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 missing572
/// (or removed when the new one is in place), and a fully completed in-progress573
/// 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
}