diff --git a/src/capture.cpp b/src/capture.cpp index cadc44e..6d729f3 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -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 buf(4096, '\0'); - GetProjectPathEx(proj, buf.data(), static_cast(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(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 ---------------------------------------