fix(capture): detect unsaved project via EnumProjects .rpp filename

GetProjectPathEx returns REAPER's recording path (never empty); EnumProjects
returns the .rpp path (empty when unsaved) — the correct signal. Bank now
derives from the .rpp parent so it sits alongside the project file. Render
progress window documented as inherent to REAPER offline render.
This commit is contained in:
2026-07-22 18:45:07 -04:00
parent c7a8c75ef2
commit 09a85737a8
+73 -26
View File
@@ -6,9 +6,20 @@
//
// Scope (M3): ONE source mode — the time-selection master mix. Drives the
// RENDER_* project settings via GetSetProjectInfo / _String, snapshots and
// restores every setting it changes (non-destructive), triggers a no-dialog
// render, then populates a Sample. It NEVER inserts into the arrange
// (load-bearing principle).
// restores every setting it changes (non-destructive), triggers a render, then
// populates a Sample. It NEVER inserts into the arrange (load-bearing principle).
//
// RENDER PROGRESS WINDOW (Item 2 finding — not suppressible via stock API):
// Triggering kActionRenderUsingMostRecentSettings (42230) causes REAPER to show
// its offline-render progress dialog (progress bar + waveform view) for the
// duration of the render. The RENDER_SETTINGS bits documented in
// reaper_plugin_functions.h (line ~3041) contain no "no-dialog", "headless", or
// "suppress-progress-window" flag. No GetSetProjectInfo desc documents such a
// flag either. There is no stock, header-verifiable mechanism to prevent REAPER
// from showing this UI for an offline file render triggered via Main_OnCommand.
// This is inherent to REAPER's offline render path. The dialog-free alternative
// is the realtime-record backend (M8), which captures the master bus output to a
// temp track during playback and never invokes the offline render pipeline.
#include "capture.h"
@@ -22,7 +33,6 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjectPathEx
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_GetSetProjectInfo_String
#define REAPERAPI_WANT_GetSet_LoopTimeRange
@@ -242,44 +252,79 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
return result;
}
// Resolve the project directory. GetProjectPathEx writes the effective
// recording/project path (absolute). Verified: SDK header line ~2550,
// GetProjectPathEx(ReaProject*, char* bufOut, int bufOut_sz).
// Resolve the project directory from the .rpp file path.
//
// Unsaved-project guard: an active project that has never been saved has
// an empty path. In that state any bank folder resolution would either fall
// back to a CWD-relative directory (violating the relative-paths-only
// invariant) or write to REAPER's default media location — both are wrong.
// Instead we prompt the user to save, then re-query. If the save dialog is
// cancelled (path still empty), refuse and write nothing.
// Unsaved-project detection: we use EnumProjects(-1, buf, bufsz) to read
// the project's .rpp filename. Per SDK header line ~1262:
// EnumProjects(int idx, char* projfnOutOptional, int sz)
// "idx=-1 for current project, projfn can be NULL if not interested in filename."
// The out-parameter is the full path to the .rpp file, and is EMPTY for a
// project that has never been saved — making it a reliable unsaved sentinel.
//
// WHY NOT GetProjectPathEx: that function returns the project *recording path*
// (SDK header line ~2548: "Get the project recording path."), NOT the .rpp
// location. For an unsaved project it returns REAPER's default media/recording
// directory — never empty — so it cannot detect the unsaved state. Using it
// caused the original bug: the guard never fired, and captures landed in
// REAPER's default media location rather than alongside the .rpp.
//
// WHY NOT GetProjectPathEx for the saved-project dir: even for a saved project,
// GetProjectPathEx returns the recording path (which may be a media subfolder),
// not the .rpp parent directory. We need the .rpp parent so reasampler_bank/
// sits alongside the .rpp and travels with the project.
//
// FLOW:
// 1. Read .rpp path via EnumProjects(-1, buf, bufsz).
// 2. If non-empty (saved) -> derive project dir as parent of the .rpp.
// 3. If empty (unsaved) -> Main_SaveProject(proj, true) prompts Save-As.
// Re-read. If now non-empty -> proceed. If still empty (user cancelled) ->
// refuse CaptureStatus::NoProject, write nothing.
//
// DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save/Save-As
// dialog and blocks until the user dismisses it. "true" means forceSaveAsIn
// — verified SDK header line ~4599:
// dialog and blocks until the user dismisses it. "true" = forceSaveAsIn.
// Verified SDK header line ~4599:
// void Main_SaveProject(ReaProject* proj, bool forceSaveAsInOptional)
// The blocking behaviour and dialog appearance can only be confirmed in a
// running REAPER.
auto resolveProjectDir = [&]() -> std::string {
auto readRppPath = [&]() -> std::string {
std::vector<char> buf(4096, '\0');
GetProjectPathEx(proj, buf.data(), static_cast<int>(buf.size()));
// EnumProjects(-1, ...) returns the active project and writes the .rpp
// path into buf. We already have the ReaProject* from the earlier call
// (nullptr-checked above), but calling EnumProjects again is the only
// stock, header-documented way to read the .rpp filename.
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return std::string(buf.data());
};
std::string projectDir = resolveProjectDir();
if (projectDir.empty()) {
// Prompt the user to save so the project acquires a path.
std::string rppPath = readRppPath();
if (rppPath.empty()) {
// Project is unsaved. Prompt the user to choose a save location.
Main_SaveProject(proj, true);
// Re-query: if the dialog was confirmed the path is now set; if the
// user cancelled it is still empty.
projectDir = resolveProjectDir();
// Re-read: non-empty if the user confirmed, still empty if cancelled.
rppPath = readRppPath();
}
if (projectDir.empty()) {
if (rppPath.empty()) {
// User cancelled the save dialog — refuse, write nothing.
result.status = CaptureStatus::NoProject;
result.message = "Project must be saved before capture — nothing captured.";
return result;
}
// Derive the project directory as the parent folder of the .rpp file.
// std::filesystem::path handles both forward- and back-slash paths; .parent_path()
// gives the containing directory. Convert to forward-slash string so the rest
// of the capture pipeline (deriveBankPaths, RENDER_FILE) sees a clean path.
const std::string projectDir = [&]() -> std::string {
namespace fs = std::filesystem;
std::string dir = fs::path(rppPath).parent_path().string();
// normalizeSlashes is in capture_paths (pure); replicate the transform
// inline here to avoid a cross-module dependency for a one-liner.
for (char& c : dir) { if (c == '\\') c = '/'; }
// Strip a single trailing slash (defensive; parent_path usually omits it).
if (dir.size() > 1 && dir.back() == '/') dir.pop_back();
return dir;
}();
// Compute the unique tag ONCE so the file stem and Sample.id carry the same
// timestamp. Calling makeUniqueTag() twice could yield different values if a
// second boundary crosses between the two calls (bug: id and filename diverge).
@@ -363,9 +408,11 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
}
setProjString(proj, "RENDER_FORMAT", fmtBase64);
// --- Trigger the headless render ----------------------------------------
// --- Trigger the render -------------------------------------------------
// DAW-ONLY ASSUMPTION (see kActionRenderUsingMostRecentSettings): this runs
// the render synchronously with no dialog on the current build.
// the render synchronously on the current build. REAPER will show its
// offline-render progress window for the duration (see file-top comment —
// the progress UI is not suppressible via stock API).
Main_OnCommand(kActionRenderUsingMostRecentSettings, 0);
// --- Verify the output file exists ---------------------------------------