feat: resume incomplete production runs with contract and checksum verification
- run.lock + append-only progress.jsonl (path/SHA-256 per published PNG) - resume only when stored render.json equals the current job contract - verify every planned PNG; keep good files untouched, regenerate missing, quarantine corrupt/foreign files (.corrupt-aNNN-K), compare unjournaled - archive previous cancelled/failed markers as attempt-NNN-*.json, resume.jsonl per attempt, cancel again, complete.json as uninterrupted - unique per-write temp names; editor 'Onderbroken renders' list + Hervat - crash/cancel/mismatch/lock tests and real GPU resume_fixture
@@ -26,6 +26,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
26
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27
27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
28
28
- Stel breedte, hoogte en fps in de Bodies-weergave in.
29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
29
30
30
31

31
32
@@ -81,6 +82,17 @@ renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
81
82
82
83
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
83
84
85
### Hervatten
86
87
Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaangeroerd.
88
89
- 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.
90
- 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.
91
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
92
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.
93
94

95
84
96
## 2D depth-compositor (bibliotheek)
85
97
86
98
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
@@ -123,6 +135,11 @@ cargo clippy --workspace --all-targets --locked -- -D warnings
123
135
cargo test --workspace --locked
124
136
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
125
137
xvfb-run -a cargo run --example preview_depth_probe --locked
138
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set-controle
139
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2
140
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
141
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
142
cargo run --release --example resume_fixture --locked -- verify /tmp/resume
126
143
```
127
144
128
145
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
@@ -26,6 +26,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.29
30
30
31
31
32
@@ -81,6 +82,17 @@ renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png81
82
82
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.83
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.83
84
85
### Hervatten86
87
Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaangeroerd.88
89
- 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.90
- 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.91
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.92
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.93
94
95
84
## 2D depth-compositor (bibliotheek)96
## 2D depth-compositor (bibliotheek)85
97
86
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).98
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).@@ -123,6 +135,11 @@ cargo clippy --workspace --all-targets --locked -- -D warnings123
cargo test --workspace --locked135
cargo test --workspace --locked124
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo136
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo125
xvfb-run -a cargo run --example preview_depth_probe --locked137
xvfb-run -a cargo run --example preview_depth_probe --locked138
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set-controle139
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2140
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200141
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male142
cargo run --release --example resume_fixture --locked -- verify /tmp/resume126
```143
```127
144
128
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.145
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.@@ -126,7 +126,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
126
126
127
127
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
128
128
129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Resume remains later work. Automatic cleanup of old runs is still future work.
129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
130
131
**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).
130
132
131
133
## 8. Map contracts and rendering algorithm
132
134
@@ -217,7 +219,7 @@ These requirements are durable even where the implementation is staged. They sup
217
219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218
220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219
221
220
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented; resume and FBX drawing remain unimplemented.
222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7); FBX drawing remains unimplemented.
221
223
222
224
## 12. Incremental feature plan and acceptance gates
223
225
@@ -227,7 +229,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
227
229
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
228
230
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
229
231
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
230
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
232
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
231
233
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.
232
234
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
233
235
@@ -126,7 +126,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/126
126
127
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.127
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.128
128
129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Resume remains later work. Automatic cleanup of old runs is still future work.129
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.130
131
**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).130
132
131
## 8. Map contracts and rendering algorithm133
## 8. Map contracts and rendering algorithm132
134
@@ -217,7 +219,7 @@ These requirements are durable even where the implementation is staged. They sup217
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.218
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.219
221
220
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented; resume and FBX drawing remain unimplemented.222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7); FBX drawing remains unimplemented.221
223
222
## 12. Incremental feature plan and acceptance gates224
## 12. Incremental feature plan and acceptance gates223
225
@@ -227,7 +229,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p227
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.229
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.228
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.230
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.229
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.231
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.230
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)232
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)231
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.233
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.232
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.234
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.233
235
The contents of this binary file are not shown.
@@ -0,0 +1,236 @@
1
//! Real native GPU proof for production resume (no input automation, no delays).
2
//! Usage:
3
//! resume_fixture setup ROOT MALE.glb FEMALE.glb WIDTH HEIGHT FPS
4
//! resume_fixture render ROOT male|female [--cancel-after=N] [--abort-after=N]
5
//! resume_fixture verify ROOT (ExportRun::open on every run)
6
//! resume_fixture resume ROOT male|female [--cancel-after=N] [--screenshot=PATH]
7
//! `--abort-after` kills the process with `abort()` (a real crash: no terminal
8
//! marker). Each GUI invocation writes ROOT/<mode>-result.json.
9
use eframe::App;
10
use paperdoll_renderer::{
11
editor::EditorApp,
12
session::{adopt_file, Session, Sex},
13
};
14
use std::path::PathBuf;
15
16
struct Fixture {
17
editor: EditorApp,
18
root: PathBuf,
19
mode: String,
20
cancel_after: Option<u32>,
21
abort_after: Option<u32>,
22
screenshot: Option<PathBuf>,
23
phase: u8,
24
cancel_sent: bool,
25
frames: u64,
26
last: u32,
27
done: bool,
28
candidates: serde_json::Value,
29
}
30
31
impl App for Fixture {
32
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
33
self.frames += 1;
34
self.editor.update(ctx, frame);
35
match self.phase {
36
0 => {
37
if self.mode == "resume" {
38
self.editor.scan_resumable_runs();
39
self.candidates = serde_json::Value::Array(
40
self.editor
41
.resumable_runs()
42
.iter()
43
.map(|c| {
44
serde_json::json!({"name":c.name,"state":c.state,"journaled":c.journaled,"planned":c.planned,"blocked":c.blocked})
45
})
46
.collect(),
47
);
48
println!("CANDIDATES {}", self.candidates);
49
self.phase = 1;
50
} else {
51
self.editor.render_all_angles();
52
self.phase = 3;
53
}
54
ctx.request_repaint();
55
return;
56
}
57
1 => {
58
if self.screenshot.is_some() {
59
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
60
self.phase = 2;
61
ctx.request_repaint();
62
return;
63
}
64
self.start_resume(ctx);
65
return;
66
}
67
2 => {
68
let shot = ctx.input(|i| {
69
i.raw.events.iter().find_map(|e| match e {
70
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
71
_ => None,
72
})
73
});
74
if let (Some(image), Some(path)) = (shot, &self.screenshot) {
75
let [w, h] = image.size;
76
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
77
let file = std::fs::File::create(path).unwrap();
78
let mut enc =
79
png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
80
enc.set_color(png::ColorType::Rgba);
81
enc.set_depth(png::BitDepth::Eight);
82
enc.write_header().unwrap().write_image_data(&rgba).unwrap();
83
println!("SCREENSHOT {}", path.display());
84
self.start_resume(ctx);
85
}
86
ctx.request_repaint();
87
return;
88
}
89
_ => {}
90
}
91
let (completed, total, label) = self.editor.render_progress();
92
if completed != self.last {
93
println!("PASS {completed}/{total}: {label}");
94
self.last = completed;
95
}
96
if let Some(n) = self.abort_after {
97
if completed >= n {
98
println!("ABORT at {completed}/{total}");
99
std::process::abort();
100
}
101
}
102
if let Some(n) = self.cancel_after {
103
if completed >= n && !self.cancel_sent && self.editor.render_is_running() {
104
self.cancel_sent = true;
105
println!("CANCEL at {completed}/{total}");
106
self.editor.request_render_cancel();
107
}
108
}
109
if !self.editor.render_is_running() && !self.done {
110
let error = self.editor.render_error().map(str::to_string);
111
self.finish(ctx, error);
112
}
113
ctx.request_repaint();
114
}
115
fn on_exit(&mut self, gl: Option<&glow::Context>) {
116
self.editor.on_exit(gl);
117
}
118
}
119
120
impl Fixture {
121
fn start_resume(&mut self, ctx: &eframe::egui::Context) {
122
self.phase = 3;
123
let Some(dir) = self
124
.editor
125
.resumable_runs()
126
.iter()
127
.find(|c| c.blocked.is_none())
128
.map(|c| c.dir.clone())
129
else {
130
self.finish(ctx, Some("geen hervatbare run".into()));
131
return;
132
};
133
self.editor.resume_run(dir);
134
}
135
fn finish(&mut self, ctx: &eframe::egui::Context, error: Option<String>) {
136
self.done = true;
137
let (completed, total, label) = self.editor.render_progress();
138
let value = serde_json::json!({
139
"mode": self.mode,
140
"completed": completed,
141
"total": total,
142
"last_label": label,
143
"cancel_sent": self.cancel_sent,
144
"error": error,
145
"candidates_before": self.candidates,
146
"gui_updates": self.frames,
147
});
148
std::fs::write(
149
self.root.join(format!("{}-result.json", self.mode)),
150
serde_json::to_vec_pretty(&value).unwrap(),
151
)
152
.unwrap();
153
println!("RESULT {value}");
154
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
155
}
156
}
157
158
fn main() -> Result<(), Box<dyn std::error::Error>> {
159
let a = std::env::args().skip(1).collect::<Vec<_>>();
160
let mode = a.first().ok_or("mode")?.clone();
161
let root = PathBuf::from(a.get(1).ok_or("ROOT")?);
162
if mode == "verify" {
163
// Open every run directory through the strict schema-3 export_set reader.
164
let mut dirs = std::fs::read_dir(root.join("renders"))?
165
.map(|e| e.map(|e| e.path()))
166
.collect::<Result<Vec<_>, _>>()?;
167
dirs.sort();
168
for dir in dirs {
169
match paperdoll_renderer::export_set::ExportRun::open(&dir) {
170
Ok(run) => println!(
171
"OPEN-OK {} png_files={}",
172
dir.display(),
173
run.complete.png_files
174
),
175
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
176
}
177
}
178
return Ok(());
179
}
180
if mode == "setup" {
181
let mut s = Session::new();
182
s.install_new_project(&root)?;
183
for (sex, index) in [(Sex::Male, 2), (Sex::Female, 3)] {
184
let path = adopt_file(&root, &PathBuf::from(&a[index]), "assets/body")?;
185
s.set_body_path(sex, path);
186
}
187
s.set_render(a[4].parse()?, a[5].parse()?, a[6].parse()?);
188
s.save()?;
189
return Ok(());
190
}
191
let sex = match a.get(2).map(String::as_str) {
192
Some("male") => Sex::Male,
193
Some("female") => Sex::Female,
194
_ => return Err("male|female".into()),
195
};
196
let flag = |name: &str| {
197
a.iter()
198
.find_map(|v| v.strip_prefix(name))
199
.map(|v| v.to_string())
200
};
201
let cancel_after = flag("--cancel-after=").map(|v| v.parse()).transpose()?;
202
let abort_after = flag("--abort-after=").map(|v| v.parse()).transpose()?;
203
let screenshot = flag("--screenshot=").map(PathBuf::from);
204
let root2 = root.clone();
205
let mode2 = mode.clone();
206
eframe::run_native(
207
"Paperdoll Resume Fixture",
208
eframe::NativeOptions {
209
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
210
..paperdoll_renderer::preview_gl::native_options()
211
},
212
Box::new(move |_| {
213
let mut editor = EditorApp::default();
214
editor.open_path(root2.join("character.ppd"));
215
editor.open_body_configuration(sex);
216
Ok(Box::new(Fixture {
217
editor,
218
root: root2,
219
mode: mode2,
220
cancel_after,
221
abort_after,
222
screenshot,
223
phase: 0,
224
cancel_sent: false,
225
frames: 0,
226
last: 0,
227
done: false,
228
candidates: serde_json::Value::Null,
229
}))
230
}),
231
)?;
232
let report: serde_json::Value =
233
serde_json::from_slice(&std::fs::read(root.join(format!("{mode}-result.json")))?)?;
234
println!("FINAL {report}");
235
Ok(())
236
}
@@ -0,0 +1,236 @@1
//! Real native GPU proof for production resume (no input automation, no delays).2
//! Usage:3
//! resume_fixture setup ROOT MALE.glb FEMALE.glb WIDTH HEIGHT FPS4
//! resume_fixture render ROOT male|female [--cancel-after=N] [--abort-after=N]5
//! resume_fixture verify ROOT (ExportRun::open on every run)6
//! resume_fixture resume ROOT male|female [--cancel-after=N] [--screenshot=PATH]7
//! `--abort-after` kills the process with `abort()` (a real crash: no terminal8
//! marker). Each GUI invocation writes ROOT/<mode>-result.json.9
use eframe::App;10
use paperdoll_renderer::{11
editor::EditorApp,12
session::{adopt_file, Session, Sex},13
};14
use std::path::PathBuf;15
16
struct Fixture {17
editor: EditorApp,18
root: PathBuf,19
mode: String,20
cancel_after: Option<u32>,21
abort_after: Option<u32>,22
screenshot: Option<PathBuf>,23
phase: u8,24
cancel_sent: bool,25
frames: u64,26
last: u32,27
done: bool,28
candidates: serde_json::Value,29
}30
31
impl App for Fixture {32
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {33
self.frames += 1;34
self.editor.update(ctx, frame);35
match self.phase {36
0 => {37
if self.mode == "resume" {38
self.editor.scan_resumable_runs();39
self.candidates = serde_json::Value::Array(40
self.editor41
.resumable_runs()42
.iter()43
.map(|c| {44
serde_json::json!({"name":c.name,"state":c.state,"journaled":c.journaled,"planned":c.planned,"blocked":c.blocked})45
})46
.collect(),47
);48
println!("CANDIDATES {}", self.candidates);49
self.phase = 1;50
} else {51
self.editor.render_all_angles();52
self.phase = 3;53
}54
ctx.request_repaint();55
return;56
}57
1 => {58
if self.screenshot.is_some() {59
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);60
self.phase = 2;61
ctx.request_repaint();62
return;63
}64
self.start_resume(ctx);65
return;66
}67
2 => {68
let shot = ctx.input(|i| {69
i.raw.events.iter().find_map(|e| match e {70
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),71
_ => None,72
})73
});74
if let (Some(image), Some(path)) = (shot, &self.screenshot) {75
let [w, h] = image.size;76
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();77
let file = std::fs::File::create(path).unwrap();78
let mut enc =79
png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);80
enc.set_color(png::ColorType::Rgba);81
enc.set_depth(png::BitDepth::Eight);82
enc.write_header().unwrap().write_image_data(&rgba).unwrap();83
println!("SCREENSHOT {}", path.display());84
self.start_resume(ctx);85
}86
ctx.request_repaint();87
return;88
}89
_ => {}90
}91
let (completed, total, label) = self.editor.render_progress();92
if completed != self.last {93
println!("PASS {completed}/{total}: {label}");94
self.last = completed;95
}96
if let Some(n) = self.abort_after {97
if completed >= n {98
println!("ABORT at {completed}/{total}");99
std::process::abort();100
}101
}102
if let Some(n) = self.cancel_after {103
if completed >= n && !self.cancel_sent && self.editor.render_is_running() {104
self.cancel_sent = true;105
println!("CANCEL at {completed}/{total}");106
self.editor.request_render_cancel();107
}108
}109
if !self.editor.render_is_running() && !self.done {110
let error = self.editor.render_error().map(str::to_string);111
self.finish(ctx, error);112
}113
ctx.request_repaint();114
}115
fn on_exit(&mut self, gl: Option<&glow::Context>) {116
self.editor.on_exit(gl);117
}118
}119
120
impl Fixture {121
fn start_resume(&mut self, ctx: &eframe::egui::Context) {122
self.phase = 3;123
let Some(dir) = self124
.editor125
.resumable_runs()126
.iter()127
.find(|c| c.blocked.is_none())128
.map(|c| c.dir.clone())129
else {130
self.finish(ctx, Some("geen hervatbare run".into()));131
return;132
};133
self.editor.resume_run(dir);134
}135
fn finish(&mut self, ctx: &eframe::egui::Context, error: Option<String>) {136
self.done = true;137
let (completed, total, label) = self.editor.render_progress();138
let value = serde_json::json!({139
"mode": self.mode,140
"completed": completed,141
"total": total,142
"last_label": label,143
"cancel_sent": self.cancel_sent,144
"error": error,145
"candidates_before": self.candidates,146
"gui_updates": self.frames,147
});148
std::fs::write(149
self.root.join(format!("{}-result.json", self.mode)),150
serde_json::to_vec_pretty(&value).unwrap(),151
)152
.unwrap();153
println!("RESULT {value}");154
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);155
}156
}157
158
fn main() -> Result<(), Box<dyn std::error::Error>> {159
let a = std::env::args().skip(1).collect::<Vec<_>>();160
let mode = a.first().ok_or("mode")?.clone();161
let root = PathBuf::from(a.get(1).ok_or("ROOT")?);162
if mode == "verify" {163
// Open every run directory through the strict schema-3 export_set reader.164
let mut dirs = std::fs::read_dir(root.join("renders"))?165
.map(|e| e.map(|e| e.path()))166
.collect::<Result<Vec<_>, _>>()?;167
dirs.sort();168
for dir in dirs {169
match paperdoll_renderer::export_set::ExportRun::open(&dir) {170
Ok(run) => println!(171
"OPEN-OK {} png_files={}",172
dir.display(),173
run.complete.png_files174
),175
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),176
}177
}178
return Ok(());179
}180
if mode == "setup" {181
let mut s = Session::new();182
s.install_new_project(&root)?;183
for (sex, index) in [(Sex::Male, 2), (Sex::Female, 3)] {184
let path = adopt_file(&root, &PathBuf::from(&a[index]), "assets/body")?;185
s.set_body_path(sex, path);186
}187
s.set_render(a[4].parse()?, a[5].parse()?, a[6].parse()?);188
s.save()?;189
return Ok(());190
}191
let sex = match a.get(2).map(String::as_str) {192
Some("male") => Sex::Male,193
Some("female") => Sex::Female,194
_ => return Err("male|female".into()),195
};196
let flag = |name: &str| {197
a.iter()198
.find_map(|v| v.strip_prefix(name))199
.map(|v| v.to_string())200
};201
let cancel_after = flag("--cancel-after=").map(|v| v.parse()).transpose()?;202
let abort_after = flag("--abort-after=").map(|v| v.parse()).transpose()?;203
let screenshot = flag("--screenshot=").map(PathBuf::from);204
let root2 = root.clone();205
let mode2 = mode.clone();206
eframe::run_native(207
"Paperdoll Resume Fixture",208
eframe::NativeOptions {209
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),210
..paperdoll_renderer::preview_gl::native_options()211
},212
Box::new(move |_| {213
let mut editor = EditorApp::default();214
editor.open_path(root2.join("character.ppd"));215
editor.open_body_configuration(sex);216
Ok(Box::new(Fixture {217
editor,218
root: root2,219
mode: mode2,220
cancel_after,221
abort_after,222
screenshot,223
phase: 0,224
cancel_sent: false,225
frames: 0,226
last: 0,227
done: false,228
candidates: serde_json::Value::Null,229
}))230
}),231
)?;232
let report: serde_json::Value =233
serde_json::from_slice(&std::fs::read(root.join(format!("{mode}-result.json")))?)?;234
println!("FINAL {report}");235
Ok(())236
}@@ -44,6 +44,9 @@ pub struct EditorApp {
44
44
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
45
45
preview2d_image: Option<ImageBuf>,
46
46
preview2d_speed_text: String,
47
resume_candidates: Vec<crate::resume::ResumeCandidate>,
48
resume_scanned: bool,
49
resume_scan_error: Option<String>,
47
50
}
48
51
49
52
impl EditorApp {
@@ -75,6 +78,9 @@ impl EditorApp {
75
78
preview2d_texture_key: None,
76
79
preview2d_image: None,
77
80
preview2d_speed_text: "1.0".to_string(),
81
resume_candidates: Vec::new(),
82
resume_scanned: false,
83
resume_scan_error: None,
78
84
}
79
85
}
80
86
@@ -128,6 +134,9 @@ impl EditorApp {
128
134
self.render_worker = None;
129
135
self.render_ui = RenderUiState::idle();
130
136
self.pass_texture = None;
137
self.resume_candidates.clear();
138
self.resume_scanned = false;
139
self.resume_scan_error = None;
131
140
}
132
141
133
142
fn sync_fields(&mut self) {
@@ -398,29 +407,23 @@ impl EditorApp {
398
407
self.render_ui.progress.label.clone(),
399
408
)
400
409
}
401
fn start_render(&mut self) {
402
if self.render_ui.running {
403
return;
404
}
405
let Some(opened) = &self.session.opened else {
406
return;
407
};
410
/// The job the Render action would start now (current project, body, equipment).
411
fn build_job(&mut self) -> Option<RenderJob> {
412
let opened = self.session.opened.as_ref()?;
408
413
if let Err(error) = opened.document.validate_shape() {
409
414
self.session.fail(error.to_string());
410
return;
415
return None;
411
416
}
412
417
let Some(character) = self.character().cloned() else {
413
418
self.session.fail("dit lichaam heeft geen tekenbaar glTF");
414
return;
415
};
416
let Some(root) = self.session.project_root() else {
417
return;
419
return None;
418
420
};
421
let root = self.session.project_root()?;
419
422
if let Err(error) =
420
423
crate::project::prepare_output_dir(&root, &opened.document.output_directory)
421
424
{
422
425
self.session.fail(error.to_string());
423
return;
426
return None;
424
427
}
425
428
let sex = usize::from(self.controls.sex == PreviewSex::Female);
426
429
let preset = match self.session.screen {
@@ -429,7 +432,7 @@ impl EditorApp {
429
432
};
430
433
if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {
431
434
self.session.fail("configureer eerst het equipment");
432
return;
435
return None;
433
436
}
434
437
let body_rel = match self.controls.sex {
435
438
PreviewSex::Male => opened.document.male_model.clone(),
@@ -437,18 +440,18 @@ impl EditorApp {
437
440
};
438
441
let Some(body_rel) = body_rel else {
439
442
self.session.fail("dit lichaam heeft geen modelpad");
440
return;
443
return None;
441
444
};
442
445
if let Err(error) = crate::project::portable_relative(&body_rel) {
443
446
self.session.fail(error);
444
return;
447
return None;
445
448
}
446
449
let body_path = root.join(&body_rel);
447
450
let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {
448
451
Ok(hash) => hash,
449
452
Err(error) => {
450
453
self.session.fail(format!("body-fingerprint: {error}"));
451
return;
454
return None;
452
455
}
453
456
};
454
457
let preset_sha256 = match &preset {
@@ -457,11 +460,11 @@ impl EditorApp {
457
460
Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),
458
461
Err(error) => {
459
462
self.session.fail(format!("equipment-fingerprint: {error}"));
460
return;
463
return None;
461
464
}
462
465
},
463
466
};
464
let job = RenderJob {
467
Some(RenderJob {
465
468
character,
466
469
sex: if sex == 0 { "male" } else { "female" }.into(),
467
470
preset,
@@ -477,12 +480,109 @@ impl EditorApp {
477
480
sha256: body_sha,
478
481
},
479
482
preset_sha256,
483
})
484
}
485
fn start_render(&mut self) {
486
if self.render_ui.running {
487
return;
488
}
489
let Some(job) = self.build_job() else {
490
return;
480
491
};
481
492
self.render_worker = Some(spawn(job, None));
493
self.begin_worker();
494
}
495
fn begin_worker(&mut self) {
482
496
self.render_ui = RenderUiState::idle();
483
497
self.pass_texture = None;
484
498
self.render_ui.running = true;
499
self.resume_candidates.clear();
500
self.resume_scanned = false;
485
501
}
502
/// Find incomplete runs of the current body/equipment set and check their contract.
503
pub fn scan_resumable_runs(&mut self) {
504
self.resume_scanned = true;
505
self.resume_candidates.clear();
506
self.resume_scan_error = None;
507
let Some(job) = self.build_job() else {
508
self.resume_scan_error = Some("geen renderbare job voor deze weergave".into());
509
return;
510
};
511
let output = job.output.clone();
512
let result = crate::production::contract(job)
513
.and_then(|c| crate::resume::candidates(&output, &c.render_doc));
514
match result {
515
Ok(found) => self.resume_candidates = found,
516
Err(error) => self.resume_scan_error = Some(error),
517
}
518
}
519
pub fn resumable_runs(&self) -> &[crate::resume::ResumeCandidate] {
520
&self.resume_candidates
521
}
522
/// Resume one incomplete run; the worker re-checks contract, lock and checksums.
523
pub fn resume_run(&mut self, dir: PathBuf) {
524
if self.render_ui.running {
525
return;
526
}
527
let Some(job) = self.build_job() else {
528
return;
529
};
530
self.render_worker = Some(crate::render_job::spawn_resume(job, dir, None));
531
self.begin_worker();
532
}
533
/// Request cancellation of the running batch (same as the GUI button).
534
pub fn request_render_cancel(&mut self) {
535
self.cancel_render();
536
}
537
fn resume_ui(&mut self, ui: &mut egui::Ui) {
538
ui.horizontal_wrapped(|ui| {
539
ui.label("Onderbroken renders:");
540
if ui
541
.add_enabled(
542
!self.render_ui.running,
543
egui::Button::new("Zoek onderbroken runs"),
544
)
545
.on_hover_text(
546
"Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",
547
)
548
.clicked()
549
{
550
self.scan_resumable_runs();
551
}
552
if self.resume_scanned && self.resume_candidates.is_empty() {
553
ui.weak("geen gevonden voor deze set");
554
}
555
});
556
if let Some(error) = &self.resume_scan_error {
557
ui.colored_label(egui::Color32::LIGHT_RED, error);
558
}
559
let mut chosen = None;
560
for candidate in &self.resume_candidates {
561
ui.horizontal_wrapped(|ui| {
562
ui.label(format!(
563
"{} · {} · {} van {} PNG's vastgelegd",
564
candidate.name, candidate.state, candidate.journaled, candidate.planned
565
));
566
match &candidate.blocked {
567
None => {
568
if ui
569
.add_enabled(!self.render_ui.running, egui::Button::new("Hervat"))
570
.clicked()
571
{
572
chosen = Some(candidate.dir.clone());
573
}
574
}
575
Some(reason) => {
576
ui.weak(reason);
577
}
578
}
579
});
580
}
581
if let Some(dir) = chosen {
582
self.resume_run(dir);
583
}
584
}
585
486
586
fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
487
587
let request = self
488
588
.render_worker
@@ -525,6 +625,10 @@ impl EditorApp {
525
625
}
526
626
if terminal {
527
627
self.render_worker = None;
628
if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {
629
// Cancelled: offer the run for resumption right away.
630
self.scan_resumable_runs();
631
}
528
632
} else if ended {
529
633
self.render_ui.apply(RenderEvent::Failed(
530
634
"De renderworker stopte zonder eindresultaat.".into(),
@@ -899,6 +1003,7 @@ impl EditorApp {
899
1003
});
900
1004
self.framing_ui(ui, usize::from(sex == Sex::Female));
901
1005
self.render_progress_ui(ui);
1006
self.resume_ui(ui);
902
1007
if self.render_ui.preview.is_some() {
903
1008
self.paint_pass(ui);
904
1009
} else {
@@ -1213,6 +1318,7 @@ impl EditorApp {
1213
1318
}
1214
1319
});
1215
1320
self.render_progress_ui(ui);
1321
self.resume_ui(ui);
1216
1322
if self.render_ui.preview.is_some() {
1217
1323
self.paint_pass(ui);
1218
1324
return;
@@ -44,6 +44,9 @@ pub struct EditorApp {44
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,44
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,45
preview2d_image: Option<ImageBuf>,45
preview2d_image: Option<ImageBuf>,46
preview2d_speed_text: String,46
preview2d_speed_text: String,47
resume_candidates: Vec<crate::resume::ResumeCandidate>,48
resume_scanned: bool,49
resume_scan_error: Option<String>,47
}50
}48
51
49
impl EditorApp {52
impl EditorApp {@@ -75,6 +78,9 @@ impl EditorApp {75
preview2d_texture_key: None,78
preview2d_texture_key: None,76
preview2d_image: None,79
preview2d_image: None,77
preview2d_speed_text: "1.0".to_string(),80
preview2d_speed_text: "1.0".to_string(),81
resume_candidates: Vec::new(),82
resume_scanned: false,83
resume_scan_error: None,78
}84
}79
}85
}80
86
@@ -128,6 +134,9 @@ impl EditorApp {128
self.render_worker = None;134
self.render_worker = None;129
self.render_ui = RenderUiState::idle();135
self.render_ui = RenderUiState::idle();130
self.pass_texture = None;136
self.pass_texture = None;137
self.resume_candidates.clear();138
self.resume_scanned = false;139
self.resume_scan_error = None;131
}140
}132
141
133
fn sync_fields(&mut self) {142
fn sync_fields(&mut self) {@@ -398,29 +407,23 @@ impl EditorApp {398
self.render_ui.progress.label.clone(),407
self.render_ui.progress.label.clone(),399
)408
)400
}409
}401
fn start_render(&mut self) {410
/// The job the Render action would start now (current project, body, equipment).402
if self.render_ui.running {411
fn build_job(&mut self) -> Option<RenderJob> {403
return;412
let opened = self.session.opened.as_ref()?;404
}405
let Some(opened) = &self.session.opened else {406
return;407
};408
if let Err(error) = opened.document.validate_shape() {413
if let Err(error) = opened.document.validate_shape() {409
self.session.fail(error.to_string());414
self.session.fail(error.to_string());410
return;415
return None;411
}416
}412
let Some(character) = self.character().cloned() else {417
let Some(character) = self.character().cloned() else {413
self.session.fail("dit lichaam heeft geen tekenbaar glTF");418
self.session.fail("dit lichaam heeft geen tekenbaar glTF");414
return;419
return None;415
};416
let Some(root) = self.session.project_root() else {417
return;418
};420
};421
let root = self.session.project_root()?;419
if let Err(error) =422
if let Err(error) =420
crate::project::prepare_output_dir(&root, &opened.document.output_directory)423
crate::project::prepare_output_dir(&root, &opened.document.output_directory)421
{424
{422
self.session.fail(error.to_string());425
self.session.fail(error.to_string());423
return;426
return None;424
}427
}425
let sex = usize::from(self.controls.sex == PreviewSex::Female);428
let sex = usize::from(self.controls.sex == PreviewSex::Female);426
let preset = match self.session.screen {429
let preset = match self.session.screen {@@ -429,7 +432,7 @@ impl EditorApp {429
};432
};430
if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {433
if matches!(self.session.screen, Screen::Equipment(_)) && preset.is_none() {431
self.session.fail("configureer eerst het equipment");434
self.session.fail("configureer eerst het equipment");432
return;435
return None;433
}436
}434
let body_rel = match self.controls.sex {437
let body_rel = match self.controls.sex {435
PreviewSex::Male => opened.document.male_model.clone(),438
PreviewSex::Male => opened.document.male_model.clone(),@@ -437,18 +440,18 @@ impl EditorApp {437
};440
};438
let Some(body_rel) = body_rel else {441
let Some(body_rel) = body_rel else {439
self.session.fail("dit lichaam heeft geen modelpad");442
self.session.fail("dit lichaam heeft geen modelpad");440
return;443
return None;441
};444
};442
if let Err(error) = crate::project::portable_relative(&body_rel) {445
if let Err(error) = crate::project::portable_relative(&body_rel) {443
self.session.fail(error);446
self.session.fail(error);444
return;447
return None;445
}448
}446
let body_path = root.join(&body_rel);449
let body_path = root.join(&body_rel);447
let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {450
let body_sha = match crate::digest::sha256_file(&body_path, 32 * 1024 * 1024) {448
Ok(hash) => hash,451
Ok(hash) => hash,449
Err(error) => {452
Err(error) => {450
self.session.fail(format!("body-fingerprint: {error}"));453
self.session.fail(format!("body-fingerprint: {error}"));451
return;454
return None;452
}455
}453
};456
};454
let preset_sha256 = match &preset {457
let preset_sha256 = match &preset {@@ -457,11 +460,11 @@ impl EditorApp {457
Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),460
Ok(bytes) => Some(crate::digest::sha256_hex(&bytes)),458
Err(error) => {461
Err(error) => {459
self.session.fail(format!("equipment-fingerprint: {error}"));462
self.session.fail(format!("equipment-fingerprint: {error}"));460
return;463
return None;461
}464
}462
},465
},463
};466
};464
let job = RenderJob {467
Some(RenderJob {465
character,468
character,466
sex: if sex == 0 { "male" } else { "female" }.into(),469
sex: if sex == 0 { "male" } else { "female" }.into(),467
preset,470
preset,@@ -477,12 +480,109 @@ impl EditorApp {477
sha256: body_sha,480
sha256: body_sha,478
},481
},479
preset_sha256,482
preset_sha256,483
})484
}485
fn start_render(&mut self) {486
if self.render_ui.running {487
return;488
}489
let Some(job) = self.build_job() else {490
return;480
};491
};481
self.render_worker = Some(spawn(job, None));492
self.render_worker = Some(spawn(job, None));493
self.begin_worker();494
}495
fn begin_worker(&mut self) {482
self.render_ui = RenderUiState::idle();496
self.render_ui = RenderUiState::idle();483
self.pass_texture = None;497
self.pass_texture = None;484
self.render_ui.running = true;498
self.render_ui.running = true;499
self.resume_candidates.clear();500
self.resume_scanned = false;485
}501
}502
/// Find incomplete runs of the current body/equipment set and check their contract.503
pub fn scan_resumable_runs(&mut self) {504
self.resume_scanned = true;505
self.resume_candidates.clear();506
self.resume_scan_error = None;507
let Some(job) = self.build_job() else {508
self.resume_scan_error = Some("geen renderbare job voor deze weergave".into());509
return;510
};511
let output = job.output.clone();512
let result = crate::production::contract(job)513
.and_then(|c| crate::resume::candidates(&output, &c.render_doc));514
match result {515
Ok(found) => self.resume_candidates = found,516
Err(error) => self.resume_scan_error = Some(error),517
}518
}519
pub fn resumable_runs(&self) -> &[crate::resume::ResumeCandidate] {520
&self.resume_candidates521
}522
/// Resume one incomplete run; the worker re-checks contract, lock and checksums.523
pub fn resume_run(&mut self, dir: PathBuf) {524
if self.render_ui.running {525
return;526
}527
let Some(job) = self.build_job() else {528
return;529
};530
self.render_worker = Some(crate::render_job::spawn_resume(job, dir, None));531
self.begin_worker();532
}533
/// Request cancellation of the running batch (same as the GUI button).534
pub fn request_render_cancel(&mut self) {535
self.cancel_render();536
}537
fn resume_ui(&mut self, ui: &mut egui::Ui) {538
ui.horizontal_wrapped(|ui| {539
ui.label("Onderbroken renders:");540
if ui541
.add_enabled(542
!self.render_ui.running,543
egui::Button::new("Zoek onderbroken runs"),544
)545
.on_hover_text(546
"Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",547
)548
.clicked()549
{550
self.scan_resumable_runs();551
}552
if self.resume_scanned && self.resume_candidates.is_empty() {553
ui.weak("geen gevonden voor deze set");554
}555
});556
if let Some(error) = &self.resume_scan_error {557
ui.colored_label(egui::Color32::LIGHT_RED, error);558
}559
let mut chosen = None;560
for candidate in &self.resume_candidates {561
ui.horizontal_wrapped(|ui| {562
ui.label(format!(563
"{} · {} · {} van {} PNG's vastgelegd",564
candidate.name, candidate.state, candidate.journaled, candidate.planned565
));566
match &candidate.blocked {567
None => {568
if ui569
.add_enabled(!self.render_ui.running, egui::Button::new("Hervat"))570
.clicked()571
{572
chosen = Some(candidate.dir.clone());573
}574
}575
Some(reason) => {576
ui.weak(reason);577
}578
}579
});580
}581
if let Some(dir) = chosen {582
self.resume_run(dir);583
}584
}585
486
fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {586
fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {487
let request = self587
let request = self488
.render_worker588
.render_worker@@ -525,6 +625,10 @@ impl EditorApp {525
}625
}526
if terminal {626
if terminal {527
self.render_worker = None;627
self.render_worker = None;628
if self.render_ui.saved.is_none() && self.render_ui.error.is_none() {629
// Cancelled: offer the run for resumption right away.630
self.scan_resumable_runs();631
}528
} else if ended {632
} else if ended {529
self.render_ui.apply(RenderEvent::Failed(633
self.render_ui.apply(RenderEvent::Failed(530
"De renderworker stopte zonder eindresultaat.".into(),634
"De renderworker stopte zonder eindresultaat.".into(),@@ -899,6 +1003,7 @@ impl EditorApp {899
});1003
});900
self.framing_ui(ui, usize::from(sex == Sex::Female));1004
self.framing_ui(ui, usize::from(sex == Sex::Female));901
self.render_progress_ui(ui);1005
self.render_progress_ui(ui);1006
self.resume_ui(ui);902
if self.render_ui.preview.is_some() {1007
if self.render_ui.preview.is_some() {903
self.paint_pass(ui);1008
self.paint_pass(ui);904
} else {1009
} else {@@ -1213,6 +1318,7 @@ impl EditorApp {1213
}1318
}1214
});1319
});1215
self.render_progress_ui(ui);1320
self.render_progress_ui(ui);1321
self.resume_ui(ui);1216
if self.render_ui.preview.is_some() {1322
if self.render_ui.preview.is_some() {1217
self.paint_pass(ui);1323
self.paint_pass(ui);1218
return;1324
return;@@ -3,7 +3,7 @@
3
3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4
4
//! draws skinned body/equipment previews, and streams production target maps.
5
5
//! Completed export runs are opened through `export_set` with checksum/identity
6
//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback.
6
//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
7
7
8
8
pub mod camera;
9
9
pub mod compositor;
@@ -22,5 +22,6 @@ pub mod preview_gl;
22
22
pub mod production;
23
23
pub mod project;
24
24
pub mod render_job;
25
pub mod resume;
25
26
pub mod session;
26
27
pub mod still;
@@ -3,7 +3,7 @@3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,3
//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,4
//! draws skinned body/equipment previews, and streams production target maps.4
//! draws skinned body/equipment previews, and streams production target maps.5
//! Completed export runs are opened through `export_set` with checksum/identity5
//! Completed export runs are opened through `export_set` with checksum/identity6
//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback.6
//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.7
7
8
pub mod camera;8
pub mod camera;9
pub mod compositor;9
pub mod compositor;@@ -22,5 +22,6 @@ pub mod preview_gl;22
pub mod production;22
pub mod production;23
pub mod project;23
pub mod project;24
pub mod render_job;24
pub mod render_job;25
pub mod resume;25
pub mod session;26
pub mod session;26
pub mod still;27
pub mod still;@@ -1052,7 +1052,12 @@ fn atomic_file(
1052
1052
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1053
1053
) -> Result<(), String> {
1054
1054
let path = safe_parent(root, relative)?;
1055
let temp = path.with_extension(format!("part-{}", std::process::id()));
1055
static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1056
let temp = path.with_extension(format!(
1057
"part-{}-{}",
1058
std::process::id(),
1059
TEMP_SEQ.fetch_add(1, Ordering::Relaxed)
1060
));
1056
1061
let mut owned = false;
1057
1062
let result = (|| {
1058
1063
let mut file = std::fs::OpenOptions::new()
@@ -1154,6 +1159,14 @@ pub fn write_depth(
1154
1159
Ok(digest)
1155
1160
}
1156
1161
1162
/// Atomic no-clobber publication of already encoded bytes under the run root.
1163
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1164
atomic_file(root, relative, |file| {
1165
use std::io::Write;
1166
file.write_all(bytes).map_err(|e| e.to_string())
1167
})
1168
}
1169
1157
1170
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1158
1171
use std::io::Write;
1159
1172
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
@@ -1219,7 +1232,32 @@ impl Drop for RenderThread {
1219
1232
self.cancel.store(true, Ordering::Relaxed);
1220
1233
}
1221
1234
}
1235
/// Where a production batch writes: a fresh run directory, or an existing
1236
/// incomplete run that is recovered after contract and checksum verification.
1237
#[derive(Debug, Clone, PartialEq, Eq)]
1238
pub enum RunTarget {
1239
Fresh,
1240
Resume(PathBuf),
1241
}
1242
1222
1243
pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1244
spawn_target(job, RunTarget::Fresh, ctx)
1245
}
1246
1247
/// Resume a selected incomplete run (see `resume`).
1248
pub fn spawn_resume(
1249
job: RenderJob,
1250
run_dir: PathBuf,
1251
ctx: Option<eframe::egui::Context>,
1252
) -> RenderThread {
1253
spawn_target(job, RunTarget::Resume(run_dir), ctx)
1254
}
1255
1256
fn spawn_target(
1257
job: RenderJob,
1258
target: RunTarget,
1259
ctx: Option<eframe::egui::Context>,
1260
) -> RenderThread {
1223
1261
let mailbox = Mailbox::default();
1224
1262
let output = mailbox.clone();
1225
1263
let cancel = Arc::new(AtomicBool::new(false));
@@ -1232,7 +1270,7 @@ pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread
1232
1270
ctx.request_repaint();
1233
1271
}
1234
1272
};
1235
let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit);
1273
let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
1236
1274
if let Err(error) = result {
1237
1275
if flag.load(Ordering::Relaxed) {
1238
1276
emit(RenderEvent::Cancelled);
@@ -1248,13 +1286,18 @@ pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread
1248
1286
handle: Some(handle),
1249
1287
}
1250
1288
}
1251
fn execute(
1252
mut job: RenderJob,
1253
gpu: &mpsc::Sender<GpuRequest>,
1254
cancel: &AtomicBool,
1255
ctx: Option<&eframe::egui::Context>,
1256
emit: &mut dyn FnMut(RenderEvent),
1257
) -> Result<(), String> {
1289
1290
/// The immutable export contract of a job: validated/resolved job, plan,
1291
/// set token and the exact `render.json` document.
1292
pub struct Contract {
1293
pub job: RenderJob,
1294
pub plan: Plan,
1295
pub set: String,
1296
pub render_doc: serde_json::Value,
1297
}
1298
1299
/// Validate a job and build its `render.json` contract without touching disk.
1300
pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1258
1301
job.framing.validate()?;
1259
1302
let pivot = crate::camera::resolve(&job.character, job.framing);
1260
1303
job.framing = pivot.framing;
@@ -1283,15 +1326,6 @@ fn execute(
1283
1326
if output_bytes > 16 * 1024 * 1024 * 1024 {
1284
1327
return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1285
1328
}
1286
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1287
let stamp = std::time::SystemTime::now()
1288
.duration_since(std::time::UNIX_EPOCH)
1289
.map_err(|e| e.to_string())?
1290
.as_nanos();
1291
let root = job
1292
.output
1293
.join(format!("run-{stamp}-{}", std::process::id()));
1294
std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1295
1329
let set = job
1296
1330
.preset
1297
1331
.as_ref()
@@ -1364,7 +1398,96 @@ fn execute(
1364
1398
"passes": plan.passes,
1365
1399
"png_files": plan.files,
1366
1400
});
1367
write_json(&root, "render.json", render_doc.clone())?;
1401
Ok(Contract {
1402
job,
1403
plan,
1404
set,
1405
render_doc,
1406
})
1407
}
1408
1409
/// Saved map tokens in per-image publication order.
1410
pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];
1411
1412
/// Every planned target PNG (relative, `/`-separated) in render order, four per image.
1413
pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, String> {
1414
let mut out = Vec::with_capacity(plan.files as usize);
1415
for s in samples(plan) {
1416
for map in SAVED_ORDER {
1417
let path = output_path(
1418
sex,
1419
set,
1420
&plan.clips[s.clip].token,
1421
s.frame,
1422
s.pitch,
1423
s.yaw,
1424
map,
1425
)?;
1426
out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"));
1427
}
1428
}
1429
Ok(out)
1430
}
1431
1432
#[cfg(test)]
1433
fn execute(
1434
job: RenderJob,
1435
gpu: &mpsc::Sender<GpuRequest>,
1436
cancel: &AtomicBool,
1437
ctx: Option<&eframe::egui::Context>,
1438
emit: &mut dyn FnMut(RenderEvent),
1439
) -> Result<(), String> {
1440
execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit)
1441
}
1442
1443
fn execute_target(
1444
job: RenderJob,
1445
target: RunTarget,
1446
gpu: &mpsc::Sender<GpuRequest>,
1447
cancel: &AtomicBool,
1448
ctx: Option<&eframe::egui::Context>,
1449
emit: &mut dyn FnMut(RenderEvent),
1450
) -> Result<(), String> {
1451
let Contract {
1452
mut job,
1453
plan,
1454
set,
1455
render_doc,
1456
} = contract(job)?;
1457
let planned = planned_paths(&plan, &job.sex, &set)?;
1458
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1459
let mut session = match target {
1460
RunTarget::Fresh => {
1461
let stamp = std::time::SystemTime::now()
1462
.duration_since(std::time::UNIX_EPOCH)
1463
.map_err(|e| e.to_string())?
1464
.as_nanos();
1465
let root = job
1466
.output
1467
.join(format!("run-{stamp}-{}", std::process::id()));
1468
std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1469
let session = crate::resume::RunSession::fresh(&root)?;
1470
write_json(&root, "render.json", render_doc.clone())?;
1471
session
1472
}
1473
RunTarget::Resume(dir) => {
1474
let total = plan.passes;
1475
crate::resume::RunSession::resume(
1476
&job.output,
1477
&dir,
1478
&render_doc,
1479
&planned,
1480
&mut |done, all| {
1481
emit(RenderEvent::Step {
1482
index: 0,
1483
total,
1484
label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),
1485
})
1486
},
1487
)?
1488
}
1489
};
1490
let root = session.root.clone();
1368
1491
// Internal unique names select every source clip, even duplicate display names.
1369
1492
for (i, c) in job.character.clips.iter_mut().enumerate() {
1370
1493
c.name = format!("render-clip-{i}");
@@ -1373,16 +1496,12 @@ fn execute(
1373
1496
let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1374
1497
let mut cache = equipment::TextureCache::default();
1375
1498
let result = (|| {
1376
for sample in samples(&plan) {
1499
for (index, sample) in samples(&plan).enumerate() {
1377
1500
let clip = &plan.clips[sample.clip];
1378
1501
let frame = sample.frame;
1379
1502
let pitch = sample.pitch;
1380
1503
let yaw = sample.yaw;
1381
let key = if job.character.clips.is_empty() {
1382
String::new()
1383
} else {
1384
format!("render-clip-{}", sample.clip)
1385
};
1504
let paths = &planned[index * 4..index * 4 + 4];
1386
1505
if cancel.load(Ordering::Relaxed) {
1387
1506
return Err("geannuleerd".into());
1388
1507
}
@@ -1390,6 +1509,23 @@ fn execute(
1390
1509
"{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1391
1510
job.sex, clip.source_name, clip.frames
1392
1511
);
1512
if paths.iter().all(|p| session.verified.contains_key(p)) {
1513
for p in paths {
1514
png_sha256.insert(p.clone(), session.verified[p].clone());
1515
}
1516
completed += PASSES.len() as u32;
1517
emit(RenderEvent::Step {
1518
index: completed,
1519
total: plan.passes,
1520
label: format!("{pose} · al aanwezig, checksums kloppen"),
1521
});
1522
continue;
1523
}
1524
let key = if job.character.clips.is_empty() {
1525
String::new()
1526
} else {
1527
format!("render-clip-{}", sample.clip)
1528
};
1393
1529
emit(RenderEvent::Step {
1394
1530
index: completed,
1395
1531
total: plan.passes,
@@ -1452,12 +1588,15 @@ fn execute(
1452
1588
label: format!("{pose} · target-res / diffuse"),
1453
1589
});
1454
1590
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1455
for (map, preview) in [
1591
for ((map, preview), rel) in [
1456
1592
("diffuse", target.diffuse),
1457
1593
("depth", target.depth_preview),
1458
1594
("normal", target.normal),
1459
1595
("specular", target.specular),
1460
] {
1596
]
1597
.into_iter()
1598
.zip(paths)
1599
{
1461
1600
if cancel.load(Ordering::Relaxed) {
1462
1601
return Err("geannuleerd".into());
1463
1602
}
@@ -1466,32 +1605,33 @@ fn execute(
1466
1605
total: plan.passes,
1467
1606
label: format!("{pose} · {map}"),
1468
1607
});
1469
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;
1470
let digest = if map == "depth" {
1471
write_depth(
1472
&root,
1473
&path,
1474
job.target_width,
1475
job.target_height,
1476
&target.depth,
1477
)?
1608
debug_assert!(rel.ends_with(&format!("_{map}.png")));
1609
let kept = session.verified.contains_key(rel);
1610
let digest = if kept {
1611
session.verified[rel].clone()
1478
1612
} else {
1479
write_rgba(&root, &path, &preview)?
1613
let bytes = if map == "depth" {
1614
encode_depth_png(job.target_width, job.target_height, &target.depth)?
1615
} else {
1616
encode_rgba_png(&preview, map == "diffuse")?
1617
};
1618
session.publish(rel, &bytes)?
1480
1619
};
1481
let key = path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
1482
png_sha256.insert(key, digest);
1620
png_sha256.insert(rel.clone(), digest);
1483
1621
completed += 1;
1622
let pass = if map == "diffuse" {
1623
"target-res / diffuse"
1624
} else {
1625
map
1626
};
1484
1627
emit(RenderEvent::Pass {
1485
1628
index: completed - 1,
1486
1629
total: plan.passes,
1487
label: format!(
1488
"{pose} · {}",
1489
if map == "diffuse" {
1490
"target-res / diffuse"
1491
} else {
1492
map
1493
}
1494
),
1630
label: if kept {
1631
format!("{pose} · {pass} (bestaand bestand behouden)")
1632
} else {
1633
format!("{pose} · {pass}")
1634
},
1495
1635
image: Some(preview),
1496
1636
});
1497
1637
}
@@ -1534,7 +1674,14 @@ fn execute(
1534
1674
let _ = write_json(
1535
1675
&root,
1536
1676
name,
1537
serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}),
1677
serde_json::json!({
1678
"error": error,
1679
"completed_passes": completed,
1680
"total_passes": plan.passes,
1681
"png_files_done": png_sha256.len(),
1682
"png_files": plan.files,
1683
"attempt": session.attempt,
1684
}),
1538
1685
);
1539
1686
Err(error)
1540
1687
}
@@ -1860,8 +2007,16 @@ mod tests {
1860
2007
t.0.join("blocked.png")
1861
2008
.with_extension(format!("part-{}", std::process::id()));
1862
2009
std::fs::write(&tmp, b"keep").unwrap();
1863
assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err());
2010
// A foreign/stale temp (e.g. left by a crashed process with a reused PID)
2011
// is never deleted or reused; per-write unique temp names still publish.
2012
write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();
1864
2013
assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
2014
let temps = std::fs::read_dir(&t.0)
2015
.unwrap()
2016
.filter_map(|e| e.ok())
2017
.filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))
2018
.count();
2019
assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");
1865
2020
assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
1866
2021
assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
1867
2022
assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
@@ -2187,4 +2342,340 @@ mod tests {
2187
2342
}
2188
2343
}
2189
2344
}
2345
2346
// ---- Resume (design §7/§12.6) ----
2347
fn resume_job(out: &Path) -> RenderJob {
2348
let mut b = body();
2349
b.clips.clear();
2350
RenderJob {
2351
character: b,
2352
sex: "male".into(),
2353
preset: None,
2354
target_width: 2,
2355
target_height: 2,
2356
fps: 12,
2357
framing: CameraFraming::default(),
2358
output: out.to_path_buf(),
2359
project_id: "resume-project".into(),
2360
project_name: "Resume".into(),
2361
body_model: SourceFingerprint {
2362
path: "assets/body/test.glb".into(),
2363
sha256: crate::digest::sha256_hex(b"resume-body"),
2364
},
2365
preset_sha256: None,
2366
}
2367
}
2368
/// Deterministic pose-dependent fake GPU output (view matrix drives the colour).
2369
fn fake_raw(scene: &Scene) -> Buffers {
2370
let mut b = raw(scene.width, scene.height);
2371
let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)
2372
as u32
2373
% 200;
2374
for px in b.diffuse.chunks_exact_mut(4) {
2375
px[1] = v as u8;
2376
px[2] = (255 - v) as u8;
2377
}
2378
b
2379
}
2380
/// Run a job; cancel after `stop` completed Pass events (None = run to the end).
2381
fn drive(
2382
job: RenderJob,
2383
target: RunTarget,
2384
stop: Option<usize>,
2385
) -> (Result<(), String>, Vec<RenderEvent>) {
2386
let flag = AtomicBool::new(false);
2387
let (tx, rx) = mpsc::channel::<GpuRequest>();
2388
let gpu = std::thread::spawn(move || {
2389
for request in rx {
2390
let reply = fake_raw(&request.scene);
2391
let _ = request.reply.send(Ok(reply));
2392
}
2393
});
2394
let mut events = Vec::new();
2395
let mut passes = 0;
2396
let result = execute_target(job, target, &tx, &flag, None, &mut |event| {
2397
if matches!(event, RenderEvent::Pass { .. }) {
2398
passes += 1;
2399
if stop == Some(passes) {
2400
flag.store(true, Ordering::Relaxed);
2401
}
2402
}
2403
events.push(event);
2404
});
2405
drop(tx);
2406
gpu.join().unwrap();
2407
(result, events)
2408
}
2409
fn runs(out: &Path) -> Vec<PathBuf> {
2410
let mut v = std::fs::read_dir(out)
2411
.unwrap()
2412
.map(|e| e.unwrap().path())
2413
.collect::<Vec<_>>();
2414
v.sort();
2415
v
2416
}
2417
fn checksums(run: &Path) -> serde_json::Value {
2418
let c: serde_json::Value =
2419
serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();
2420
c["png_sha256"].clone()
2421
}
2422
fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2423
let mut out = std::collections::BTreeMap::new();
2424
let mut stack = vec![run.to_path_buf()];
2425
while let Some(dir) = stack.pop() {
2426
for e in std::fs::read_dir(dir).unwrap() {
2427
let p = e.unwrap().path();
2428
if p.is_dir() {
2429
stack.push(p);
2430
} else {
2431
out.insert(
2432
p.clone(),
2433
crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),
2434
);
2435
}
2436
}
2437
}
2438
out
2439
}
2440
#[cfg(unix)]
2441
fn inode(p: &Path) -> u64 {
2442
use std::os::unix::fs::MetadataExt;
2443
std::fs::metadata(p).unwrap().ino()
2444
}
2445
2446
#[test]
2447
#[cfg(unix)]
2448
fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {
2449
let reference_dir = Temp::new();
2450
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2451
ok.unwrap();
2452
let reference = checksums(&runs(&reference_dir.0)[0]);
2453
assert_eq!(reference.as_object().unwrap().len(), 144);
2454
2455
let tmp = Temp::new();
2456
// An earlier completed run in the same output must stay byte-identical.
2457
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2458
let earlier = runs(&tmp.0)[0].clone();
2459
let earlier_tree = tree(&earlier);
2460
std::thread::sleep(std::time::Duration::from_millis(2));
2461
let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));
2462
assert!(first.is_err());
2463
let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();
2464
let cancelled: serde_json::Value =
2465
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2466
let done_first = cancelled["png_files_done"].as_u64().unwrap();
2467
assert!(done_first > 0 && done_first < 144);
2468
assert_eq!(cancelled["attempt"], 0);
2469
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");
2470
let first_inode = inode(&first_file);
2471
assert!(crate::export_set::ExportRun::open(&run).is_err());
2472
2473
let found =
2474
crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)
2475
.unwrap();
2476
assert_eq!(found.len(), 1);
2477
assert_eq!(found[0].state, "geannuleerd");
2478
assert_eq!(found[0].journaled as u64, done_first);
2479
assert!(found[0].blocked.is_none());
2480
2481
// Second attempt: cancelled again after more work.
2482
let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));
2483
assert!(second.is_err());
2484
// Progress resumes at the verified work, not at zero, and skipped images are labelled.
2485
let first_step = events
2486
.iter()
2487
.find_map(|e| match e {
2488
RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {
2489
Some(*index)
2490
}
2491
_ => None,
2492
})
2493
.unwrap();
2494
assert_eq!(first_step, 6);
2495
let cancelled2: serde_json::Value =
2496
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2497
assert_eq!(cancelled2["attempt"], 1);
2498
assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);
2499
assert!(run.join("attempt-000-cancelled.json").exists());
2500
assert_eq!(
2501
inode(&first_file),
2502
first_inode,
2503
"verified PNG was rewritten"
2504
);
2505
2506
// Third attempt completes.
2507
let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2508
third.unwrap();
2509
assert!(!run.join("cancelled.json").exists());
2510
assert!(run.join("attempt-001-cancelled.json").exists());
2511
assert_eq!(inode(&first_file), first_inode);
2512
assert_eq!(checksums(&run), reference);
2513
crate::export_set::ExportRun::open(&run).unwrap();
2514
let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2515
assert_eq!(log.lines().count(), 2);
2516
assert_eq!(tree(&earlier), earlier_tree);
2517
// A completed run is no longer offered and cannot be resumed.
2518
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2519
assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2520
let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2521
assert!(again.unwrap_err().contains("al voltooid"));
2522
}
2523
2524
#[test]
2525
#[cfg(unix)]
2526
fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
2527
let reference_dir = Temp::new();
2528
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2529
.0
2530
.unwrap();
2531
let reference = checksums(&runs(&reference_dir.0)[0]);
2532
2533
let tmp = Temp::new();
2534
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))
2535
.0
2536
.is_err());
2537
let run = runs(&tmp.0)[0].clone();
2538
// Simulate a hard crash: no terminal marker, torn last journal line.
2539
std::fs::remove_file(run.join("cancelled.json")).unwrap();
2540
let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();
2541
let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();
2542
let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
2543
let torn_path = run.join(torn["path"].as_str().unwrap());
2544
let last = lines.pop().unwrap();
2545
let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];
2546
std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2547
let torn_inode = inode(&torn_path);
2548
// Corrupt one journaled PNG, delete another, leave a foreign temp file.
2549
let frame = run.join("male/body/clip000-bind/f000000");
2550
let corrupt = frame.join("p-30_y030_normal.png");
2551
std::fs::write(&corrupt, b"not a png").unwrap();
2552
let missing = frame.join("p-30_y060_depth.png");
2553
std::fs::remove_file(&missing).unwrap();
2554
let stale = frame.join("p-30_y090_diffuse.part-999999-0");
2555
std::fs::write(&stale, b"partial").unwrap();
2556
2557
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2558
let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2559
assert_eq!(found[0].state, "afgebroken");
2560
2561
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2562
.0
2563
.unwrap();
2564
assert_eq!(checksums(&run), reference);
2565
crate::export_set::ExportRun::open(&run).unwrap();
2566
assert!(missing.exists());
2567
assert_eq!(
2568
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),
2569
b"not a png"
2570
);
2571
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
2572
assert_eq!(
2573
inode(&torn_path),
2574
torn_inode,
2575
"identical unjournaled file was replaced"
2576
);
2577
let log: serde_json::Value = serde_json::from_str(
2578
std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))
2579
.unwrap()
2580
.lines()
2581
.next()
2582
.unwrap(),
2583
)
2584
.unwrap();
2585
assert_eq!(log["previous_marker"], serde_json::Value::Null);
2586
assert_eq!(log["damaged_journal_lines"], 1);
2587
assert_eq!(log["unjournaled_existing"], 1);
2588
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);
2589
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2590
}
2591
2592
#[test]
2593
fn unjournaled_different_file_is_quarantined_not_overwritten() {
2594
let tmp = Temp::new();
2595
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))
2596
.0
2597
.is_err());
2598
let run = runs(&tmp.0)[0].clone();
2599
// A planned PNG appears that the journal never recorded and that differs.
2600
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");
2601
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
2602
std::fs::write(&foreign, b"foreign").unwrap();
2603
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2604
.0
2605
.unwrap();
2606
crate::export_set::ExportRun::open(&run).unwrap();
2607
assert_eq!(
2608
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),
2609
b"foreign"
2610
);
2611
}
2612
2613
#[test]
2614
fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {
2615
let tmp = Temp::new();
2616
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))
2617
.0
2618
.is_err());
2619
let run = runs(&tmp.0)[0].clone();
2620
let before = std::fs::read(run.join("cancelled.json")).unwrap();
2621
// Contract drift: FPS, body bytes, target size.
2622
let mut job = resume_job(&tmp.0);
2623
job.fps = 24;
2624
let err = drive(job, RunTarget::Resume(run.clone()), None)
2625
.0
2626
.unwrap_err();
2627
assert!(err.contains("fps"), "{err}");
2628
let mut job = resume_job(&tmp.0);
2629
job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");
2630
let err = drive(job, RunTarget::Resume(run.clone()), None)
2631
.0
2632
.unwrap_err();
2633
assert!(err.contains("body_model"), "{err}");
2634
let mut job = resume_job(&tmp.0);
2635
job.target_width = 3;
2636
let doc = contract(job.clone()).unwrap().render_doc;
2637
let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2638
assert!(found[0].blocked.as_ref().unwrap().contains("target"));
2639
assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());
2640
// Refusals leave the run and its marker untouched.
2641
assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);
2642
assert!(!run.join(crate::resume::RESUME_LOG).exists());
2643
// Directory not directly inside the output directory.
2644
let other = Temp::new();
2645
let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)
2646
.0
2647
.unwrap_err();
2648
assert!(err.contains("uitvoermap"), "{err}");
2649
// Locked by a running worker.
2650
let lock = std::fs::OpenOptions::new()
2651
.write(true)
2652
.open(run.join(crate::resume::LOCK))
2653
.unwrap();
2654
lock.lock().unwrap();
2655
let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2656
.0
2657
.unwrap_err();
2658
assert!(err.contains("run.lock"), "{err}");
2659
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2660
assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]
2661
.blocked
2662
.is_some());
2663
drop(lock);
2664
// Journal naming a path outside the plan.
2665
use std::io::Write;
2666
let mut j = std::fs::OpenOptions::new()
2667
.append(true)
2668
.open(run.join(crate::resume::JOURNAL))
2669
.unwrap();
2670
writeln!(
2671
j,
2672
"{}",
2673
serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})
2674
)
2675
.unwrap();
2676
let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2677
.0
2678
.unwrap_err();
2679
assert!(err.contains("onbekend pad"), "{err}");
2680
}
2190
2681
}
@@ -1052,7 +1052,12 @@ fn atomic_file(1052
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,1052
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,1053
) -> Result<(), String> {1053
) -> Result<(), String> {1054
let path = safe_parent(root, relative)?;1054
let path = safe_parent(root, relative)?;1055
let temp = path.with_extension(format!("part-{}", std::process::id()));1055
static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);1056
let temp = path.with_extension(format!(1057
"part-{}-{}",1058
std::process::id(),1059
TEMP_SEQ.fetch_add(1, Ordering::Relaxed)1060
));1056
let mut owned = false;1061
let mut owned = false;1057
let result = (|| {1062
let result = (|| {1058
let mut file = std::fs::OpenOptions::new()1063
let mut file = std::fs::OpenOptions::new()@@ -1154,6 +1159,14 @@ pub fn write_depth(1154
Ok(digest)1159
Ok(digest)1155
}1160
}1156
1161
1162
/// Atomic no-clobber publication of already encoded bytes under the run root.1163
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {1164
atomic_file(root, relative, |file| {1165
use std::io::Write;1166
file.write_all(bytes).map_err(|e| e.to_string())1167
})1168
}1169
1157
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {1170
fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {1158
use std::io::Write;1171
use std::io::Write;1159
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;1172
let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;@@ -1219,7 +1232,32 @@ impl Drop for RenderThread {1219
self.cancel.store(true, Ordering::Relaxed);1232
self.cancel.store(true, Ordering::Relaxed);1220
}1233
}1221
}1234
}1235
/// Where a production batch writes: a fresh run directory, or an existing1236
/// incomplete run that is recovered after contract and checksum verification.1237
#[derive(Debug, Clone, PartialEq, Eq)]1238
pub enum RunTarget {1239
Fresh,1240
Resume(PathBuf),1241
}1242
1222
pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {1243
pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {1244
spawn_target(job, RunTarget::Fresh, ctx)1245
}1246
1247
/// Resume a selected incomplete run (see `resume`).1248
pub fn spawn_resume(1249
job: RenderJob,1250
run_dir: PathBuf,1251
ctx: Option<eframe::egui::Context>,1252
) -> RenderThread {1253
spawn_target(job, RunTarget::Resume(run_dir), ctx)1254
}1255
1256
fn spawn_target(1257
job: RenderJob,1258
target: RunTarget,1259
ctx: Option<eframe::egui::Context>,1260
) -> RenderThread {1223
let mailbox = Mailbox::default();1261
let mailbox = Mailbox::default();1224
let output = mailbox.clone();1262
let output = mailbox.clone();1225
let cancel = Arc::new(AtomicBool::new(false));1263
let cancel = Arc::new(AtomicBool::new(false));@@ -1232,7 +1270,7 @@ pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread1232
ctx.request_repaint();1270
ctx.request_repaint();1233
}1271
}1234
};1272
};1235
let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit);1273
let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);1236
if let Err(error) = result {1274
if let Err(error) = result {1237
if flag.load(Ordering::Relaxed) {1275
if flag.load(Ordering::Relaxed) {1238
emit(RenderEvent::Cancelled);1276
emit(RenderEvent::Cancelled);@@ -1248,13 +1286,18 @@ pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread1248
handle: Some(handle),1286
handle: Some(handle),1249
}1287
}1250
}1288
}1251
fn execute(1289
1252
mut job: RenderJob,1290
/// The immutable export contract of a job: validated/resolved job, plan,1253
gpu: &mpsc::Sender<GpuRequest>,1291
/// set token and the exact `render.json` document.1254
cancel: &AtomicBool,1292
pub struct Contract {1255
ctx: Option<&eframe::egui::Context>,1293
pub job: RenderJob,1256
emit: &mut dyn FnMut(RenderEvent),1294
pub plan: Plan,1257
) -> Result<(), String> {1295
pub set: String,1296
pub render_doc: serde_json::Value,1297
}1298
1299
/// Validate a job and build its `render.json` contract without touching disk.1300
pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1258
job.framing.validate()?;1301
job.framing.validate()?;1259
let pivot = crate::camera::resolve(&job.character, job.framing);1302
let pivot = crate::camera::resolve(&job.character, job.framing);1260
job.framing = pivot.framing;1303
job.framing = pivot.framing;@@ -1283,15 +1326,6 @@ fn execute(1283
if output_bytes > 16 * 1024 * 1024 * 1024 {1326
if output_bytes > 16 * 1024 * 1024 * 1024 {1284
return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());1327
return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());1285
}1328
}1286
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;1287
let stamp = std::time::SystemTime::now()1288
.duration_since(std::time::UNIX_EPOCH)1289
.map_err(|e| e.to_string())?1290
.as_nanos();1291
let root = job1292
.output1293
.join(format!("run-{stamp}-{}", std::process::id()));1294
std::fs::create_dir(&root).map_err(|e| e.to_string())?;1295
let set = job1329
let set = job1296
.preset1330
.preset1297
.as_ref()1331
.as_ref()@@ -1364,7 +1398,96 @@ fn execute(1364
"passes": plan.passes,1398
"passes": plan.passes,1365
"png_files": plan.files,1399
"png_files": plan.files,1366
});1400
});1367
write_json(&root, "render.json", render_doc.clone())?;1401
Ok(Contract {1402
job,1403
plan,1404
set,1405
render_doc,1406
})1407
}1408
1409
/// Saved map tokens in per-image publication order.1410
pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];1411
1412
/// Every planned target PNG (relative, `/`-separated) in render order, four per image.1413
pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, String> {1414
let mut out = Vec::with_capacity(plan.files as usize);1415
for s in samples(plan) {1416
for map in SAVED_ORDER {1417
let path = output_path(1418
sex,1419
set,1420
&plan.clips[s.clip].token,1421
s.frame,1422
s.pitch,1423
s.yaw,1424
map,1425
)?;1426
out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"));1427
}1428
}1429
Ok(out)1430
}1431
1432
#[cfg(test)]1433
fn execute(1434
job: RenderJob,1435
gpu: &mpsc::Sender<GpuRequest>,1436
cancel: &AtomicBool,1437
ctx: Option<&eframe::egui::Context>,1438
emit: &mut dyn FnMut(RenderEvent),1439
) -> Result<(), String> {1440
execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit)1441
}1442
1443
fn execute_target(1444
job: RenderJob,1445
target: RunTarget,1446
gpu: &mpsc::Sender<GpuRequest>,1447
cancel: &AtomicBool,1448
ctx: Option<&eframe::egui::Context>,1449
emit: &mut dyn FnMut(RenderEvent),1450
) -> Result<(), String> {1451
let Contract {1452
mut job,1453
plan,1454
set,1455
render_doc,1456
} = contract(job)?;1457
let planned = planned_paths(&plan, &job.sex, &set)?;1458
std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;1459
let mut session = match target {1460
RunTarget::Fresh => {1461
let stamp = std::time::SystemTime::now()1462
.duration_since(std::time::UNIX_EPOCH)1463
.map_err(|e| e.to_string())?1464
.as_nanos();1465
let root = job1466
.output1467
.join(format!("run-{stamp}-{}", std::process::id()));1468
std::fs::create_dir(&root).map_err(|e| e.to_string())?;1469
let session = crate::resume::RunSession::fresh(&root)?;1470
write_json(&root, "render.json", render_doc.clone())?;1471
session1472
}1473
RunTarget::Resume(dir) => {1474
let total = plan.passes;1475
crate::resume::RunSession::resume(1476
&job.output,1477
&dir,1478
&render_doc,1479
&planned,1480
&mut |done, all| {1481
emit(RenderEvent::Step {1482
index: 0,1483
total,1484
label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),1485
})1486
},1487
)?1488
}1489
};1490
let root = session.root.clone();1368
// Internal unique names select every source clip, even duplicate display names.1491
// Internal unique names select every source clip, even duplicate display names.1369
for (i, c) in job.character.clips.iter_mut().enumerate() {1492
for (i, c) in job.character.clips.iter_mut().enumerate() {1370
c.name = format!("render-clip-{i}");1493
c.name = format!("render-clip-{i}");@@ -1373,16 +1496,12 @@ fn execute(1373
let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();1496
let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();1374
let mut cache = equipment::TextureCache::default();1497
let mut cache = equipment::TextureCache::default();1375
let result = (|| {1498
let result = (|| {1376
for sample in samples(&plan) {1499
for (index, sample) in samples(&plan).enumerate() {1377
let clip = &plan.clips[sample.clip];1500
let clip = &plan.clips[sample.clip];1378
let frame = sample.frame;1501
let frame = sample.frame;1379
let pitch = sample.pitch;1502
let pitch = sample.pitch;1380
let yaw = sample.yaw;1503
let yaw = sample.yaw;1381
let key = if job.character.clips.is_empty() {1504
let paths = &planned[index * 4..index * 4 + 4];1382
String::new()1383
} else {1384
format!("render-clip-{}", sample.clip)1385
};1386
if cancel.load(Ordering::Relaxed) {1505
if cancel.load(Ordering::Relaxed) {1387
return Err("geannuleerd".into());1506
return Err("geannuleerd".into());1388
}1507
}@@ -1390,6 +1509,23 @@ fn execute(1390
"{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",1509
"{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",1391
job.sex, clip.source_name, clip.frames1510
job.sex, clip.source_name, clip.frames1392
);1511
);1512
if paths.iter().all(|p| session.verified.contains_key(p)) {1513
for p in paths {1514
png_sha256.insert(p.clone(), session.verified[p].clone());1515
}1516
completed += PASSES.len() as u32;1517
emit(RenderEvent::Step {1518
index: completed,1519
total: plan.passes,1520
label: format!("{pose} · al aanwezig, checksums kloppen"),1521
});1522
continue;1523
}1524
let key = if job.character.clips.is_empty() {1525
String::new()1526
} else {1527
format!("render-clip-{}", sample.clip)1528
};1393
emit(RenderEvent::Step {1529
emit(RenderEvent::Step {1394
index: completed,1530
index: completed,1395
total: plan.passes,1531
total: plan.passes,@@ -1452,12 +1588,15 @@ fn execute(1452
label: format!("{pose} · target-res / diffuse"),1588
label: format!("{pose} · target-res / diffuse"),1453
});1589
});1454
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;1590
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;1455
for (map, preview) in [1591
for ((map, preview), rel) in [1456
("diffuse", target.diffuse),1592
("diffuse", target.diffuse),1457
("depth", target.depth_preview),1593
("depth", target.depth_preview),1458
("normal", target.normal),1594
("normal", target.normal),1459
("specular", target.specular),1595
("specular", target.specular),1460
] {1596
]1597
.into_iter()1598
.zip(paths)1599
{1461
if cancel.load(Ordering::Relaxed) {1600
if cancel.load(Ordering::Relaxed) {1462
return Err("geannuleerd".into());1601
return Err("geannuleerd".into());1463
}1602
}@@ -1466,32 +1605,33 @@ fn execute(1466
total: plan.passes,1605
total: plan.passes,1467
label: format!("{pose} · {map}"),1606
label: format!("{pose} · {map}"),1468
});1607
});1469
let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;1608
debug_assert!(rel.ends_with(&format!("_{map}.png")));1470
let digest = if map == "depth" {1609
let kept = session.verified.contains_key(rel);1471
write_depth(1610
let digest = if kept {1472
&root,1611
session.verified[rel].clone()1473
&path,1474
job.target_width,1475
job.target_height,1476
&target.depth,1477
)?1478
} else {1612
} else {1479
write_rgba(&root, &path, &preview)?1613
let bytes = if map == "depth" {1614
encode_depth_png(job.target_width, job.target_height, &target.depth)?1615
} else {1616
encode_rgba_png(&preview, map == "diffuse")?1617
};1618
session.publish(rel, &bytes)?1480
};1619
};1481
let key = path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");1620
png_sha256.insert(rel.clone(), digest);1482
png_sha256.insert(key, digest);1483
completed += 1;1621
completed += 1;1622
let pass = if map == "diffuse" {1623
"target-res / diffuse"1624
} else {1625
map1626
};1484
emit(RenderEvent::Pass {1627
emit(RenderEvent::Pass {1485
index: completed - 1,1628
index: completed - 1,1486
total: plan.passes,1629
total: plan.passes,1487
label: format!(1630
label: if kept {1488
"{pose} · {}",1631
format!("{pose} · {pass} (bestaand bestand behouden)")1489
if map == "diffuse" {1632
} else {1490
"target-res / diffuse"1633
format!("{pose} · {pass}")1491
} else {1634
},1492
map1493
}1494
),1495
image: Some(preview),1635
image: Some(preview),1496
});1636
});1497
}1637
}@@ -1534,7 +1674,14 @@ fn execute(1534
let _ = write_json(1674
let _ = write_json(1535
&root,1675
&root,1536
name,1676
name,1537
serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}),1677
serde_json::json!({1678
"error": error,1679
"completed_passes": completed,1680
"total_passes": plan.passes,1681
"png_files_done": png_sha256.len(),1682
"png_files": plan.files,1683
"attempt": session.attempt,1684
}),1538
);1685
);1539
Err(error)1686
Err(error)1540
}1687
}@@ -1860,8 +2007,16 @@ mod tests {1860
t.0.join("blocked.png")2007
t.0.join("blocked.png")1861
.with_extension(format!("part-{}", std::process::id()));2008
.with_extension(format!("part-{}", std::process::id()));1862
std::fs::write(&tmp, b"keep").unwrap();2009
std::fs::write(&tmp, b"keep").unwrap();1863
assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err());2010
// A foreign/stale temp (e.g. left by a crashed process with a reused PID)2011
// is never deleted or reused; per-write unique temp names still publish.2012
write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();1864
assert_eq!(std::fs::read(tmp).unwrap(), b"keep");2013
assert_eq!(std::fs::read(tmp).unwrap(), b"keep");2014
let temps = std::fs::read_dir(&t.0)2015
.unwrap()2016
.filter_map(|e| e.ok())2017
.filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))2018
.count();2019
assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");1865
assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());2020
assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());1866
assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());2021
assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());1867
assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());2022
assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());@@ -2187,4 +2342,340 @@ mod tests {2187
}2342
}2188
}2343
}2189
}2344
}2345
2346
// ---- Resume (design §7/§12.6) ----2347
fn resume_job(out: &Path) -> RenderJob {2348
let mut b = body();2349
b.clips.clear();2350
RenderJob {2351
character: b,2352
sex: "male".into(),2353
preset: None,2354
target_width: 2,2355
target_height: 2,2356
fps: 12,2357
framing: CameraFraming::default(),2358
output: out.to_path_buf(),2359
project_id: "resume-project".into(),2360
project_name: "Resume".into(),2361
body_model: SourceFingerprint {2362
path: "assets/body/test.glb".into(),2363
sha256: crate::digest::sha256_hex(b"resume-body"),2364
},2365
preset_sha256: None,2366
}2367
}2368
/// Deterministic pose-dependent fake GPU output (view matrix drives the colour).2369
fn fake_raw(scene: &Scene) -> Buffers {2370
let mut b = raw(scene.width, scene.height);2371
let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)2372
as u322373
% 200;2374
for px in b.diffuse.chunks_exact_mut(4) {2375
px[1] = v as u8;2376
px[2] = (255 - v) as u8;2377
}2378
b2379
}2380
/// Run a job; cancel after `stop` completed Pass events (None = run to the end).2381
fn drive(2382
job: RenderJob,2383
target: RunTarget,2384
stop: Option<usize>,2385
) -> (Result<(), String>, Vec<RenderEvent>) {2386
let flag = AtomicBool::new(false);2387
let (tx, rx) = mpsc::channel::<GpuRequest>();2388
let gpu = std::thread::spawn(move || {2389
for request in rx {2390
let reply = fake_raw(&request.scene);2391
let _ = request.reply.send(Ok(reply));2392
}2393
});2394
let mut events = Vec::new();2395
let mut passes = 0;2396
let result = execute_target(job, target, &tx, &flag, None, &mut |event| {2397
if matches!(event, RenderEvent::Pass { .. }) {2398
passes += 1;2399
if stop == Some(passes) {2400
flag.store(true, Ordering::Relaxed);2401
}2402
}2403
events.push(event);2404
});2405
drop(tx);2406
gpu.join().unwrap();2407
(result, events)2408
}2409
fn runs(out: &Path) -> Vec<PathBuf> {2410
let mut v = std::fs::read_dir(out)2411
.unwrap()2412
.map(|e| e.unwrap().path())2413
.collect::<Vec<_>>();2414
v.sort();2415
v2416
}2417
fn checksums(run: &Path) -> serde_json::Value {2418
let c: serde_json::Value =2419
serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();2420
c["png_sha256"].clone()2421
}2422
fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {2423
let mut out = std::collections::BTreeMap::new();2424
let mut stack = vec![run.to_path_buf()];2425
while let Some(dir) = stack.pop() {2426
for e in std::fs::read_dir(dir).unwrap() {2427
let p = e.unwrap().path();2428
if p.is_dir() {2429
stack.push(p);2430
} else {2431
out.insert(2432
p.clone(),2433
crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),2434
);2435
}2436
}2437
}2438
out2439
}2440
#[cfg(unix)]2441
fn inode(p: &Path) -> u64 {2442
use std::os::unix::fs::MetadataExt;2443
std::fs::metadata(p).unwrap().ino()2444
}2445
2446
#[test]2447
#[cfg(unix)]2448
fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {2449
let reference_dir = Temp::new();2450
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);2451
ok.unwrap();2452
let reference = checksums(&runs(&reference_dir.0)[0]);2453
assert_eq!(reference.as_object().unwrap().len(), 144);2454
2455
let tmp = Temp::new();2456
// An earlier completed run in the same output must stay byte-identical.2457
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();2458
let earlier = runs(&tmp.0)[0].clone();2459
let earlier_tree = tree(&earlier);2460
std::thread::sleep(std::time::Duration::from_millis(2));2461
let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));2462
assert!(first.is_err());2463
let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();2464
let cancelled: serde_json::Value =2465
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2466
let done_first = cancelled["png_files_done"].as_u64().unwrap();2467
assert!(done_first > 0 && done_first < 144);2468
assert_eq!(cancelled["attempt"], 0);2469
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");2470
let first_inode = inode(&first_file);2471
assert!(crate::export_set::ExportRun::open(&run).is_err());2472
2473
let found =2474
crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)2475
.unwrap();2476
assert_eq!(found.len(), 1);2477
assert_eq!(found[0].state, "geannuleerd");2478
assert_eq!(found[0].journaled as u64, done_first);2479
assert!(found[0].blocked.is_none());2480
2481
// Second attempt: cancelled again after more work.2482
let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));2483
assert!(second.is_err());2484
// Progress resumes at the verified work, not at zero, and skipped images are labelled.2485
let first_step = events2486
.iter()2487
.find_map(|e| match e {2488
RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {2489
Some(*index)2490
}2491
_ => None,2492
})2493
.unwrap();2494
assert_eq!(first_step, 6);2495
let cancelled2: serde_json::Value =2496
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2497
assert_eq!(cancelled2["attempt"], 1);2498
assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);2499
assert!(run.join("attempt-000-cancelled.json").exists());2500
assert_eq!(2501
inode(&first_file),2502
first_inode,2503
"verified PNG was rewritten"2504
);2505
2506
// Third attempt completes.2507
let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);2508
third.unwrap();2509
assert!(!run.join("cancelled.json").exists());2510
assert!(run.join("attempt-001-cancelled.json").exists());2511
assert_eq!(inode(&first_file), first_inode);2512
assert_eq!(checksums(&run), reference);2513
crate::export_set::ExportRun::open(&run).unwrap();2514
let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();2515
assert_eq!(log.lines().count(), 2);2516
assert_eq!(tree(&earlier), earlier_tree);2517
// A completed run is no longer offered and cannot be resumed.2518
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;2519
assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());2520
let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);2521
assert!(again.unwrap_err().contains("al voltooid"));2522
}2523
2524
#[test]2525
#[cfg(unix)]2526
fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {2527
let reference_dir = Temp::new();2528
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)2529
.02530
.unwrap();2531
let reference = checksums(&runs(&reference_dir.0)[0]);2532
2533
let tmp = Temp::new();2534
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))2535
.02536
.is_err());2537
let run = runs(&tmp.0)[0].clone();2538
// Simulate a hard crash: no terminal marker, torn last journal line.2539
std::fs::remove_file(run.join("cancelled.json")).unwrap();2540
let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();2541
let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();2542
let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();2543
let torn_path = run.join(torn["path"].as_str().unwrap());2544
let last = lines.pop().unwrap();2545
let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];2546
std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();2547
let torn_inode = inode(&torn_path);2548
// Corrupt one journaled PNG, delete another, leave a foreign temp file.2549
let frame = run.join("male/body/clip000-bind/f000000");2550
let corrupt = frame.join("p-30_y030_normal.png");2551
std::fs::write(&corrupt, b"not a png").unwrap();2552
let missing = frame.join("p-30_y060_depth.png");2553
std::fs::remove_file(&missing).unwrap();2554
let stale = frame.join("p-30_y090_diffuse.part-999999-0");2555
std::fs::write(&stale, b"partial").unwrap();2556
2557
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;2558
let found = crate::resume::candidates(&tmp.0, &doc).unwrap();2559
assert_eq!(found[0].state, "afgebroken");2560
2561
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2562
.02563
.unwrap();2564
assert_eq!(checksums(&run), reference);2565
crate::export_set::ExportRun::open(&run).unwrap();2566
assert!(missing.exists());2567
assert_eq!(2568
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),2569
b"not a png"2570
);2571
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");2572
assert_eq!(2573
inode(&torn_path),2574
torn_inode,2575
"identical unjournaled file was replaced"2576
);2577
let log: serde_json::Value = serde_json::from_str(2578
std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))2579
.unwrap()2580
.lines()2581
.next()2582
.unwrap(),2583
)2584
.unwrap();2585
assert_eq!(log["previous_marker"], serde_json::Value::Null);2586
assert_eq!(log["damaged_journal_lines"], 1);2587
assert_eq!(log["unjournaled_existing"], 1);2588
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);2589
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);2590
}2591
2592
#[test]2593
fn unjournaled_different_file_is_quarantined_not_overwritten() {2594
let tmp = Temp::new();2595
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))2596
.02597
.is_err());2598
let run = runs(&tmp.0)[0].clone();2599
// A planned PNG appears that the journal never recorded and that differs.2600
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");2601
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();2602
std::fs::write(&foreign, b"foreign").unwrap();2603
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2604
.02605
.unwrap();2606
crate::export_set::ExportRun::open(&run).unwrap();2607
assert_eq!(2608
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),2609
b"foreign"2610
);2611
}2612
2613
#[test]2614
fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {2615
let tmp = Temp::new();2616
assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))2617
.02618
.is_err());2619
let run = runs(&tmp.0)[0].clone();2620
let before = std::fs::read(run.join("cancelled.json")).unwrap();2621
// Contract drift: FPS, body bytes, target size.2622
let mut job = resume_job(&tmp.0);2623
job.fps = 24;2624
let err = drive(job, RunTarget::Resume(run.clone()), None)2625
.02626
.unwrap_err();2627
assert!(err.contains("fps"), "{err}");2628
let mut job = resume_job(&tmp.0);2629
job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");2630
let err = drive(job, RunTarget::Resume(run.clone()), None)2631
.02632
.unwrap_err();2633
assert!(err.contains("body_model"), "{err}");2634
let mut job = resume_job(&tmp.0);2635
job.target_width = 3;2636
let doc = contract(job.clone()).unwrap().render_doc;2637
let found = crate::resume::candidates(&tmp.0, &doc).unwrap();2638
assert!(found[0].blocked.as_ref().unwrap().contains("target"));2639
assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());2640
// Refusals leave the run and its marker untouched.2641
assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);2642
assert!(!run.join(crate::resume::RESUME_LOG).exists());2643
// Directory not directly inside the output directory.2644
let other = Temp::new();2645
let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)2646
.02647
.unwrap_err();2648
assert!(err.contains("uitvoermap"), "{err}");2649
// Locked by a running worker.2650
let lock = std::fs::OpenOptions::new()2651
.write(true)2652
.open(run.join(crate::resume::LOCK))2653
.unwrap();2654
lock.lock().unwrap();2655
let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2656
.02657
.unwrap_err();2658
assert!(err.contains("run.lock"), "{err}");2659
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;2660
assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]2661
.blocked2662
.is_some());2663
drop(lock);2664
// Journal naming a path outside the plan.2665
use std::io::Write;2666
let mut j = std::fs::OpenOptions::new()2667
.append(true)2668
.open(run.join(crate::resume::JOURNAL))2669
.unwrap();2670
writeln!(2671
j,2672
"{}",2673
serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})2674
)2675
.unwrap();2676
let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2677
.02678
.unwrap_err();2679
assert!(err.contains("onbekend pad"), "{err}");2680
}2190
}2681
}@@ -2,7 +2,7 @@
2
2
//! GPU service are implemented in `production`; previews retain native size.
3
3
4
4
use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};
5
pub use crate::production::{spawn, RenderJob, RenderThread};
5
pub use crate::production::{spawn, spawn_resume, RenderJob, RenderThread};
6
6
7
7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8
8
pub enum PassKind {
@@ -2,7 +2,7 @@2
//! GPU service are implemented in `production`; previews retain native size.2
//! GPU service are implemented in `production`; previews retain native size.3
3
4
use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};4
use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};5
pub use crate::production::{spawn, RenderJob, RenderThread};5
pub use crate::production::{spawn, spawn_resume, RenderJob, RenderThread};6
6
7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]7
#[derive(Debug, Clone, Copy, PartialEq, Eq)]8
pub enum PassKind {8
pub enum PassKind {@@ -0,0 +1,505 @@
1
//! Recovery of incomplete/cancelled/crashed production runs (design §7/§12.6).
2
//!
3
//! Every production run directory holds an exclusive `run.lock` while a
4
//! worker writes it and an append-only `progress.jsonl` journal with one
5
//! `{"path","sha256"}` line per published target PNG (fsynced after the PNG
6
//! itself was published). A run may be resumed only when its `render.json`
7
//! is byte-for-byte the same contract document that the current job would
8
//! write (project, sex/set, body/equipment SHA-256, target, FPS, framing,
9
//! pivot, camera, clip inventory, maps, counts). Before any rendering every
10
//! planned PNG is checked: journaled files whose bytes match their digest are
11
//! kept untouched; missing ones are regenerated; corrupt files and foreign
12
//! links are moved aside (`<name>.corrupt-aNNN-K`, never deleted); existing
13
//! files without a journal entry (crash between publish and journal) are
14
//! compared with the regenerated bytes and kept when identical.
15
use crate::digest;
16
use serde_json::Value;
17
use std::{
18
collections::{BTreeMap, BTreeSet, HashSet},
19
fs::{self, File, OpenOptions},
20
io::{Read, Write},
21
path::{Path, PathBuf},
22
};
23
24
pub const JOURNAL: &str = "progress.jsonl";
25
pub const LOCK: &str = "run.lock";
26
pub const RESUME_LOG: &str = "resume.jsonl";
27
const MAX_RENDER_JSON: u64 = 1024 * 1024;
28
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
29
const MAX_CANDIDATES: usize = 20;
30
const MAX_SCANNED_RUNS: usize = 2000;
31
32
/// Outcome of the pre-render verification of a resumed run.
33
#[derive(Debug, Clone, Default, PartialEq, Eq)]
34
pub struct ResumeReport {
35
pub attempt: u32,
36
pub planned: usize,
37
pub verified: usize,
38
pub missing: usize,
39
pub quarantined: Vec<String>,
40
pub unjournaled: usize,
41
pub damaged_journal_lines: usize,
42
pub previous_marker: Option<String>,
43
}
44
45
/// Open write session on one run directory (fresh or resumed).
46
pub struct RunSession {
47
pub root: PathBuf,
48
pub attempt: u32,
49
/// Planned PNGs already on disk with a verified journal checksum.
50
pub verified: BTreeMap<String, String>,
51
/// Existing regular PNGs without a journal entry; compared on regeneration.
52
pub unjournaled: BTreeSet<String>,
53
pub report: ResumeReport,
54
journal: File,
55
_lock: File,
56
}
57
58
fn io(e: std::io::Error) -> String {
59
e.to_string()
60
}
61
62
fn lock_run(root: &Path) -> Result<File, String> {
63
let path = root.join(LOCK);
64
if let Ok(meta) = fs::symlink_metadata(&path) {
65
if !meta.is_file() {
66
return Err("run.lock is geen regulier bestand".into());
67
}
68
}
69
let file = OpenOptions::new()
70
.create(true)
71
.truncate(false)
72
.write(true)
73
.open(&path)
74
.map_err(io)?;
75
match file.try_lock() {
76
Ok(()) => Ok(file),
77
Err(fs::TryLockError::WouldBlock) => {
78
Err("deze run wordt op dit moment al gerenderd (run.lock bezet)".into())
79
}
80
Err(fs::TryLockError::Error(e)) => Err(format!("run.lock: {e}")),
81
}
82
}
83
84
fn is_locked(root: &Path) -> bool {
85
let Ok(file) = OpenOptions::new().write(true).open(root.join(LOCK)) else {
86
return false;
87
};
88
matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock))
89
}
90
91
fn open_journal(root: &Path) -> Result<File, String> {
92
let path = root.join(JOURNAL);
93
if let Ok(meta) = fs::symlink_metadata(&path) {
94
if !meta.is_file() {
95
return Err("progress.jsonl is geen regulier bestand".into());
96
}
97
}
98
OpenOptions::new()
99
.create(true)
100
.append(true)
101
.open(path)
102
.map_err(io)
103
}
104
105
fn read_regular(path: &Path, max: u64) -> Result<Option<Vec<u8>>, String> {
106
let meta = match fs::symlink_metadata(path) {
107
Ok(meta) => meta,
108
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
109
Err(e) => return Err(e.to_string()),
110
};
111
if !meta.is_file() {
112
return Err(format!("{} is geen regulier bestand", path.display()));
113
}
114
if meta.len() > max {
115
return Err(format!("{} groter dan {max} bytes", path.display()));
116
}
117
let mut bytes = Vec::new();
118
File::open(path)
119
.map_err(io)?
120
.take(max + 1)
121
.read_to_end(&mut bytes)
122
.map_err(io)?;
123
if bytes.len() as u64 > max {
124
return Err(format!("{} groter dan {max} bytes", path.display()));
125
}
126
Ok(Some(bytes))
127
}
128
129
/// Journal entries: last valid entry per path wins; returns (entries, damaged lines, ends with newline).
130
pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, bool), String> {
131
let Some(bytes) = read_regular(&root.join(JOURNAL), MAX_JOURNAL)? else {
132
return Ok((BTreeMap::new(), 0, true));
133
};
134
let mut entries = BTreeMap::new();
135
let mut damaged = 0;
136
for line in bytes.split(|b| *b == b'\n') {
137
if line.is_empty() {
138
continue;
139
}
140
let parsed = serde_json::from_slice::<Value>(line).ok().and_then(|v| {
141
let path = v.get("path")?.as_str()?.to_string();
142
let sha = v.get("sha256")?.as_str()?.to_string();
143
digest::is_sha256_hex(&sha).then_some((path, sha))
144
});
145
match parsed {
146
Some((path, sha)) => {
147
entries.insert(path, sha);
148
}
149
None => damaged += 1,
150
}
151
}
152
Ok((entries, damaged, bytes.last().is_none_or(|b| *b == b'\n')))
153
}
154
155
/// Move a bad file/link aside inside the run without deleting or clobbering anything.
156
fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
157
let path = root.join(rel);
158
let name = path
159
.file_name()
160
.and_then(|n| n.to_str())
161
.ok_or("ongeldige bestandsnaam")?
162
.to_string();
163
for n in 0..1000 {
164
let target = path.with_file_name(format!("{name}.corrupt-a{attempt:03}-{n}"));
165
match fs::hard_link(&path, &target) {
166
Ok(()) => {
167
fs::remove_file(&path).map_err(io)?;
168
let rel_target = Path::new(rel)
169
.with_file_name(target.file_name().unwrap_or_default())
170
.to_string_lossy()
171
.replace('\\', "/");
172
return Ok(rel_target);
173
}
174
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
175
Err(e) => return Err(format!("quarantaine van {rel} mislukt: {e}")),
176
}
177
}
178
Err(format!("geen vrije quarantainenaam voor {rel}"))
179
}
180
181
/// Move a file to a fresh unique name (no clobber), returning the new file name.
182
fn archive(root: &Path, name: &str, prefix: &str) -> Result<String, String> {
183
for n in 0..1000 {
184
let target = if n == 0 {
185
format!("{prefix}-{name}")
186
} else {
187
format!("{prefix}-{n}-{name}")
188
};
189
match fs::hard_link(root.join(name), root.join(&target)) {
190
Ok(()) => {
191
fs::remove_file(root.join(name)).map_err(io)?;
192
return Ok(target);
193
}
194
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
195
Err(e) => return Err(format!("archiveren van {name} mislukt: {e}")),
196
}
197
}
198
Err(format!("geen vrije archiefnaam voor {name}"))
199
}
200
201
/// Top-level keys whose values differ between two JSON objects.
202
pub fn contract_differences(existing: &Value, expected: &Value) -> Vec<String> {
203
match (existing.as_object(), expected.as_object()) {
204
(Some(a), Some(b)) => a
205
.keys()
206
.chain(b.keys())
207
.collect::<BTreeSet<_>>()
208
.into_iter()
209
.filter(|k| a.get(*k) != b.get(*k))
210
.cloned()
211
.collect(),
212
_ => vec!["render.json".into()],
213
}
214
}
215
216
fn normalized(doc: &Value) -> Result<Value, String> {
217
serde_json::from_str(&serde_json::to_string(doc).map_err(|e| e.to_string())?)
218
.map_err(|e| e.to_string())
219
}
220
221
fn read_render(root: &Path) -> Result<Value, String> {
222
let bytes =
223
read_regular(&root.join("render.json"), MAX_RENDER_JSON)?.ok_or("render.json ontbreekt")?;
224
serde_json::from_slice(&bytes).map_err(|e| format!("render.json: {e}"))
225
}
226
227
fn check_contract(root: &Path, render_doc: &Value) -> Result<(), String> {
228
let existing = read_render(root)?;
229
let expected = normalized(render_doc)?;
230
if existing != expected {
231
return Err(format!(
232
"huidige instellingen/bronnen passen niet bij deze run (verschil in: {})",
233
contract_differences(&existing, &expected).join(", ")
234
));
235
}
236
Ok(())
237
}
238
239
/// Direct `run-*` child directory of the output directory, not a link.
240
fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
241
let meta = fs::symlink_metadata(dir).map_err(io)?;
242
if !meta.is_dir() {
243
return Err("runmap is geen gewone map".into());
244
}
245
let out = output.canonicalize().map_err(io)?;
246
let root = dir.canonicalize().map_err(io)?;
247
let name_ok = root
248
.file_name()
249
.and_then(|n| n.to_str())
250
.is_some_and(|n| n.starts_with("run-"));
251
if root.parent() != Some(out.as_path()) || !name_ok {
252
return Err("runmap ligt niet direct in de uitvoermap".into());
253
}
254
Ok(root)
255
}
256
257
impl RunSession {
258
/// A new, empty run directory created by the caller.
259
pub fn fresh(root: &Path) -> Result<Self, String> {
260
let lock = lock_run(root)?;
261
let journal = open_journal(root)?;
262
Ok(Self {
263
root: root.to_path_buf(),
264
attempt: 0,
265
verified: BTreeMap::new(),
266
unjournaled: BTreeSet::new(),
267
report: ResumeReport::default(),
268
journal,
269
_lock: lock,
270
})
271
}
272
273
/// Lock, contract-check and verify an incomplete run before re-rendering.
274
/// `planned` lists every relative target PNG of the job in render order.
275
pub fn resume(
276
output: &Path,
277
dir: &Path,
278
render_doc: &Value,
279
planned: &[String],
280
progress: &mut dyn FnMut(usize, usize),
281
) -> Result<Self, String> {
282
let root = run_root(output, dir)?;
283
if fs::symlink_metadata(root.join("complete.json")).is_ok() {
284
return Err("deze run is al voltooid (complete.json bestaat)".into());
285
}
286
let lock = lock_run(&root)?;
287
check_contract(&root, render_doc)?;
288
let planned_set: HashSet<&str> = planned.iter().map(String::as_str).collect();
289
let (journal_entries, damaged, newline) = read_journal(&root)?;
290
if let Some(unknown) = journal_entries
291
.keys()
292
.find(|k| !planned_set.contains(k.as_str()))
293
{
294
return Err(format!("journal noemt een onbekend pad: {unknown}"));
295
}
296
let previous_attempts = read_regular(&root.join(RESUME_LOG), MAX_RENDER_JSON)?
297
.map(|b| b.split(|c| *c == b'\n').filter(|l| !l.is_empty()).count())
298
.unwrap_or(0);
299
let attempt = previous_attempts as u32 + 1;
300
let mut report = ResumeReport {
301
attempt,
302
planned: planned.len(),
303
damaged_journal_lines: damaged,
304
..Default::default()
305
};
306
let mut verified = BTreeMap::new();
307
let mut unjournaled = BTreeSet::new();
308
for (i, rel) in planned.iter().enumerate() {
309
if i % 256 == 0 {
310
progress(i, planned.len());
311
}
312
let path = root.join(rel);
313
let meta = match fs::symlink_metadata(&path) {
314
Ok(meta) => meta,
315
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
316
report.missing += 1;
317
continue;
318
}
319
Err(e) => return Err(e.to_string()),
320
};
321
if meta.is_dir() {
322
return Err(format!("map staat op de plaats van PNG {rel}"));
323
}
324
if !meta.is_file() || meta.len() > crate::export_set::MAX_PNG_BYTES {
325
report.quarantined.push(quarantine(&root, rel, attempt)?);
326
continue;
327
}
328
match journal_entries.get(rel) {
329
Some(expected) => {
330
let bytes = fs::read(&path).map_err(io)?;
331
if digest::sha256_hex(&bytes) == *expected {
332
verified.insert(rel.clone(), expected.clone());
333
} else {
334
report.quarantined.push(quarantine(&root, rel, attempt)?);
335
}
336
}
337
None => {
338
unjournaled.insert(rel.clone());
339
}
340
}
341
}
342
progress(planned.len(), planned.len());
343
report.verified = verified.len();
344
report.unjournaled = unjournaled.len();
345
for marker in ["cancelled.json", "failed.json"] {
346
if fs::symlink_metadata(root.join(marker)).is_ok() {
347
let archived = archive(&root, marker, &format!("attempt-{:03}", attempt - 1))?;
348
report.previous_marker = Some(archived);
349
}
350
}
351
let mut journal = open_journal(&root)?;
352
if !newline {
353
journal.write_all(b"\n").map_err(io)?;
354
journal.sync_data().map_err(io)?;
355
}
356
let entry = serde_json::json!({
357
"attempt": attempt,
358
"planned_png_files": report.planned,
359
"verified": report.verified,
360
"missing": report.missing,
361
"quarantined": report.quarantined,
362
"unjournaled_existing": report.unjournaled,
363
"damaged_journal_lines": report.damaged_journal_lines,
364
"previous_marker": report.previous_marker,
365
});
366
let mut log = OpenOptions::new()
367
.create(true)
368
.append(true)
369
.open(root.join(RESUME_LOG))
370
.map_err(io)?;
371
log.write_all(format!("{entry}\n").as_bytes()).map_err(io)?;
372
log.sync_data().map_err(io)?;
373
Ok(Self {
374
root,
375
attempt,
376
verified,
377
unjournaled,
378
report,
379
journal,
380
_lock: lock,
381
})
382
}
383
384
fn record(&mut self, rel: &str, sha: &str) -> Result<(), String> {
385
let line = serde_json::json!({"path": rel, "sha256": sha}).to_string() + "\n";
386
self.journal.write_all(line.as_bytes()).map_err(io)?;
387
self.journal.sync_data().map_err(io)
388
}
389
390
/// Publish encoded PNG bytes for a planned path, never overwriting a file in place.
391
pub fn publish(&mut self, rel: &str, bytes: &[u8]) -> Result<String, String> {
392
if let Some(sha) = self.verified.get(rel) {
393
return Ok(sha.clone());
394
}
395
let sha = digest::sha256_hex(bytes);
396
if self.unjournaled.remove(rel) {
397
let path = self.root.join(rel);
398
match fs::symlink_metadata(&path) {
399
Ok(meta) if meta.is_file() => {
400
let existing = fs::read(&path).map_err(io)?;
401
if existing == bytes {
402
self.record(rel, &sha)?;
403
return Ok(sha);
404
}
405
let q = quarantine(&self.root, rel, self.attempt)?;
406
self.report.quarantined.push(q);
407
}
408
Ok(_) => {
409
let q = quarantine(&self.root, rel, self.attempt)?;
410
self.report.quarantined.push(q);
411
}
412
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
413
Err(e) => return Err(e.to_string()),
414
}
415
}
416
crate::production::publish_bytes(&self.root, Path::new(rel), bytes)?;
417
self.record(rel, &sha)?;
418
Ok(sha)
419
}
420
}
421
422
/// An incomplete run of the current sex/set found in the output directory.
423
#[derive(Debug, Clone, PartialEq, Eq)]
424
pub struct ResumeCandidate {
425
pub dir: PathBuf,
426
pub name: String,
427
/// Distinct planned PNGs recorded in the journal (not yet re-verified).
428
pub journaled: usize,
429
pub planned: u64,
430
/// "geannuleerd", "mislukt" or "afgebroken" (no terminal marker: crash/kill).
431
pub state: String,
432
/// Why this run cannot be resumed now; None when resumable.
433
pub blocked: Option<String>,
434
}
435
436
fn run_stamp(name: &str) -> u128 {
437
name.strip_prefix("run-")
438
.and_then(|r| r.split('-').next())
439
.and_then(|n| n.parse().ok())
440
.unwrap_or(0)
441
}
442
443
/// Incomplete runs of the same sex/set as `render_doc`, newest first.
444
pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandidate>, String> {
445
let expected = normalized(render_doc)?;
446
let read = match fs::read_dir(output) {
447
Ok(read) => read,
448
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
449
Err(e) => return Err(e.to_string()),
450
};
451
let mut found = Vec::new();
452
for entry in read.take(MAX_SCANNED_RUNS) {
453
let entry = entry.map_err(io)?;
454
let name = entry.file_name().to_string_lossy().to_string();
455
let Ok(kind) = entry.file_type() else {
456
continue;
457
};
458
if !name.starts_with("run-") || !kind.is_dir() {
459
continue;
460
}
461
let dir = entry.path();
462
if fs::symlink_metadata(dir.join("complete.json")).is_ok() {
463
continue;
464
}
465
let Ok(existing) = read_render(&dir) else {
466
continue;
467
};
468
if existing.get("sex") != expected.get("sex") || existing.get("set") != expected.get("set")
469
{
470
continue;
471
}
472
let state = if dir.join("cancelled.json").exists() {
473
"geannuleerd"
474
} else if dir.join("failed.json").exists() {
475
"mislukt"
476
} else {
477
"afgebroken"
478
};
479
let journaled = read_journal(&dir).map(|(e, _, _)| e.len()).unwrap_or(0);
480
let blocked = if is_locked(&dir) {
481
Some("wordt op dit moment gerenderd".to_string())
482
} else if existing != expected {
483
Some(format!(
484
"past niet meer bij de huidige instellingen/bronnen (verschil in: {})",
485
contract_differences(&existing, &expected).join(", ")
486
))
487
} else {
488
None
489
};
490
found.push(ResumeCandidate {
491
dir,
492
planned: existing
493
.get("png_files")
494
.and_then(Value::as_u64)
495
.unwrap_or(0),
496
name,
497
journaled,
498
state: state.into(),
499
blocked,
500
});
501
}
502
found.sort_by_key(|c| std::cmp::Reverse(run_stamp(&c.name)));
503
found.truncate(MAX_CANDIDATES);
504
Ok(found)
505
}
@@ -0,0 +1,505 @@1
//! Recovery of incomplete/cancelled/crashed production runs (design §7/§12.6).2
//!3
//! Every production run directory holds an exclusive `run.lock` while a4
//! worker writes it and an append-only `progress.jsonl` journal with one5
//! `{"path","sha256"}` line per published target PNG (fsynced after the PNG6
//! itself was published). A run may be resumed only when its `render.json`7
//! is byte-for-byte the same contract document that the current job would8
//! write (project, sex/set, body/equipment SHA-256, target, FPS, framing,9
//! pivot, camera, clip inventory, maps, counts). Before any rendering every10
//! planned PNG is checked: journaled files whose bytes match their digest are11
//! kept untouched; missing ones are regenerated; corrupt files and foreign12
//! links are moved aside (`<name>.corrupt-aNNN-K`, never deleted); existing13
//! files without a journal entry (crash between publish and journal) are14
//! compared with the regenerated bytes and kept when identical.15
use crate::digest;16
use serde_json::Value;17
use std::{18
collections::{BTreeMap, BTreeSet, HashSet},19
fs::{self, File, OpenOptions},20
io::{Read, Write},21
path::{Path, PathBuf},22
};23
24
pub const JOURNAL: &str = "progress.jsonl";25
pub const LOCK: &str = "run.lock";26
pub const RESUME_LOG: &str = "resume.jsonl";27
const MAX_RENDER_JSON: u64 = 1024 * 1024;28
const MAX_JOURNAL: u64 = 256 * 1024 * 1024;29
const MAX_CANDIDATES: usize = 20;30
const MAX_SCANNED_RUNS: usize = 2000;31
32
/// Outcome of the pre-render verification of a resumed run.33
#[derive(Debug, Clone, Default, PartialEq, Eq)]34
pub struct ResumeReport {35
pub attempt: u32,36
pub planned: usize,37
pub verified: usize,38
pub missing: usize,39
pub quarantined: Vec<String>,40
pub unjournaled: usize,41
pub damaged_journal_lines: usize,42
pub previous_marker: Option<String>,43
}44
45
/// Open write session on one run directory (fresh or resumed).46
pub struct RunSession {47
pub root: PathBuf,48
pub attempt: u32,49
/// Planned PNGs already on disk with a verified journal checksum.50
pub verified: BTreeMap<String, String>,51
/// Existing regular PNGs without a journal entry; compared on regeneration.52
pub unjournaled: BTreeSet<String>,53
pub report: ResumeReport,54
journal: File,55
_lock: File,56
}57
58
fn io(e: std::io::Error) -> String {59
e.to_string()60
}61
62
fn lock_run(root: &Path) -> Result<File, String> {63
let path = root.join(LOCK);64
if let Ok(meta) = fs::symlink_metadata(&path) {65
if !meta.is_file() {66
return Err("run.lock is geen regulier bestand".into());67
}68
}69
let file = OpenOptions::new()70
.create(true)71
.truncate(false)72
.write(true)73
.open(&path)74
.map_err(io)?;75
match file.try_lock() {76
Ok(()) => Ok(file),77
Err(fs::TryLockError::WouldBlock) => {78
Err("deze run wordt op dit moment al gerenderd (run.lock bezet)".into())79
}80
Err(fs::TryLockError::Error(e)) => Err(format!("run.lock: {e}")),81
}82
}83
84
fn is_locked(root: &Path) -> bool {85
let Ok(file) = OpenOptions::new().write(true).open(root.join(LOCK)) else {86
return false;87
};88
matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock))89
}90
91
fn open_journal(root: &Path) -> Result<File, String> {92
let path = root.join(JOURNAL);93
if let Ok(meta) = fs::symlink_metadata(&path) {94
if !meta.is_file() {95
return Err("progress.jsonl is geen regulier bestand".into());96
}97
}98
OpenOptions::new()99
.create(true)100
.append(true)101
.open(path)102
.map_err(io)103
}104
105
fn read_regular(path: &Path, max: u64) -> Result<Option<Vec<u8>>, String> {106
let meta = match fs::symlink_metadata(path) {107
Ok(meta) => meta,108
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),109
Err(e) => return Err(e.to_string()),110
};111
if !meta.is_file() {112
return Err(format!("{} is geen regulier bestand", path.display()));113
}114
if meta.len() > max {115
return Err(format!("{} groter dan {max} bytes", path.display()));116
}117
let mut bytes = Vec::new();118
File::open(path)119
.map_err(io)?120
.take(max + 1)121
.read_to_end(&mut bytes)122
.map_err(io)?;123
if bytes.len() as u64 > max {124
return Err(format!("{} groter dan {max} bytes", path.display()));125
}126
Ok(Some(bytes))127
}128
129
/// Journal entries: last valid entry per path wins; returns (entries, damaged lines, ends with newline).130
pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, bool), String> {131
let Some(bytes) = read_regular(&root.join(JOURNAL), MAX_JOURNAL)? else {132
return Ok((BTreeMap::new(), 0, true));133
};134
let mut entries = BTreeMap::new();135
let mut damaged = 0;136
for line in bytes.split(|b| *b == b'\n') {137
if line.is_empty() {138
continue;139
}140
let parsed = serde_json::from_slice::<Value>(line).ok().and_then(|v| {141
let path = v.get("path")?.as_str()?.to_string();142
let sha = v.get("sha256")?.as_str()?.to_string();143
digest::is_sha256_hex(&sha).then_some((path, sha))144
});145
match parsed {146
Some((path, sha)) => {147
entries.insert(path, sha);148
}149
None => damaged += 1,150
}151
}152
Ok((entries, damaged, bytes.last().is_none_or(|b| *b == b'\n')))153
}154
155
/// Move a bad file/link aside inside the run without deleting or clobbering anything.156
fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {157
let path = root.join(rel);158
let name = path159
.file_name()160
.and_then(|n| n.to_str())161
.ok_or("ongeldige bestandsnaam")?162
.to_string();163
for n in 0..1000 {164
let target = path.with_file_name(format!("{name}.corrupt-a{attempt:03}-{n}"));165
match fs::hard_link(&path, &target) {166
Ok(()) => {167
fs::remove_file(&path).map_err(io)?;168
let rel_target = Path::new(rel)169
.with_file_name(target.file_name().unwrap_or_default())170
.to_string_lossy()171
.replace('\\', "/");172
return Ok(rel_target);173
}174
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,175
Err(e) => return Err(format!("quarantaine van {rel} mislukt: {e}")),176
}177
}178
Err(format!("geen vrije quarantainenaam voor {rel}"))179
}180
181
/// Move a file to a fresh unique name (no clobber), returning the new file name.182
fn archive(root: &Path, name: &str, prefix: &str) -> Result<String, String> {183
for n in 0..1000 {184
let target = if n == 0 {185
format!("{prefix}-{name}")186
} else {187
format!("{prefix}-{n}-{name}")188
};189
match fs::hard_link(root.join(name), root.join(&target)) {190
Ok(()) => {191
fs::remove_file(root.join(name)).map_err(io)?;192
return Ok(target);193
}194
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,195
Err(e) => return Err(format!("archiveren van {name} mislukt: {e}")),196
}197
}198
Err(format!("geen vrije archiefnaam voor {name}"))199
}200
201
/// Top-level keys whose values differ between two JSON objects.202
pub fn contract_differences(existing: &Value, expected: &Value) -> Vec<String> {203
match (existing.as_object(), expected.as_object()) {204
(Some(a), Some(b)) => a205
.keys()206
.chain(b.keys())207
.collect::<BTreeSet<_>>()208
.into_iter()209
.filter(|k| a.get(*k) != b.get(*k))210
.cloned()211
.collect(),212
_ => vec!["render.json".into()],213
}214
}215
216
fn normalized(doc: &Value) -> Result<Value, String> {217
serde_json::from_str(&serde_json::to_string(doc).map_err(|e| e.to_string())?)218
.map_err(|e| e.to_string())219
}220
221
fn read_render(root: &Path) -> Result<Value, String> {222
let bytes =223
read_regular(&root.join("render.json"), MAX_RENDER_JSON)?.ok_or("render.json ontbreekt")?;224
serde_json::from_slice(&bytes).map_err(|e| format!("render.json: {e}"))225
}226
227
fn check_contract(root: &Path, render_doc: &Value) -> Result<(), String> {228
let existing = read_render(root)?;229
let expected = normalized(render_doc)?;230
if existing != expected {231
return Err(format!(232
"huidige instellingen/bronnen passen niet bij deze run (verschil in: {})",233
contract_differences(&existing, &expected).join(", ")234
));235
}236
Ok(())237
}238
239
/// Direct `run-*` child directory of the output directory, not a link.240
fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {241
let meta = fs::symlink_metadata(dir).map_err(io)?;242
if !meta.is_dir() {243
return Err("runmap is geen gewone map".into());244
}245
let out = output.canonicalize().map_err(io)?;246
let root = dir.canonicalize().map_err(io)?;247
let name_ok = root248
.file_name()249
.and_then(|n| n.to_str())250
.is_some_and(|n| n.starts_with("run-"));251
if root.parent() != Some(out.as_path()) || !name_ok {252
return Err("runmap ligt niet direct in de uitvoermap".into());253
}254
Ok(root)255
}256
257
impl RunSession {258
/// A new, empty run directory created by the caller.259
pub fn fresh(root: &Path) -> Result<Self, String> {260
let lock = lock_run(root)?;261
let journal = open_journal(root)?;262
Ok(Self {263
root: root.to_path_buf(),264
attempt: 0,265
verified: BTreeMap::new(),266
unjournaled: BTreeSet::new(),267
report: ResumeReport::default(),268
journal,269
_lock: lock,270
})271
}272
273
/// Lock, contract-check and verify an incomplete run before re-rendering.274
/// `planned` lists every relative target PNG of the job in render order.275
pub fn resume(276
output: &Path,277
dir: &Path,278
render_doc: &Value,279
planned: &[String],280
progress: &mut dyn FnMut(usize, usize),281
) -> Result<Self, String> {282
let root = run_root(output, dir)?;283
if fs::symlink_metadata(root.join("complete.json")).is_ok() {284
return Err("deze run is al voltooid (complete.json bestaat)".into());285
}286
let lock = lock_run(&root)?;287
check_contract(&root, render_doc)?;288
let planned_set: HashSet<&str> = planned.iter().map(String::as_str).collect();289
let (journal_entries, damaged, newline) = read_journal(&root)?;290
if let Some(unknown) = journal_entries291
.keys()292
.find(|k| !planned_set.contains(k.as_str()))293
{294
return Err(format!("journal noemt een onbekend pad: {unknown}"));295
}296
let previous_attempts = read_regular(&root.join(RESUME_LOG), MAX_RENDER_JSON)?297
.map(|b| b.split(|c| *c == b'\n').filter(|l| !l.is_empty()).count())298
.unwrap_or(0);299
let attempt = previous_attempts as u32 + 1;300
let mut report = ResumeReport {301
attempt,302
planned: planned.len(),303
damaged_journal_lines: damaged,304
..Default::default()305
};306
let mut verified = BTreeMap::new();307
let mut unjournaled = BTreeSet::new();308
for (i, rel) in planned.iter().enumerate() {309
if i % 256 == 0 {310
progress(i, planned.len());311
}312
let path = root.join(rel);313
let meta = match fs::symlink_metadata(&path) {314
Ok(meta) => meta,315
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {316
report.missing += 1;317
continue;318
}319
Err(e) => return Err(e.to_string()),320
};321
if meta.is_dir() {322
return Err(format!("map staat op de plaats van PNG {rel}"));323
}324
if !meta.is_file() || meta.len() > crate::export_set::MAX_PNG_BYTES {325
report.quarantined.push(quarantine(&root, rel, attempt)?);326
continue;327
}328
match journal_entries.get(rel) {329
Some(expected) => {330
let bytes = fs::read(&path).map_err(io)?;331
if digest::sha256_hex(&bytes) == *expected {332
verified.insert(rel.clone(), expected.clone());333
} else {334
report.quarantined.push(quarantine(&root, rel, attempt)?);335
}336
}337
None => {338
unjournaled.insert(rel.clone());339
}340
}341
}342
progress(planned.len(), planned.len());343
report.verified = verified.len();344
report.unjournaled = unjournaled.len();345
for marker in ["cancelled.json", "failed.json"] {346
if fs::symlink_metadata(root.join(marker)).is_ok() {347
let archived = archive(&root, marker, &format!("attempt-{:03}", attempt - 1))?;348
report.previous_marker = Some(archived);349
}350
}351
let mut journal = open_journal(&root)?;352
if !newline {353
journal.write_all(b"\n").map_err(io)?;354
journal.sync_data().map_err(io)?;355
}356
let entry = serde_json::json!({357
"attempt": attempt,358
"planned_png_files": report.planned,359
"verified": report.verified,360
"missing": report.missing,361
"quarantined": report.quarantined,362
"unjournaled_existing": report.unjournaled,363
"damaged_journal_lines": report.damaged_journal_lines,364
"previous_marker": report.previous_marker,365
});366
let mut log = OpenOptions::new()367
.create(true)368
.append(true)369
.open(root.join(RESUME_LOG))370
.map_err(io)?;371
log.write_all(format!("{entry}\n").as_bytes()).map_err(io)?;372
log.sync_data().map_err(io)?;373
Ok(Self {374
root,375
attempt,376
verified,377
unjournaled,378
report,379
journal,380
_lock: lock,381
})382
}383
384
fn record(&mut self, rel: &str, sha: &str) -> Result<(), String> {385
let line = serde_json::json!({"path": rel, "sha256": sha}).to_string() + "\n";386
self.journal.write_all(line.as_bytes()).map_err(io)?;387
self.journal.sync_data().map_err(io)388
}389
390
/// Publish encoded PNG bytes for a planned path, never overwriting a file in place.391
pub fn publish(&mut self, rel: &str, bytes: &[u8]) -> Result<String, String> {392
if let Some(sha) = self.verified.get(rel) {393
return Ok(sha.clone());394
}395
let sha = digest::sha256_hex(bytes);396
if self.unjournaled.remove(rel) {397
let path = self.root.join(rel);398
match fs::symlink_metadata(&path) {399
Ok(meta) if meta.is_file() => {400
let existing = fs::read(&path).map_err(io)?;401
if existing == bytes {402
self.record(rel, &sha)?;403
return Ok(sha);404
}405
let q = quarantine(&self.root, rel, self.attempt)?;406
self.report.quarantined.push(q);407
}408
Ok(_) => {409
let q = quarantine(&self.root, rel, self.attempt)?;410
self.report.quarantined.push(q);411
}412
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}413
Err(e) => return Err(e.to_string()),414
}415
}416
crate::production::publish_bytes(&self.root, Path::new(rel), bytes)?;417
self.record(rel, &sha)?;418
Ok(sha)419
}420
}421
422
/// An incomplete run of the current sex/set found in the output directory.423
#[derive(Debug, Clone, PartialEq, Eq)]424
pub struct ResumeCandidate {425
pub dir: PathBuf,426
pub name: String,427
/// Distinct planned PNGs recorded in the journal (not yet re-verified).428
pub journaled: usize,429
pub planned: u64,430
/// "geannuleerd", "mislukt" or "afgebroken" (no terminal marker: crash/kill).431
pub state: String,432
/// Why this run cannot be resumed now; None when resumable.433
pub blocked: Option<String>,434
}435
436
fn run_stamp(name: &str) -> u128 {437
name.strip_prefix("run-")438
.and_then(|r| r.split('-').next())439
.and_then(|n| n.parse().ok())440
.unwrap_or(0)441
}442
443
/// Incomplete runs of the same sex/set as `render_doc`, newest first.444
pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandidate>, String> {445
let expected = normalized(render_doc)?;446
let read = match fs::read_dir(output) {447
Ok(read) => read,448
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),449
Err(e) => return Err(e.to_string()),450
};451
let mut found = Vec::new();452
for entry in read.take(MAX_SCANNED_RUNS) {453
let entry = entry.map_err(io)?;454
let name = entry.file_name().to_string_lossy().to_string();455
let Ok(kind) = entry.file_type() else {456
continue;457
};458
if !name.starts_with("run-") || !kind.is_dir() {459
continue;460
}461
let dir = entry.path();462
if fs::symlink_metadata(dir.join("complete.json")).is_ok() {463
continue;464
}465
let Ok(existing) = read_render(&dir) else {466
continue;467
};468
if existing.get("sex") != expected.get("sex") || existing.get("set") != expected.get("set")469
{470
continue;471
}472
let state = if dir.join("cancelled.json").exists() {473
"geannuleerd"474
} else if dir.join("failed.json").exists() {475
"mislukt"476
} else {477
"afgebroken"478
};479
let journaled = read_journal(&dir).map(|(e, _, _)| e.len()).unwrap_or(0);480
let blocked = if is_locked(&dir) {481
Some("wordt op dit moment gerenderd".to_string())482
} else if existing != expected {483
Some(format!(484
"past niet meer bij de huidige instellingen/bronnen (verschil in: {})",485
contract_differences(&existing, &expected).join(", ")486
))487
} else {488
None489
};490
found.push(ResumeCandidate {491
dir,492
planned: existing493
.get("png_files")494
.and_then(Value::as_u64)495
.unwrap_or(0),496
name,497
journaled,498
state: state.into(),499
blocked,500
});501
}502
found.sort_by_key(|c| std::cmp::Reverse(run_stamp(&c.name)));503
found.truncate(MAX_CANDIDATES);504
Ok(found)505
}