diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index 0adc480..e50d580 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -831,7 +831,8 @@ code, which makes the byte-identity regression floor structural rather than hope and makes the fix cheap to revert if the underlying inference proves wrong. Also added: a transient isolation guard cutting `B_MAINSEND` on direct folder children and muting receives so an item capture stays true to item scope, and a post-render frame-count -gate (±1 tolerance, tail-None only) that refuses and self-cleans a widened render. New +gate (±1 tolerance, tail-None only) that refuses a widened render and retains it outside +the bank for diagnosis rather than deleting it. New modules `core/capture/render_window`, `core/capture/track_topology`, `shell/capture/render_selection`, `shell/capture/render_isolation`. diff --git a/docs/PLAN.md b/docs/PLAN.md index b9c02a7..e183a3e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -2128,8 +2128,8 @@ full-extent case runs literally unchanged code, keeping the byte-identity regres floor structural and the fix cheap to revert if the override inference proves wrong. Also landed: a transient isolation guard (cutting `B_MAINSEND` on direct folder children, muting receives) so an item capture stays true to item scope, and a -post-render frame-count gate (±1 tolerance, tail-None only) that refuses and self-cleans -a widened render. New modules `core/capture/render_window`, `core/capture/track_topology`, +post-render frame-count gate (±1 tolerance, tail-None only) that refuses a widened +render and retains it outside the bank for diagnosis rather than deleting it. New modules `core/capture/render_window`, `core/capture/track_topology`, `shell/capture/render_selection`, `shell/capture/render_isolation`. The whole fix rests on the unverified inference that REAPER's selected-tracks render source overrides custom time bounds — Ψ-W3-T1 (below) now also depends on it. **DAW-verification diff --git a/docs/TODO.md b/docs/TODO.md index 2f099fa..3ef3058 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -689,7 +689,7 @@ stereo file today. `Sample::channelCount` matching, the same way an offline dead-center capture does; a true-stereo bake is byte-identical to today's output. -## A 0-byte render can still pass every gate under Auto/Manual tail (narrowed, not closed) +## A 0-byte render can still pass every gate under Auto/Manual tail (closed) **Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check passes for a 0-byte file, and the bounds gate used to fire only when `expectedFrames > 0` @@ -697,22 +697,25 @@ passes for a 0-byte file, and the bounds gate used to fire only when `expectedFr refusing, so a 0-byte render reached `stampCaptureSample` and landed as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`. -**Narrowed.** `shell/capture/render_bounds_gate` now refuses an unmeasurable render -(invalid layout, or a layout declaring no sample rate) instead of skipping it. That -covers `TailMode::None` only — the gate does not judge Auto/Manual, which add frames by -design, so a 0-byte render under either of those still lands as `Ok`. The refusal reuses -`CaptureStatus::BoundsMismatch` rather than minting its own status; the earlier note here -preferred a distinct status, and that preference is unresolved, not withdrawn. +**Narrowed, then reopened as an asymmetry.** `shell/capture/render_bounds_gate` was +first changed to refuse an unmeasurable render (invalid layout, or a layout declaring no +sample rate) instead of skipping it — but that gate only ever judges `TailMode::None`, +so a 0-byte render under Auto/Manual still landed as `Ok`, while `None` now refused and +quarantined the identical file. The two tail modes disagreed on a defect neither should +accept. -**Intended fix.** Reject a 0-byte / unparseable render right after the exists-check, on -every tail mode, before anything downstream reads it. +**Closed.** `capture.cpp` now checks `checkRenderedFileNotEmpty` right after the +exists-check, on every tail mode, before the `TailMode::None`-only bounds gate runs — +a 0-byte render is refused and quarantined identically regardless of tail mode. The +refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own status; the +earlier note here preferred a distinct status, and that preference is unresolved, not +withdrawn. ## An offline capture can be refused for a short render — root cause open **Symptom (live, 2026-08-02).** A capture over [0.000000s, 4.067797s) at 48 kHz was refused: `Render produced 195216 frames but the requested range is 195254`. 38 frames -short — two orders of magnitude outside the gate's one-frame tolerance, so the tolerance -is not what refused it. +short — 38x the gate's one-frame tolerance, so the tolerance is not what refused it. **Hypothesis A — the render bounds itself to the media it can see.** REAPER's selected-items render source (`&32`) derives its bounds from the selected items' own @@ -739,3 +742,29 @@ numbered blocker steps: step 1 separates A's `&32` path from the shared `&128` p says how to tell when it failed to), step 2 asks whether the render is short at all, step 3 reads the retained refused render to place the missing frames. Nothing here should be "fixed" before that comes back. + +## Split `render_bounds_gate` on the verdict/message vs. filesystem seam + +**Context (Ψ-W3 round-two review).** `render_bounds_gate.cpp` mixes pure verdict +composition (frame-count comparison, message text) with filesystem I/O +(`retainRefusedRender`'s `fs::create_directories`/`fs::rename`) in one shell TU. The +verdict half has no REAPER dependency and no filesystem dependency either — it could be +`core/capture`, unit-tested directly instead of only through the pure `render_window` +functions it calls. The reviewer's suggested split: verdict + message composition pure +and testable in `core/capture`, leaving only `retainRefusedRender` (and the two thin +`checkRendered*` entry points that call it) in `shell/capture`. + +**Why deferred.** Out of scope for the dispatch that surfaced it — a structural split, +not the bug fix in front of it. + +**Filed also because it's already slightly wrong today.** `render_bounds_gate.cpp` +touches no REAPER API (it is `` + the pure `core/capture` modules only), so +`src/shell/capture/CLAUDE.md`'s "this directory is the REAPER API surface only" scope +line no longer describes it — one more small argument for eventually moving the +REAPER-free half to `core/capture`, separate from the untested-filesystem-code gap +above. + +**Done looks like.** `core/capture` owns a pure `checkRenderedBoundsVerdict`-shaped +function under a `_tests` target with no REAPER, no VST3 SDK, and no +filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and +the two callers' plumbing. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 6aa2e8a..c8e8bc0 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -27,9 +27,9 @@ Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, i - [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`) - [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`) - [ ] **Open blocker — root cause unknown; the three steps below are the experiment** (both live hypotheses and what is NOT yet excluded: `docs/TODO.md` §An offline capture can be refused for a short render). A live capture over [0.000000s, 4.067797s) was refused 38 frames short (195216 of 195254 at 48 kHz). Set View → time unit to Samples first -- [ ] **Step 1 — does the item-scope render bound itself to the media?** This only tests anything if item scope actually reaches REAPER's selected-items render, and it does that ONLY when the selected items' extent already equals the requested window (`itemExtentPrintsWindow`, `src/core/capture/render_window.h`); otherwise item scope re-sources through the items' own tracks — the same source track scope uses, so the two runs would test one thing twice. So: snap the time selection to the item's exact start and end, run **item** scope, then **track** scope over the identical range. Report both `Render source:` lines and both frame counts. **If both lines read `selected tracks via master`, the item path was NOT exercised** — the extents did not match; re-snap and repeat before concluding anything -- [ ] **Step 2 — full-length or short?** Extend the same range ~1 s past the end of all media, **track** scope. Landing with the full range (no refusal, the card reads the extended length) rules out BOTH a trailing-silence trim and a content-extent bound at once. A short render does NOT tell them apart: a trim firing despite `RENDER_NORMALIZE &(4<<16)` and a render bounding itself to content extent produce the same count. Report which happened, then run step 3 -- [ ] **Step 3 — where are the missing frames?** A refused render is kept deliberately, not deleted: it is moved to `/reasampler_refused/`. **Follow the path in the refusal line, not this sentence** — if the move itself failed the file stays in the bank folder, unindexed, and the line says which happened. Insert it against the source over the same range and report whether the head aligns. Frames missing from the TAIL with an aligned head fits either a tail trim or a content-extent bound; a head offset fits neither and is a start-position defect. Also report whether the media under the range ends before the range does. Delete `reasampler_refused/` when done — nothing in the bank references it +- [ ] **Step 1 — does the shortfall follow the render source?** This only tests anything if item scope actually reaches REAPER's selected-items render, and it does that ONLY when the selected items' extent already equals the requested window (`itemExtentPrintsWindow`, `src/core/capture/render_window.h`); otherwise item scope re-sources through the items' own tracks — the same source track scope uses, so the two runs would test one thing twice. So: snap the time selection to the item's exact start and end, run **item** scope, then **track** scope over the identical range. Report both `Render source:` lines and both frame counts. **If both lines read `selected tracks via master`, the item path was NOT exercised** — the extents did not match; re-snap and repeat before concluding anything. **A landing `&32` run here is not evidence `&32` honours custom bounds** — at a window snapped to the item's own extent, a render that honours the window and one that bounds itself to media content print IDENTICAL frames, so this step cannot tell those two apart; it only tells you which render source is in play. **If neither run refuses at this snapped range, the blocker did not reproduce here** — this range does not recreate the original refusal, which ran past the end of its media; move to step 2, which does +- [ ] **Step 2 — full-length or short?** Extend the same range ~1 s past the end of all media, **track** scope. Landing with the full range (no refusal, the card reads the extended length) rules out BOTH a trailing-silence trim and a content-extent bound at once — but it also means there is no refused render for step 3 to read; re-run the ORIGINAL refusing range ([0.000000s, 4.067797s), track scope) to produce one before continuing. A short render does NOT tell the two hypotheses apart: a trim firing despite `RENDER_NORMALIZE &(4<<16)` and a render bounding itself to content extent produce the same count — and that render IS the one step 3 reads. Report which happened, then run step 3 +- [ ] **Step 3 — where are the missing frames?** Reads the short render from step 2 (or, if step 2 landed, the fresh refused render from re-running the original range per step 2's note) — not anything step 1 may have left behind, since a correctly-snapped step 1 should not have refused at all. A refused render is kept deliberately, not deleted: it is moved to `/reasampler_refused/`. **Follow the path in the refusal line, not this sentence** — if the move itself failed the file stays in the bank folder, unindexed, and the line says which happened. Filenames carry a timestamp/counter but no scope marker, so if more than one file has landed in `reasampler_refused/` by now, the one from step 2 is the most recently written one — or empty the folder before running step 2 so there is only one candidate. Insert it against the source over the same range and report whether the head aligns. Frames missing from the TAIL with an aligned head fits either a tail trim or a content-extent bound; a head offset fits neither and is a start-position defect. Also report whether the media under the range ends before the range does. Delete `reasampler_refused/` when done — nothing in the bank references it ## Names and channels diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index d24e436..e32e642 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -8,8 +8,9 @@ namespace reasampler::capture { namespace { -// Round-to-nearest, so a position that sits mid-frame maps to the frame a render -// of it prints rather than to the frame below it. +// Round-to-nearest: the convention THIS module measures a window by, so a mid-frame +// position maps to the closer frame boundary rather than always down. Not a claim +// about how any renderer resolves that position -- see the header's caveat. long long frameIndexAt(double seconds, int sampleRate) { return std::llround(seconds * static_cast(sampleRate)); } diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 5fd5f22..37bbea9 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -52,7 +52,7 @@ detail not covered there: ## Modules - `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds. -- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never sees it either way. +- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened). - `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`. - `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`. - `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index d5089b4..6e7355a 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -497,6 +497,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) { return result; } + // A 0-byte render is refused on every tail mode, ahead of and independent from the + // TailMode::None-only bounds gate below — Auto/Manual add frames by design but never + // legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check, + // Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0). + const BoundsVerdict emptyVerdict = + checkRenderedFileNotEmpty(expectedPath, projectDir); + if (emptyVerdict.refused) { + result.status = CaptureStatus::BoundsMismatch; + result.message = emptyVerdict.message; + return result; + } + // Exact bounds, made structural: render_bounds_gate judges the landed file against // the requested window and owns what becomes of a render that fails. // (On TailMode::None the landed file is read three times on this path — that gate, diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 31714af..97f79fe 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -16,11 +16,13 @@ #include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "shell/panel/panel_input.h" // bankPanelRefresh #include "core/capture/batch_capture.h" // planCaptureUnits / BatchOutcome +#include "core/capture/capture_paths.h" // projectDirOfRpp #include "core/model/bank_book.h" // BankBook / Bank #include "core/model/provenance.h" // recipe parse/build, fingerprint #include "shell/persist/session.h" // ReaSamplerSession #include "shell/capture/capture_orchestrator.h" // captureAndIndexOne / renderOffline #include "shell/capture/provenance_shell.h" // fxChainIdentity* / trackByGuid +#include "shell/capture/render_bounds_gate.h" // refusedRenderFolder #include "shell/capture/scope_resolve.h" // ResolvedSource #include "shell/capture/track_guid.h" // guidString @@ -28,6 +30,7 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedMediaItems +#define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetSelectedMediaItem #define REAPERAPI_WANT_GetMediaItem_Track #define REAPERAPI_WANT_GetMediaItemInfo_Value @@ -95,6 +98,24 @@ private: std::vector selected_; }; +// Names where refused renders were retained, once, when the batch quarantined at +// least one (BoundsMismatch failures only -- a render that never produced a file has +// nothing to retain). Without this, a batch's per-unit failure detail (which DOES name +// the destination, same as a single capture's console line) never reaches the console +// at all -- the batch summary reports ordinals only. +std::string withQuarantineNote(std::string line, int quarantinedCount) { + if (quarantinedCount <= 0) return line; + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + const std::string dir = projectDirOfRpp(std::string(buf.data())); + if (dir.empty()) return line; // unreachable: a quarantine implies a saved project + line += " " + std::to_string(quarantinedCount) + " refused render" + + (quarantinedCount == 1 ? " was" : "s were") + " retained for diagnosis, " + "normally at " + refusedRenderFolder(dir) + " (delete when done) -- one " + "whose move there failed instead stays in the bank folder, unindexed."; + return line; +} + // Deselect-all then select-one so the offline render's &32 bit captures exactly // this item. Called inside ItemSelectionGuard, which restores the original selection. void selectOnlyItem(MediaItem* item) @@ -196,6 +217,7 @@ void RunBatchCaptureItems(ReaSamplerSession& session) BatchOutcome outcome; bool anyAdded = false; + int quarantined = 0; // BoundsMismatch failures, each of which retained a file { // selGuard restores the original item selection on every exit path. ItemSelectionGuard selGuard; @@ -232,6 +254,7 @@ void RunBatchCaptureItems(ReaSamplerSession& session) const bool ok = (res.status == CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; + else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined; } } // selGuard restores the original selection here, on every path @@ -243,7 +266,8 @@ void RunBatchCaptureItems(ReaSamplerSession& session) session.saveToActiveProject(); } - ShowConsoleMsg((outcome.summaryLine("item") + "\n").c_str()); + ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("item"), quarantined) + + "\n").c_str()); } // One sample per razor area, track scope over that area's own range. Track scope @@ -267,6 +291,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) BatchOutcome outcome; bool anyAdded = false; + int quarantined = 0; // BoundsMismatch failures, each of which retained a file { // selGuard restores the original track selection on every exit path. TrackSelectionGuard selGuard; @@ -298,6 +323,7 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) const bool ok = (res.status == CaptureStatus::Ok); outcome.record(unit.ordinal, ok, ok ? std::string{} : res.message); if (ok) anyAdded = true; + else if (res.status == CaptureStatus::BoundsMismatch) ++quarantined; } } // selGuard restores the original track selection here, on every path @@ -307,7 +333,8 @@ void RunBatchCaptureRazor(ReaSamplerSession& session) session.saveToActiveProject(); } - ShowConsoleMsg((outcome.summaryLine("razor area") + "\n").c_str()); + ShowConsoleMsg((withQuarantineNote(outcome.summaryLine("razor area"), quarantined) + + "\n").c_str()); } // Regenerates a provenanced sample's file from its recorded source's current state diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index 4b38d8a..9fffa26 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -3,6 +3,7 @@ #include "shell/capture/render_bounds_gate.h" #include +#include #include #include #include @@ -46,6 +47,24 @@ std::string retainRefusedRender(const std::string& renderedPath, } // namespace +std::string refusedRenderFolder(const std::string& projectDir) { + return projectDir + "/" + kRefusedSubfolder; +} + +BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, + const std::string& projectDir) { + BoundsVerdict v; + std::error_code ec; + const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec); + if (ec || size != 0) return v; // stat failure isn't this check's job — leave it be + + v.refused = true; + v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty " + "file, so there is nothing to check the requested range against." + + retainRefusedRender(renderedPath, projectDir); + return v; +} + BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const std::string& projectDir, const CaptureRequest& request) { @@ -66,10 +85,11 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, // a file whose frames were never counted. if (!layout.valid || layout.sampleRate == 0) { v.refused = true; - v.message = "Render at " + renderedPath + " could not be measured -- its WAV " - "header did not parse, or declared no sample rate -- so the frames " - "it holds were never checked against the requested range." + source + - retainRefusedRender(renderedPath, projectDir); + v.message = "Render at " + renderedPath + " could not be measured -- it could " + "not be read (locked, missing, or a permissions error), its WAV " + "header did not parse, or it declared no sample rate -- so the " + "frames it holds were never checked against the requested range." + + source + retainRefusedRender(renderedPath, projectDir); return v; } diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 18d0e59..27a4eb1 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -27,4 +27,15 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath, const std::string& projectDir, const CaptureRequest& request); +// A 0-byte render is refused on every tail mode (Auto/Manual add frames by design but +// never legitimately produce zero), independent of and ahead of the TailMode::None-only +// gate above, which does not run on Auto/Manual at all. +BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath, + const std::string& projectDir); + +// Where a refused render is retained -- exposed so a multi-unit caller (batch capture) +// can name the folder once without duplicating the subfolder name `checkRenderedBounds` +// and `checkRenderedFileNotEmpty` already use internally. +std::string refusedRenderFolder(const std::string& projectDir); + } // namespace reasampler::capture