Merge M3 fix: unsaved-project detection via .rpp filename

This commit is contained in:
2026-07-22 18:47:39 -04:00
+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 ---------------------------------------