BlueGit

golden-age paperdoll-renderer

Merge feature/production-resume: checksum-verified resume of incomplete runs

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
b78ad139903571c13e9c19ae9e451815a952f865
Pipeline
Passed
9 files changed +1431 −73
README.md +17 −0
@@ -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 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
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 ![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
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** selecteert
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).
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![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31
32
@@ -81,6 +82,17 @@ renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
81
82
82Ieder 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`.
83Ieder 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
87Een 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![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
95
84## 2D depth-compositor (bibliotheek)
96## 2D depth-compositor (bibliotheek)
85
97
86De 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).
98De 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
123cargo test --workspace --locked
135cargo test --workspace --locked
124# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
136# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
125xvfb-run -a cargo run --example preview_depth_probe --locked
137xvfb-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
139cargo 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
140xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
141xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
142cargo run --release --example resume_fixture --locked -- verify /tmp/resume
126```
143```
127
144
128De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
145De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
design.md +5 −3
@@ -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 algorithm
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- 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
220Current 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.
222Current 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 gates
224## 12. Incremental feature plan and acceptance gates
223
225
@@ -227,7 +229,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
2273. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2293. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2284. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2304. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2295. 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.
2315. 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.
2306. 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.)
2326. 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.)
2317. 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.
2337. 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.
2328. 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.
2348. 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
docs/screenshots/body-resume.png added Binary file +0 −0

The contents of this binary file are not shown.

examples/resume_fixture.rs added +236 −0
@@ -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 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.
9use eframe::App;
10use paperdoll_renderer::{
11 editor::EditorApp,
12 session::{adopt_file, Session, Sex},
13};
14use std::path::PathBuf;
15
16struct 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
31impl 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
120impl 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
158fn 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}
src/editor.rs +125 −19
@@ -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
49impl EditorApp {
52impl 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_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 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 = self
587 let request = self
488 .render_worker
588 .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;
src/lib.rs +2 −1
@@ -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/identity
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
8pub mod camera;
8pub mod camera;
9pub mod compositor;
9pub mod compositor;
@@ -22,5 +22,6 @@ pub mod preview_gl;
22pub mod production;
22pub mod production;
23pub mod project;
23pub mod project;
24pub mod render_job;
24pub mod render_job;
25pub mod resume;
25pub mod session;
26pub mod session;
26pub mod still;
27pub mod still;
src/production.rs +540 −49
@@ -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.
1163pub(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
1157fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1170fn 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 existing
1236/// incomplete run that is recovered after contract and checksum verification.
1237#[derive(Debug, Clone, PartialEq, Eq)]
1238pub enum RunTarget {
1239 Fresh,
1240 Resume(PathBuf),
1241}
1242
1222pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1243pub 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`).
1248pub 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
1256fn 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>) -> RenderThread
1232 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>) -> RenderThread
1248 handle: Some(handle),
1286 handle: Some(handle),
1249 }
1287 }
1250}
1288}
1251fn 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,
1292pub 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.
1300pub 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 = job
1292 .output
1293 .join(format!("run-{stamp}-{}", std::process::id()));
1294 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1295 let set = job
1329 let set = job
1296 .preset
1330 .preset
1297 .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.
1410pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];
1411
1412/// Every planned target PNG (relative, `/`-separated) in render order, four per image.
1413pub 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)]
1433fn 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
1443fn 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 // 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.frames
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 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 map
1626 };
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 map
1493 }
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 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}
src/render_job.rs +1 −1
@@ -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
4use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};
4use crate::preview::{paperdoll_yaws_deg, PAPERDOLL_PITCHES_DEG};
5pub use crate::production::{spawn, RenderJob, RenderThread};
5pub use crate::production::{spawn, spawn_resume, RenderJob, RenderThread};
6
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PassKind {
8pub enum PassKind {
src/resume.rs added +505 −0
@@ -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 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.
15use crate::digest;
16use serde_json::Value;
17use std::{
18 collections::{BTreeMap, BTreeSet, HashSet},
19 fs::{self, File, OpenOptions},
20 io::{Read, Write},
21 path::{Path, PathBuf},
22};
23
24pub const JOURNAL: &str = "progress.jsonl";
25pub const LOCK: &str = "run.lock";
26pub const RESUME_LOG: &str = "resume.jsonl";
27const MAX_RENDER_JSON: u64 = 1024 * 1024;
28const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
29const MAX_CANDIDATES: usize = 20;
30const MAX_SCANNED_RUNS: usize = 2000;
31
32/// Outcome of the pre-render verification of a resumed run.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub 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).
46pub 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
58fn io(e: std::io::Error) -> String {
59 e.to_string()
60}
61
62fn 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
84fn 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
91fn 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
105fn 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).
130pub 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.
156fn 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.
182fn 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.
202pub 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
216fn 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
221fn 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
227fn 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.
240fn 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
257impl 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)]
424pub 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
436fn 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.
444pub 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}