capture: close batch-quarantine silence, 0-byte asymmetry, and round-two doc overclaims

Batch captures now name the retained-render folder once instead of nothing; Auto/Manual tail modes refuse a 0-byte render like None does; VERIFICATION.md steps 1-3 no longer invite a false conclusion; docs/comments no longer overclaim.
This commit is contained in:
2026-08-02 08:00:12 -04:00
parent 2005f90c66
commit 0511d16d4f
10 changed files with 127 additions and 26 deletions
+1 -1
View File
@@ -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 `<projectDir>/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 `<projectDir>/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`).
+12
View File
@@ -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,
+29 -2
View File
@@ -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<MediaItem*> 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<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(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
+24 -4
View File
@@ -3,6 +3,7 @@
#include "shell/capture/render_bounds_gate.h"
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <system_error>
#include <vector>
@@ -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;
}
+11
View File
@@ -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