// SPDX-License-Identifier: Apache-2.0
//
// stitcher.cpp — shared cv::Stitcher orchestration.  See stitcher.hpp
// for design rationale.
//
// V1 (2026-05-15): ported from image_stitcher_jni.cpp (Android JNI
// shim) verbatim with the platform-specific Obj-C / JNI marshalling
// stripped.  Both platforms now call this through thin bridges.
//
// V2 (planned): port iOS's manual cv::detail::* pipeline features
// (explicit leaveBiggestComponent at a SEPARATE retry granularity,
// wave correction, exposure compensator) so iOS doesn't regress
// from where OpenCVStitcher.mm had it.  Selectable via a future
// StitchConfig::useManualPipeline flag.

#include "stitcher.hpp"

#include <opencv2/core.hpp>
#include <opencv2/features2d.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/stitching.hpp>
#include <opencv2/stitching/detail/blenders.hpp>
#include <opencv2/stitching/detail/camera.hpp>
#include <opencv2/stitching/detail/exposure_compensate.hpp>
#include <opencv2/stitching/detail/matchers.hpp>
#include <opencv2/stitching/detail/motion_estimators.hpp>
#include <opencv2/stitching/detail/seam_finders.hpp>
#include <opencv2/stitching/detail/warpers.hpp>
#include <opencv2/stitching/warpers.hpp>

#include <algorithm>
#include <atomic>
#include <chrono>
#include <cfloat>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <functional>
#include <string>
#include <thread>
#include <mutex>
#include <unistd.h>
#include <vector>

#include "stitcher_ladder.hpp"
#include "warp_guard.hpp"

// RNIS_MEMORY_PROFILING is defined in stitcher.hpp (shared across the 3 native
// translation units).  kMemProfilingCompiled exposes it as a constexpr below.


namespace retailens {

namespace {

// Lightweight logging helper.  When logFn is null, the message is
// dropped (no allocation past the snprintf temp buffer).
void log_info(const LogFn& logFn, const char* tag, const char* fmt, ...) {
    if (!logFn) return;
    char buf[1024];
    va_list ap;
    va_start(ap, fmt);
    std::vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    logFn(0, tag, buf);
}

void log_error(const LogFn& logFn, const char* tag, const char* fmt, ...) {
    if (!logFn) return;
    char buf[1024];
    va_list ap;
    va_start(ap, fmt);
    std::vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    logFn(2, tag, buf);
}

// Compile-time gate as a constexpr (so dead branches fold away in release).
constexpr bool kMemProfilingCompiled = (RNIS_MEMORY_PROFILING != 0);

// Per-stitch resident-memory probe, installed for the duration of a stitch by
// MemProbeScope below.  Lets rss_mb() (and therefore every OOM guard, phase log,
// the peak sampler and the per-stitch record) read the platform's real source
// without threading `config` through dozens of call sites.  Calls are serialized
// (stitchFramePaths contract), and the sampler thread only reads it within the
// scope's lifetime, so a plain file-static is safe.
std::function<double()> g_memProbe;

// Read /proc/self/statm to get RSS in MB.  Cheap (~20 µs).  Returns -1 when
// procfs is absent — which is the case on iOS (no /proc), so this is the Android
// path; iOS supplies g_memProbe instead.
double rss_mb_proc() {
    FILE* f = std::fopen("/proc/self/statm", "r");
    if (f == nullptr) return -1.0;
    long size_pages = 0, resident_pages = 0;
    int n = std::fscanf(f, "%ld %ld", &size_pages, &resident_pages);
    std::fclose(f);
    if (n != 2) return -1.0;
    long page_bytes = sysconf(_SC_PAGESIZE);
    return (double) resident_pages * (double) page_bytes / (1024.0 * 1024.0);
}

// Effective resident-memory reader (MB).  Prefers the installed probe (iOS
// phys_footprint), else /proc (Android).  -1 when neither is available.  Used at
// pipeline phase boundaries + by the OOM guards — making the guards work on iOS,
// where they previously got -1 (the runtime-pressure router was dead).
double rss_mb() {
    if (g_memProbe) {
        const double v = g_memProbe();
        if (v >= 0.0) return v;
    }
    return rss_mb_proc();
}

// Which source rss_mb() is currently resolving to — for the per-stitch record.
const char* mem_source_label() {
    if (g_memProbe && g_memProbe() >= 0.0) return "phys_footprint";
    if (rss_mb_proc() >= 0.0) return "rss";
    return "";
}

// RAII: install/uninstall the resident-memory probe for one stitch.
struct MemProbeScope {
    explicit MemProbeScope(std::function<double()> probe) { g_memProbe = std::move(probe); }
    ~MemProbeScope() { g_memProbe = nullptr; }
    MemProbeScope(const MemProbeScope&) = delete;
    MemProbeScope& operator=(const MemProbeScope&) = delete;
};

// DRY [memstat] phase log — gated, and (unlike the old inline form) skips the
// rss_mb() read entirely when profiling is off, so release pays nothing.
void log_memstat(const LogFn& logFn, bool enabled, const char* phase) {
    if (!enabled || !logFn) return;
    char buf[80];
    std::snprintf(buf, sizeof(buf), "phase=%s rss=%.1f MB", phase, rss_mb());
    logFn(0, "[memstat]", buf);
}

// Background peak-memory sampler.  Wakes every ~50 ms during a stitch and tracks
// the max resident memory into `peak` — the ONLY way to catch the transient
// warp-all + GraphCut + MultiBand spike, which the phase-boundary reads (taken
// after the blender frees its pyramids) systematically miss.  RAII: the thread
// starts in the ctor (when active) and is joined in stop()/dtor.  It is a pure
// memory reader, not OpenCV work, so cv::setNumThreads(1) does not constrain it.
class PeakSampler {
public:
    PeakSampler(bool active, std::atomic<double>& peak) : peak_(peak), active_(active) {
        if (!active_) return;
        thread_ = std::thread([this]() {
            while (!stop_.load(std::memory_order_relaxed)) {
                const double v = rss_mb();
                double cur = peak_.load(std::memory_order_relaxed);
                while (v > cur &&
                       !peak_.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {}
                // Sleep in 10 ms slices so stop() is responsive (~50 ms cadence).
                for (int i = 0; i < 5 && !stop_.load(std::memory_order_relaxed); ++i)
                    std::this_thread::sleep_for(std::chrono::milliseconds(10));
            }
        });
    }
    void stop() {
        if (!active_) return;
        stop_.store(true, std::memory_order_relaxed);
        if (thread_.joinable()) thread_.join();
        active_ = false;
    }
    ~PeakSampler() { stop(); }
    PeakSampler(const PeakSampler&) = delete;
    PeakSampler& operator=(const PeakSampler&) = delete;
private:
    std::atomic<double>& peak_;
    std::atomic<bool>    stop_{false};
    std::thread          thread_;
    bool                 active_;
};

// Total physical RAM in MB, read natively.  The Android JNI bridge sets no
// availableRamMB, so without this a 6 GB device is mis-treated as the 4 GB
// fallback (and the step-7.7 canvas budget + pre-stitch abort under-size).
// _SC_PHYS_PAGES is TOTAL and stable across runs (unlike _SC_AVPHYS_PAGES,
// which is free RAM and varies).  Returns -1.0 off Linux/Android (e.g. the
// macOS cpp-test host); the caller resolves the sentinel.
double device_total_ram_mb() {
#if defined(__linux__)
    const long pages = sysconf(_SC_PHYS_PAGES);
    const long page_bytes = sysconf(_SC_PAGESIZE);
    if (pages <= 0 || page_bytes <= 0) return -1.0;
    return (double) pages * (double) page_bytes / (1024.0 * 1024.0);
#else
    return -1.0;
#endif
}

double mat_mb(const cv::Mat& m) {
    if (m.empty()) return 0.0;
    return (double)(m.total() * m.elemSize()) / (1024.0 * 1024.0);
}

// Map string → cv::WarperCreator.  Returns nullptr for unknown names
// (caller falls back to PlaneWarper for SCANS mode anyway).
cv::Ptr<cv::WarperCreator> make_warper(const std::string& name) {
    if (name == "plane")       return cv::makePtr<cv::PlaneWarper>();
    if (name == "cylindrical") return cv::makePtr<cv::CylindricalWarper>();
    if (name == "spherical")   return cv::makePtr<cv::SphericalWarper>();
    return nullptr;
}

cv::Ptr<cv::detail::Blender> make_blender(const std::string& name) {
    if (name == "feather") {
        return cv::detail::Blender::createDefault(cv::detail::Blender::FEATHER, false);
    }
    return cv::detail::Blender::createDefault(cv::detail::Blender::MULTI_BAND, false);
}

cv::Ptr<cv::detail::SeamFinder> make_seam_finder(const std::string& name) {
    if (name == "skip" || name == "no") {
        return cv::makePtr<cv::detail::NoSeamFinder>();
    }
    if (name == "voronoi") {
        return cv::makePtr<cv::detail::VoronoiSeamFinder>();
    }
    return cv::makePtr<cv::detail::GraphCutSeamFinder>(
        cv::detail::GraphCutSeamFinder::COST_COLOR_GRAD);
}

// Bake an output rotation per the capture orientation.  Rotation
// table mirrors OpenCVStitcher.mm and the previous
// image_stitcher_jni.cpp — kept verbatim so behaviour is unchanged.
cv::Mat bake_rotation(const cv::Mat& src, const std::string& orientation,
                      const LogFn& logFn) {
    cv::Mat rotated;
    if (orientation == "landscape-left") {
        cv::rotate(src, rotated, cv::ROTATE_90_COUNTERCLOCKWISE);
        log_info(logFn, "[stitch]",
                 "bake-rotated 90° CCW for landscape-left (%dx%d → %dx%d)",
                 src.cols, src.rows, rotated.cols, rotated.rows);
        return rotated;
    }
    if (orientation == "landscape-right") {
        cv::rotate(src, rotated, cv::ROTATE_90_CLOCKWISE);
        log_info(logFn, "[stitch]",
                 "bake-rotated 90° CW for landscape-right (%dx%d → %dx%d)",
                 src.cols, src.rows, rotated.cols, rotated.rows);
        return rotated;
    }
    if (orientation == "portrait-upside-down") {
        cv::rotate(src, rotated, cv::ROTATE_180);
        log_info(logFn, "[stitch]",
                 "bake-rotated 180° for portrait-upside-down (%dx%d)",
                 src.cols, src.rows);
        return rotated;
    }
    log_info(logFn, "[stitch]", "no bake-rotation (orientation=%s, %dx%d)",
             orientation.c_str(), src.cols, src.rows);
    return src;
}

// Map cv::Stitcher::Status → StitchErrorCode.  cv::Stitcher's enum
// values aren't documented as ABI-stable so we don't rely on
// numeric equality; switch through the named constants.
StitchErrorCode statusToErrorCode(cv::Stitcher::Status status) {
    switch (status) {
        case cv::Stitcher::OK:                            return StitchErrorCode::Ok;
        case cv::Stitcher::ERR_NEED_MORE_IMGS:            return StitchErrorCode::NeedMoreImages;
        case cv::Stitcher::ERR_HOMOGRAPHY_EST_FAIL:       return StitchErrorCode::HomographyEstimationFailed;
        case cv::Stitcher::ERR_CAMERA_PARAMS_ADJUST_FAIL: return StitchErrorCode::CameraParamsAdjustFailed;
        default:                                          return StitchErrorCode::UnknownCvException;
    }
}

// V16 Phase 1b.fix3 — find the largest axis-aligned rectangle that
// fits ENTIRELY inside the non-zero region of `mask` (CV_8UC1).
// Used to crop the post-stitch panorama tightly: the regular
// boundingRect of non-zero pixels still includes the black corners
// where the projection didn't fill; the max-inscribed rectangle
// excludes those entirely.
//
// Algorithm: maximum-rectangle-in-histogram swept row by row.
// O(W * H).  For a 4-6 MP panorama on iPhone 16 Pro, completes in
// 30-60 ms.
//
// Returns cv::Rect(0,0,0,0) if `mask` is empty or fully zero.
cv::Rect maxInscribedRectFromMask(const cv::Mat& mask) {
    if (mask.empty() || mask.type() != CV_8UC1) {
        return cv::Rect();
    }
    const int H = mask.rows;
    const int W = mask.cols;

    // Per-column running heights of consecutive non-zero pixels
    // ending at the current row.
    std::vector<int> heights((size_t)W, 0);
    cv::Rect bestRect(0, 0, 0, 0);
    long long bestArea = 0;

    // Reusable monotonic stack for the row's largest-rectangle-in-
    // histogram subroutine.
    std::vector<int> stack;
    stack.reserve((size_t)W + 1);

    for (int row = 0; row < H; ++row) {
        const uchar* m = mask.ptr<uchar>(row);
        for (int col = 0; col < W; ++col) {
            heights[(size_t)col] =
                (m[col] != 0) ? heights[(size_t)col] + 1 : 0;
        }

        // Largest rectangle in the histogram for this row.
        stack.clear();
        for (int col = 0; col <= W; ++col) {
            const int h = (col == W) ? 0 : heights[(size_t)col];
            while (!stack.empty()
                   && heights[(size_t)stack.back()] > h) {
                const int topIdx = stack.back();
                stack.pop_back();
                const int leftIdx =
                    stack.empty() ? -1 : stack.back();
                const int width = col - leftIdx - 1;
                const long long area =
                    (long long)heights[(size_t)topIdx]
                    * (long long)width;
                if (area > bestArea) {
                    bestArea = area;
                    bestRect = cv::Rect(
                        leftIdx + 1,
                        row - heights[(size_t)topIdx] + 1,
                        width,
                        heights[(size_t)topIdx]
                    );
                }
            }
            stack.push_back(col);
        }
    }
    return bestRect;
}

// Issue 3 — post-stitch output validator.  The confidence filter drops
// frames that don't REGISTER, but nothing validated the final OUTPUT: a
// frame that survived confidence yet landed geometrically disconnected
// shows up as a separate blob in the coverage mask (the "disjointed image
// frames in the output" users reported).  Run connected-components on the
// coverage mask; if a meaningful fraction of the covered area lies OUTSIDE
// the largest blob, reject the stitch as LowQualityStitch so the host can
// prompt a retry rather than ship a broken panorama.
//
// Conservative by design: a coherent panorama is ONE connected blob, so a
// good capture never trips; the threshold lives in the pure, unit-tested
// retailens::stitchOutputIsDisjoint.  A small morphological close first
// bridges sub-pixel seam gaps so a single panorama isn't mis-split, while
// being far too small to merge a genuinely-detached floating frame.
//
// Fails OPEN: an empty/unreadable mask returns Ok (never block a capture on
// a mask we couldn't analyse).
//
// `singleFrameAreaPx` (2026-08-17, third check) is one input frame's area AT
// COMPOSE SCALE (same pixel space as the coverage mask); <= 0 disables the
// collapsed-placement check — see retailens::stitchOutputCollapsed.
StitchErrorCode validateStitchOutput(const cv::Mat& panorama,
                                     const cv::Mat& coverage,
                                     int numFrames,
                                     double singleFrameAreaPx,
                                     const LogFn& logFn,
                                     std::string& outMessage) {
    if (panorama.empty()) return StitchErrorCode::Ok;  // handled elsewhere
    // Build a binary coverage mask (same posture as choose_crop_rect).
    cv::Mat mask;
    const bool haveCoverage =
        (!coverage.empty() && coverage.size() == panorama.size());
    if (haveCoverage) {
        cv::Mat cov1 = coverage;
        if (coverage.channels() != 1) {
            cv::cvtColor(coverage, cov1, cv::COLOR_BGR2GRAY);
        }
        cv::threshold(cov1, mask, 0, 255, cv::THRESH_BINARY);
    } else {
        cv::Mat gray;
        cv::cvtColor(panorama, gray, cv::COLOR_BGR2GRAY);
        cv::threshold(gray, mask, 0, 255, cv::THRESH_BINARY);
    }
    if (mask.empty() || mask.type() != CV_8UC1) return StitchErrorCode::Ok;
    // Bridge thin seam gaps so a coherent pano isn't mis-split; the 5 px
    // kernel is far smaller than the gap a detached frame leaves, so
    // genuinely-separate blobs are NOT merged.
    cv::Mat closed;
    cv::morphologyEx(
        mask, closed, cv::MORPH_CLOSE,
        cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5, 5)));
    cv::Mat labels, stats, centroids;
    const int n = cv::connectedComponentsWithStats(
        closed, labels, stats, centroids, 8, CV_32S);
    double totalArea = 0.0, largestArea = 0.0;
    for (int i = 1; i < n; i++) {  // skip background label 0
        const double a = stats.at<int>(i, cv::CC_STAT_AREA);
        totalArea += a;
        if (a > largestArea) largestArea = a;
    }
    const double fragmentFraction =
        (totalArea > 0.0) ? (1.0 - largestArea / totalArea) : 0.0;
    log_info(logFn, "[stitch-bc]",
             "step11d: validate output components=%d largest=%.0f total=%.0f "
             "fragment=%.3f frames=%d",
             n - 1, largestArea, totalArea, fragmentFraction, numFrames);
    if (retailens::stitchOutputIsDisjoint(largestArea, totalArea, numFrames)) {
        char buf[176];
        std::snprintf(buf, sizeof(buf),
                      "stitch validation failed: disjoint output (%d "
                      "components, %.0f%% of coverage outside the main frame)",
                      n - 1, fragmentFraction * 100.0);
        outMessage = buf;
        return StitchErrorCode::LowQualityStitch;
    }
    // Utilization guard — the "black canvas".  A coherent blob marooned in a
    // mostly-empty canvas passes the disjoint check above (one blob), so guard
    // the coverage-to-canvas ratio too (mask is the full panorama size).
    {
        const double canvasArea = (double)mask.cols * (double)mask.rows;
        if (retailens::stitchOutputUnderutilized(totalArea, canvasArea,
                                                 numFrames)) {
            const double util = canvasArea > 0.0 ? totalArea / canvasArea : 0.0;
            log_info(logFn, "[stitch-bc]",
                     "step11d: REJECT degenerate canvas — utilization=%.3f%% "
                     "(content %.0f / canvas %dx%d)",
                     util * 100.0, totalArea, mask.cols, mask.rows);
            char buf[200];
            std::snprintf(buf, sizeof(buf),
                          "stitch validation failed: degenerate canvas "
                          "(content fills %.2f%% of the %dx%d panorama)",
                          util * 100.0, mask.cols, mask.rows);
            outMessage = buf;
            return StitchErrorCode::LowQualityStitch;
        }
    }
    // Collapsed-placement guard (2026-08-17) — the scans@0.3 failure shape:
    // N motion-gated keyframes squeezed into barely more area than ONE frame
    // (722×718 canvas from a 10-frame pan) is one coherent, well-utilized
    // blob, so BOTH checks above pass it.  Compare total coverage against
    // the minimum area N distinct keyframes can legitimately occupy; the
    // threshold lives in the pure, unit-tested retailens::
    // stitchOutputCollapsed.  Disabled when the caller can't supply a
    // compose-scale frame area (singleFrameAreaPx <= 0).
    if (retailens::stitchOutputCollapsed(totalArea, singleFrameAreaPx,
                                         numFrames)) {
        char buf[200];
        std::snprintf(buf, sizeof(buf),
                      "stitch validation failed: collapsed placement "
                      "(coverage %.0f px < %.0f px minimum for %d frames of "
                      "%.0f px each)",
                      totalArea,
                      singleFrameAreaPx
                          * (1.0 + retailens::kMinCoverageGrowthPerFrame
                                       * (numFrames - 1)),
                      numFrames, singleFrameAreaPx);
        outMessage = buf;
        log_info(logFn, "[stitch-bc]",
                 "step11d: REJECT collapsed placement — %s", buf);
        return StitchErrorCode::LowQualityStitch;
    }
    return StitchErrorCode::Ok;
}

// Pick the crop rectangle. Prefers the TRUE coverage mask from
// cv::Stitcher::resultMask() (0xFF where a frame painted, 0 where
// unfilled) so dark content is kept and only the never-covered wedges
// drop; falls back to a brightness mask if resultMask wasn't populated
// (older/edge OpenCV configs). `maskOut` receives the binary mask
// actually used, so the caller can crop a coverage sidecar to match.
cv::Rect choose_crop_rect(const cv::Mat& panorama,
                          const cv::Mat& coverage,
                          bool useInscribed,
                          const LogFn& logFn,
                          cv::Mat& maskOut) {
    const bool haveCoverage =
        (!coverage.empty() && coverage.size() == panorama.size());
    cv::Mat mask;
    if (haveCoverage) {
        cv::Mat cov1 = coverage;
        if (coverage.channels() != 1) {
            cv::cvtColor(coverage, cov1, cv::COLOR_BGR2GRAY);
        }
        // Any painted pixel (>0) is "filled" — robust to feathered edges.
        cv::threshold(cov1, mask, 0, 255, cv::THRESH_BINARY);
    } else {
        log_info(logFn, "[crop]",
                 "resultMask unusable (empty=%d size=%dx%d vs pano %dx%d) — "
                 "brightness-mask fallback",
                 coverage.empty() ? 1 : 0, coverage.cols, coverage.rows,
                 panorama.cols, panorama.rows);
        cv::Mat gray;
        cv::cvtColor(panorama, gray, cv::COLOR_BGR2GRAY);
        cv::threshold(gray, mask, 0, 255, cv::THRESH_BINARY);
    }
    maskOut = mask;

    const cv::Rect bbox = cv::boundingRect(mask);
    if (bbox.width <= 0 || bbox.height <= 0) {
        return cv::Rect(0, 0, panorama.cols, panorama.rows);
    }
    if (!useInscribed) {
        return bbox;
    }
    const cv::Rect inscribed = maxInscribedRectFromMask(mask);
    if (inscribed.width <= 0 || inscribed.height <= 0) {
        log_info(logFn, "[crop]",
                 "inscribed rect empty — bbox fallback (%dx%d)",
                 bbox.width, bbox.height);
        return bbox;
    }
    log_info(logFn, "[crop]",
             "inscribed %dx%d @ (%d,%d) via %s mask (bbox was %dx%d)",
             inscribed.width, inscribed.height, inscribed.x, inscribed.y,
             haveCoverage ? "coverage" : "brightness",
             bbox.width, bbox.height);
    return inscribed;
}

}  // namespace


// Forward declaration — body is the renamed inner entry point further
// down.  The public `stitchFramePaths` wraps this with the flattened
// 4-rung mode/threshold ladder (2026-08-17; manual opt-ins keep the
// 2026-05-22 opposite-mode fallback instead).
static StitchResult stitchFramePathsImpl_(
    const std::vector<std::string>& framePaths,
    const std::string&              outputPath,
    const StitchConfig&             config,
    LogFn                           logFn);


// ─────────────────────────────────────────────────────────────────────
// Degenerate-warp guard helpers (shared by every throw site in the manual
// pipeline's warp/compose stage).  Centralising them keeps the error
// MESSAGE consistent across the four sites — the JS host classifies a
// stitch failure by substring (see src/camera/classifyStitchError.ts /
// cameraErrorMessages.ts → STITCH_CAMERA_PARAMS_FAIL "Please pan more
// slowly"), so every degenerate-warp throw MUST carry "degenerate camera
// params" + the stitchMode.  Both predicates live in cpp/warp_guard.hpp
// (OpenCV-free + unit-tested); these builders add only the message + the
// cv::Exception envelope.
// ─────────────────────────────────────────────────────────────────────

// Per-frame divergence: ONE warped frame's ROI exceeds kMaxWarpPixels
// (broken estimator/BA on degenerate input — low feature count, near-
// duplicate frames, motion-blurred rapid pan).  stitchMode tells you which
// pipeline diverged: PANORAMA usually fails on translation-heavy input
// (homography + BA-Ray assume pure rotation); SCANS on low-texture / low-
// overlap input (affine needs enough matches).
static cv::Exception degenerateFrameException(
    int width, int height, StitchMode mode, size_t frameIdx) {
  const char* modeStr =
      (mode == StitchMode::Scans) ? "scans" : "panorama";
  return cv::Exception(
      cv::Error::StsOutOfRange,
      std::string("warpRoi too large (") + std::to_string(width) + "x"
          + std::to_string(height)
          + ") — estimator produced degenerate camera params on this frame "
          + "(stitchMode=" + modeStr + ", frameIdx="
          + std::to_string(frameIdx) + ")",
      "stitchFramePathsManual", __FILE__, __LINE__);
}

// Cumulative-canvas divergence: every per-frame ROI passed, but the UNION
// bounding box that blender->prepare() allocates exceeds kMaxCanvasPixels
// (a degenerate corner OFFSET blows the union to gigapixels while each
// frame's own extent stays small).  This is the real crash-B net.
static cv::Exception degenerateCanvasException(
    int64_t width, int64_t height, StitchMode mode, size_t frames) {
  const char* modeStr =
      (mode == StitchMode::Scans) ? "scans" : "panorama";
  return cv::Exception(
      cv::Error::StsOutOfRange,
      std::string("panorama canvas too large (") + std::to_string(width)
          + "x" + std::to_string(height)
          + ") — estimator produced degenerate camera params across the "
          + "frame set (stitchMode=" + modeStr + ", frames="
          + std::to_string(frames) + ")",
      "stitchFramePathsManual", __FILE__, __LINE__);
}

// Bounding box over every positioned warp rect (corner + size) — exactly
// what cv::detail::Blender::prepare() allocates as its CV_16SC3 canvas.
// Computed in int64 so a degenerate corner offset (which can exceed the
// int32 range on its own) doesn't overflow before canvasExceedsGuard()
// gets to inspect it.  Yields 0×0 for an empty frame set.
static void blendCanvasUnion(const std::vector<cv::Point>& corners,
                             const std::vector<cv::Size>&  sizes,
                             int64_t& unionW, int64_t& unionH) {
  if (corners.empty()) { unionW = 0; unionH = 0; return; }
  // Seed from frame 0 (avoids any sentinel / <climits> dependency).
  int64_t minX = corners[0].x;
  int64_t minY = corners[0].y;
  int64_t maxX = static_cast<int64_t>(corners[0].x) + sizes[0].width;
  int64_t maxY = static_cast<int64_t>(corners[0].y) + sizes[0].height;
  for (size_t i = 1; i < corners.size(); i++) {
    const int64_t x0 = corners[i].x;
    const int64_t y0 = corners[i].y;
    const int64_t x1 = x0 + sizes[i].width;
    const int64_t y1 = y0 + sizes[i].height;
    if (x0 < minX) minX = x0;
    if (y0 < minY) minY = y0;
    if (x1 > maxX) maxX = x1;
    if (y1 > maxY) maxY = y1;
  }
  unionW = maxX - minX;
  unionH = maxY - minY;
}


StitchResult stitchFramePaths(
    const std::vector<std::string>& framePaths,
    const std::string&              outputPath,
    const StitchConfig&             config,
    LogFn                           logFn)
{
    // ── perf-3b item 1 — serialize entries + restore parallelism (ANDROID)
    // ANDROID-ONLY, and deliberately so.  On Android, finalize (workExecutor)
    // and refine (refineExecutor) are SEPARATE dedicated threads, and
    // cv::setNumThreads below is a PROCESS-GLOBAL set per-call, so two
    // concurrent entries here would race on that global (+ the parallel-
    // backend pools).  A process-wide mutex held for the whole stitch
    // serializes them.  stitchFramePathsManual()/stitchFramePathsImpl_() are
    // reached ONLY from here (internal routing) → NOT locked, so no self-
    // deadlock (std::mutex, non-recursive, is correct).  This does NOT block
    // Android ingest: consumeFrameFromPlugin/ingestFromARCameraView run the
    // gate + JPEG encode synchronously on the vision-camera producer thread
    // (no workExecutor dispatch), so they never call stitchFramePaths.
    //
    // NOT applied on iOS: it drives independent GCD queues (workQueue vs
    // refineQueue) BY DESIGN — a 2-5s refine must not gate the next capture's
    // ingest (which finalize shares via workQueue.sync).  iOS never mutates a
    // global here (GCD backend; no setNumThreads), so there is nothing to
    // serialize and a mutex would only re-couple those queues.
    //
    // setNumThreads: v0.16.1 pinned it to 1 to stop a ~7-9 MB/stitch native-
    // heap creep (OpenCV/TBB per-worker TLS scratch, re-primed as the calling
    // thread MIGRATED across Kotlin's Dispatchers.Default pool).  The stitch
    // now runs on a STABLE dedicated thread, so that TLS primes ONCE per
    // process — the creep is bounded (verified: RSS plateaus across N stitches)
    // and multi-threading is safe again.  config.numThreads: 0 = AUTO
    // (min(4, max(2, cores/2))), 1 = single kill-switch, N = explicit.  Set
    // per-call under the lock (no restore needed — every entry sets it, and
    // entries are serialized).  iOS keeps its GCD backend untouched.
#if defined(__ANDROID__)
    static std::mutex s_stitchNativeMutex;
    std::lock_guard<std::mutex> stitchNativeLock(s_stitchNativeMutex);
    {
        int nt = config.numThreads;
        if (nt <= 0) {
            const unsigned hc = std::thread::hardware_concurrency();
            const int cores = (hc > 0u) ? static_cast<int>(hc) : 4;
            nt = std::min(4, std::max(2, cores / 2));
        }
        cv::setNumThreads(nt);
    }
#endif

    // ── 2026-06-16 — per-stitch memory profiling (DEV; gated) ───────────
    // Install the resident-memory probe (iOS phys_footprint; null on Android →
    // /proc) for the whole stitch — including the retry + the high-level/manual
    // impls below — so the OOM guards, phase logs, peak sampler + record all read
    // the right source.  The sampler runs across both attempts (peak = the worse
    // of the two, which is the conservative OOM number).  `finish()` stops the
    // sampler + stamps the record onto whichever result we return.
    const bool memProfiling = kMemProfilingCompiled && config.enableMemoryProfiling;
    MemProbeScope memProbe(config.memProbeFn);
    const std::string memSource =
        memProfiling ? std::string(mem_source_label()) : std::string();
    const double memBefore = memProfiling ? rss_mb() : -1.0;
    std::atomic<double> peakMB{ memBefore };
    PeakSampler sampler(memProfiling, peakMB);
    auto finish = [&](StitchResult r) -> StitchResult {
        sampler.stop();
        if (memProfiling) {
            const double memAfter = rss_mb();
            r.memBeforeMB = memBefore;
            r.memAfterMB  = memAfter;
            r.memPeakMB   = std::max(peakMB.load(), std::max(memBefore, memAfter));
            r.memSource   = memSource;
            // memFloorMB stays -1; the platform bridge fills it after its
            // post-stitch reclaim (Android mallopt(M_PURGE) / iOS settle read).
            char mbuf[96];
            std::snprintf(mbuf, sizeof(mbuf),
                          "memBefore=%.1f;memPeak=%.1f;memAfter=%.1f;memSrc=%s",
                          r.memBeforeMB, r.memPeakMB, r.memAfterMB, memSource.c_str());
            if (!r.debugSummary.empty()) r.debugSummary += ";";
            r.debugSummary += mbuf;
        }
        return r;
    };

    // 2026-08-17 — TWO-BRANCH orchestration (each launch is a full independent
    // run — memory does NOT stack, RAII frees between launches):
    //
    //   HIGH-LEVEL caller (both platforms' default, useManualPipeline=false):
    //     the FLATTENED 4-RUNG LADDER (cpp/stitcher_ladder.hpp).  Single-
    //     attempt rungs, threshold-only — no matcher / registration-resolution
    //     escalation (field RCA: the escalation was both the 30-min bundle-
    //     adjust wedge and the collapsed-garbage source; scans@0.3 is banned),
    //     per-rung output isolation + best-rung promotion, 120 s wall budget.
    //     The old spherical FULL re-run is gone: its surviving value is (a)
    //     the pre-compose in-place warper reroute inside the impl and (b) the
    //     DYNAMIC spherical extra rung — a fresh reservation-gated launch the
    //     ladder enqueues after a PANORAMA rung fails on a warper-fixable
    //     shape (sphericalExtraRungWarranted; at most one per ladder).
    //   MANUAL caller (useManualPipeline=true — legacy opt-in only; since
    //     2026-06-16 no production caller passes it): the OLD PANORAMA↔SCANS
    //     mode-fallback is PRESERVED for that opt-in path, exactly as before —
    //     primary + one opposite-mode fallback, legacy multi-attempt ladder
    //     inside (ladderThreshOverride stays -1).  The dispatcher guard routes
    //     SCANS→high-level affine; PANORAMA→manual (+ its own plane→spherical
    //     self-rescue).
    //
    // PreStitchMemoryAbort and UnknownCvException never re-launch (headroom
    // aborts are mode-independent; UnknownCvException is the caught-OOM
    // bucket — 2026-08-17 jetsam RCA, see ladderErrorIsTerminal).
    auto runOnce = [&](StitchMode modeOverride,
                       const std::string& warperOverride,
                       double rescueBudgetMPOverride = -1.0,
                       double ladderThreshOverride = -1.0,
                       const std::string& outputPathOverride = std::string(),
                       int bestFramesSoFar = -1) -> StitchResult {
        StitchConfig cfg = config;
        cfg.stitchMode = modeOverride;
        // 2026-08-17 review — the DYNAMIC spherical extra rung launches with
        // the warper pinned to spherical; every other launch keeps the
        // caller's warper.
        if (!warperOverride.empty()) cfg.warperType = warperOverride;
        // v0.25 — a launch carries the headroom gate's RESERVATION as a
        // canvas-budget cap, so the impl's downscale enforces that the launch
        // cannot spend more than the gate measured to fit.
        cfg.rescueCanvasBudgetMPOverride = rescueBudgetMPOverride;
        // 2026-08-17 — >= 0 puts the impl in single-attempt rung mode (one
        // estimateTransform at this threshold, attempt-1 semantics); < 0 keeps
        // the legacy multi-attempt ladder for the manual opt-in path.
        cfg.ladderThreshOverride = ladderThreshOverride;
        // 2026-08-17 review — cannot-improve early-out: the impl skips the
        // compose when this rung's estimate cannot beat the ladder's best so
        // far (rungCannotImproveBest).
        cfg.ladderBestFramesSoFar = bestFramesSoFar;
        // Per-rung output isolation: each rung writes its own .tmp so a worse
        // later rung can never clobber a better earlier write; the ladder
        // promotes exactly one tmp to outputPath via std::rename.
        return stitchFramePathsImpl_(
            framePaths,
            outputPathOverride.empty() ? outputPath : outputPathOverride,
            cfg, logFn);
    };
    // v0.25 — RESERVATION-based rescue gate (review: soundness by ENFORCEMENT,
    // not estimation).  A rescue's true compose cost is unknowable before its
    // own registration, and per-capture "bounds" proved unsound (the blender
    // allocates the bbox UNION of the warped ROIs, which a 2-D pan can blow
    // far past Σ frame areas).  So instead of estimating demand, the gate
    // RESERVES the headroom that is actually measured free (capped at the
    // device worst case) and the launch carries that reservation as a
    // canvas-budget cap the impl's downscale ENFORCES.  Consequences:
    //   - a rescue launches whenever even a minimal stitch fits (max
    //     admission — the field directive: never abandon a good capture on an
    //     over-estimate);
    //   - an admitted rescue CANNOT spend more than was free (no jetsam —
    //     an under-reservation degrades output resolution, never stability);
    //   - unknown RAM/rss keeps legacy behaviour (no gate, no cap).
    // Returns: reservation MB (> 0), 0 = skip (headroom below even the
    // minimal stitch), -1 = no measurement available (legacy, uncapped).
    auto rescueReservationMB = [&](const char* which) -> double {
        double ram = (config.availableRamMB > 0.0)
            ? config.availableRamMB : device_total_ram_mb();
        if (ram <= 0.0) return -1.0;
        const double rss = rss_mb();
        if (rss < 0.0) return -1.0;
        const double budgetMB = retailens::perProcessMemoryBudgetMB(ram);
        const double worstCaseMB =
            retailens::composeCanvasBudgetMP(ram)
            * retailens::kBlendBytesPerUnionPx;
        const double reservationMB = std::min(worstCaseMB, budgetMB - rss);
        if (reservationMB < retailens::kMinStreamStitchMB) {
            log_info(logFn, "[stitch-fallback]",
                     "%s SKIPPED for headroom: rss %.0f MB leaves "
                     "%.0f MB under the %.0f MB process budget — below the "
                     "%.0f MB minimal stitch — caller handles the skip",
                     which, rss, budgetMB - rss, budgetMB,
                     retailens::kMinStreamStitchMB);
            return 0.0;
        }
        log_info(logFn, "[stitch-fallback]",
                 "%s RESERVED %.0f MB (rss %.0f / budget %.0f MB)",
                 which, reservationMB, rss, budgetMB);
        return reservationMB;
    };
    // MB → canvas-MP cap for the runOnce override (-1 passes through).
    auto reservationToBudgetMP = [](double reservationMB) -> double {
        return (reservationMB > 0.0)
            ? reservationMB / retailens::kBlendBytesPerUnionPx : -1.0;
    };
    // 2026-08-17 review — orchestration-level exception backstop, mirroring
    // the impl's own crash-catch ladder.  The wrapper allocates on its
    // FAILURE paths (rung tmp-path strings, the growing rung trace, promote
    // and aggregate error messages) — i.e. exactly under memory pressure —
    // and a std::bad_alloc escaping here would cross the ObjC++/Swift
    // boundary uncaught (iOS has no JNI-style backstop) and std::terminate
    // the app.  Everything below runs inside this try; any throw becomes a
    // clean classified StitchResult.
    try {
    // ── MANUAL opt-in branch — exactly the legacy behaviour ─────────────
    // useManualPipeline=true callers (nothing ships with it since 2026-06-16)
    // keep primary + at-most-one opposite-mode fallback, with the legacy
    // multi-attempt threshold ladder inside the impl (ladderThreshOverride
    // stays -1).  The flattened ladder below must never run for them.
    if (config.useManualPipeline) {
        // v0.25 — the primary attempt gets the same dynamic headroom cap as
        // any other launch (field review: the static device canvas budget
        // could admit a compose larger than the memory actually free on a
        // loaded process).  reservation==0 (<350 MB free) passes -1: the
        // impl's own two-stage pre-stitch abort fires in exactly that case
        // and returns a clean PreStitchMemoryAbort.
        const double primaryReservationMB =
            rescueReservationMB("primary stitch");
        StitchResult firstAttempt =
            runOnce(config.stitchMode, std::string(),
                    reservationToBudgetMP(primaryReservationMB));
        firstAttempt.stitchModeUsed = config.stitchMode;
        if (firstAttempt.errorCode == StitchErrorCode::Ok) {
            return finish(firstAttempt);
        }
        // 2026-08-17 (jetsam RCA) — UnknownCvException stays EXCLUDED from
        // worthRetrying: it is the bucket a CAUGHT native OOM (std::bad_alloc
        // / StsNoMem) lands in, and retrying it launched a full second stitch
        // on a process that had just run out of memory — the observed iPad
        // jetsam kill.  PreStitchMemoryAbort likewise (headroom is mode-
        // independent).
        const bool worthRetrying =
            firstAttempt.errorCode == StitchErrorCode::HomographyEstimationFailed
            || firstAttempt.errorCode == StitchErrorCode::CameraParamsAdjustFailed
            || firstAttempt.errorCode == StitchErrorCode::WarpFailed
            || firstAttempt.errorCode == StitchErrorCode::EmptyPanorama
            || firstAttempt.errorCode == StitchErrorCode::LowQualityStitch;
        // Preserved opposite-mode fallback (iOS no-regression).  Headroom
        // demand follows what the FALLBACK actually runs (review finding): a
        // Scans fallback is dispatched to the HIGH-LEVEL BATCH pipeline
        // (Scans never runs manual — see the routing in
        // stitchFramePathsImpl_), so its launch is reservation-capped; only
        // a Panorama fallback runs the true manual pipeline, which
        // self-routes STREAM under pressure and carries its own guards, so
        // it takes no cap.
        if (worthRetrying) {
            const StitchMode fallbackMode =
                (config.stitchMode == StitchMode::Panorama)
                    ? StitchMode::Scans : StitchMode::Panorama;
            const double manReservationMB =
                rescueReservationMB("manual opposite-mode rescue");
            if (manReservationMB != 0.0) {
                log_info(logFn, "[stitch-fallback]",
                         "manual primary mode (%s) failed code=%d — retrying %s",
                         config.stitchMode == StitchMode::Scans ? "scans"
                                                                : "panorama",
                         static_cast<int>(firstAttempt.errorCode),
                         fallbackMode == StitchMode::Scans ? "scans"
                                                           : "panorama");
                StitchResult secondAttempt = runOnce(
                    fallbackMode, std::string(),
                    fallbackMode == StitchMode::Scans
                        ? reservationToBudgetMP(manReservationMB) : -1.0);
                if (secondAttempt.errorCode == StitchErrorCode::Ok) {
                    secondAttempt.stitchModeUsed = fallbackMode;
                    return finish(secondAttempt);
                }
            }
        }
        return finish(firstAttempt);
    }

    // ── FLATTENED 4-RUNG LADDER (2026-08-17 field RCA) ──────────────────
    // Plan (cpp/stitcher_ladder.hpp): primary model @1.00 → its floor, then
    // the opposite model @1.00 → its floor; scans@0.3 banned.  Every rung is
    // ONE estimateTransform with attempt-1 matcher semantics
    // (ladderThreshOverride) — this replaces the old primary escalation
    // ladder + spherical full re-run + SCANS rescue + reverse rescue, which
    // compounded to 8 estimateTransform runs worst case (A35: 4m41s for a
    // 10-frame pan that scans@1.0 solves in ~3 s).  Cross-model rescue is a
    // RUNG, not a special case, so a misclassified capture reaches the right
    // model in bounded time.
    if (outputPath.empty()) {
        // The rung tmp paths derive from outputPath; validate here so an
        // empty path keeps its InvalidArgument contract instead of writing
        // to a bare ".rungN.tmp" and failing the promote rename.
        StitchResult r;
        r.errorCode = StitchErrorCode::InvalidArgument;
        r.errorMessage = "outputPath must not be empty";
        r.framesRequested = static_cast<int32_t>(framePaths.size());
        log_error(logFn, "[stitch]", "%s", r.errorMessage.c_str());
        return finish(r);
    }
    // Mutable plan: the 4 planned rungs, plus room for the ONE dynamic
    // spherical extra rung (sphericalExtraRungWarranted) inserted right
    // after a failing PANORAMA rung.  Entries are COPIED out per iteration
    // — plan.insert can reallocate mid-loop.
    struct PlanEntry {
        LadderRung rung;
        bool       sphericalWarper;  // extra rung: warper pinned to spherical
    };
    std::vector<PlanEntry> plan;
    plan.reserve(static_cast<size_t>(kMaxLadderRungs));
    for (const LadderRung& r : planStitchLadder(config.stitchMode)) {
        plan.push_back({ r, false });
    }
    bool sphericalExtraEnqueued = false;
    const auto ladderT0 = std::chrono::steady_clock::now();
    // Wall-clock backstop.  The old orchestration wedged 30 minutes in one
    // loosened bundle-adjust; the wedge levers are gone (no matcher/range
    // escalation), but no capture is worth more than 2 minutes of rungs —
    // past the budget we promote the best partial or fail honestly.
    constexpr int64_t kLadderBudgetMs = 120000;

    auto modeName = [](StitchMode m) {
        return m == StitchMode::Scans ? "scans" : "panorama";
    };
    // The ".jpg" tail is load-bearing: cv::imwrite picks its encoder from
    // the file extension, so a bare ".tmp" suffix would fail every rung's
    // write.  The promote rename to outputPath doesn't care about either
    // name's extension (the bytes are JPEG in both).
    auto tmpPathForRung = [&](size_t idx) {
        return outputPath + ".rung" + std::to_string(idx + 1) + ".tmp.jpg";
    };
    // Remove every possible rung tmp (+ its debug coverage sidecar) except
    // `keep` (SIZE_MAX = remove all).  Bounded by kMaxLadderRungs, not the
    // current plan size, so the dynamic extra rung's tmp is always covered.
    // std::remove on a never-written path is a harmless ENOENT.
    auto unlinkRungTmps = [&](size_t keep) {
        for (size_t i = 0; i < static_cast<size_t>(kMaxLadderRungs); ++i) {
            if (i == keep) continue;
            const std::string tmp = tmpPathForRung(i);
            std::remove(tmp.c_str());
            std::remove((tmp + ".coverage.png").c_str());
        }
    };
    // 2026-08-17 review — orphan sweep BEFORE rung 1.  In-band cleanup only
    // runs on structured exits; a ladder killed mid-run (jetsam/lmkd/force-
    // quit during a compose peak) strands already-written multi-MB rung
    // JPEGs forever, since nothing else knows the naming scheme.  Sweeping
    // this outputPath's full candidate set here self-heals a prior crashed
    // ladder for the same path at the cost of kMaxLadderRungs ENOENT
    // unlinks in the common case.
    unlinkRungTmps(SIZE_MAX);
    // Promote rung `idx`'s tmp to outputPath (same-directory rename, atomic
    // on POSIX) and carry the debug coverage sidecar along.  A failed rename
    // MUST downgrade the result — success must never claim a file that isn't
    // at outputPath.
    auto promoteRung = [&](size_t idx, StitchResult r) -> StitchResult {
        const std::string tmp = tmpPathForRung(idx);
        if (std::rename(tmp.c_str(), outputPath.c_str()) != 0) {
            r.success = false;
            r.errorCode = StitchErrorCode::ImageWriteFailed;
            r.errorMessage = "ladder promote failed: could not rename " + tmp
                             + " to " + outputPath;
            log_error(logFn, "[stitch-retry]", "%s", r.errorMessage.c_str());
            unlinkRungTmps(SIZE_MAX);
            return finish(r);
        }
        // Sidecar is debug-only — best-effort, failures ignored.
        std::rename((tmp + ".coverage.png").c_str(),
                    (outputPath + ".coverage.png").c_str());
        unlinkRungTmps(idx);
        return finish(r);
    };

    StitchResult firstError;   // rung 1's failure — primary-model honesty
    bool         haveFirstError = false;
    StitchResult best;         // best partial success so far
    size_t       bestRung = SIZE_MAX;
    std::string  rungTrace;    // per-rung outcomes for the aggregate error
    auto traceAppend = [&](const std::string& entry) {
        if (!rungTrace.empty()) rungTrace += "; ";
        rungTrace += entry;
    };

    for (size_t i = 0; i < plan.size(); ++i) {
        // COPY, not reference — the spherical-extra insert below can
        // reallocate `plan` while this iteration still reads the entry.
        const PlanEntry entry = plan[i];
        const LadderRung& rung = entry.rung;
        const char* sphTag = entry.sphericalWarper ? " spherical" : "";
        char label[72];
        if (i == 0) {
            std::snprintf(label, sizeof(label), "primary stitch");
        } else {
            std::snprintf(label, sizeof(label), "rung %zu (%s@%.2f%s)", i + 1,
                          modeName(rung.mode), rung.confidenceThresh, sphTag);
        }
        if (i > 0) {
            const int64_t elapsedMs =
                std::chrono::duration_cast<std::chrono::milliseconds>(
                    std::chrono::steady_clock::now() - ladderT0).count();
            if (elapsedMs > kLadderBudgetMs) {
                log_info(logFn, "[stitch-retry]",
                         "ladder budget exhausted after rung %zu — skipping "
                         "remaining rungs", i);
                traceAppend("budget exhausted before rung "
                            + std::to_string(i + 1));
                break;
            }
        }
        const double reservationMB = rescueReservationMB(label);
        if (i > 0 && reservationMB == 0.0) {
            // Headroom below even a minimal stitch — skip this rung (the
            // gate already logged the numbers).  Rung 1 is exempt: it
            // launches uncapped so the impl's own two-stage pre-stitch abort
            // produces the clean PreStitchMemoryAbort that IS the primary
            // error (legacy primary behaviour).
            log_info(logFn, "[stitch-retry]",
                     "rung %zu (%s@%.2f%s) skipped for headroom", i + 1,
                     modeName(rung.mode), rung.confidenceThresh, sphTag);
            traceAppend("rung" + std::to_string(i + 1) + " "
                        + modeName(rung.mode) + " skipped(headroom)");
            continue;
        }
        StitchResult r = runOnce(rung.mode,
                                 entry.sphericalWarper ? "spherical"
                                                       : std::string(),
                                 reservationToBudgetMP(reservationMB),
                                 rung.confidenceThresh,
                                 tmpPathForRung(i),
                                 bestRung == SIZE_MAX ? -1
                                                      : best.framesIncluded);
        r.stitchModeUsed = rung.mode;
        {
            char tbuf[88];
            std::snprintf(tbuf, sizeof(tbuf), "rung%zu %s@%.2f%s code=%d",
                          i + 1, modeName(rung.mode), rung.confidenceThresh,
                          entry.sphericalWarper ? "+sph" : "",
                          static_cast<int>(r.errorCode));
            traceAppend(tbuf);
        }
        if (r.errorCode == StitchErrorCode::Ok) {
            if (retailens::ladderRungAcceptable(
                    r.framesIncluded,
                    static_cast<int>(framePaths.size()))) {
                // Complete OR all-but-one — promote immediately.  The
                // single-dropped-frame partial is the dominant field shape
                // (blurred boundary keyframe); chasing that one frame
                // through the remaining rungs costs full stitches that the
                // tie rule would discard anyway (see ladderRungAcceptable).
                log_info(logFn, "[stitch-retry]",
                         "%s acceptable (%d/%zu frames) — promoting", label,
                         r.framesIncluded, framePaths.size());
                return promoteRung(i, r);
            }
            // Partial success: strictly more frames wins; ties keep the
            // earlier rung (see stitcher_ladder.hpp for why).
            if (bestRung == SIZE_MAX
                || ladderRungBeatsBest(r.framesIncluded,
                                       best.framesIncluded)) {
                best = r;
                bestRung = i;
            }
            log_info(logFn, "[stitch-retry]",
                     "%s partial (%d/%zu frames) — continuing ladder", label,
                     r.framesIncluded, framePaths.size());
            continue;
        }
        if (i == 0) {
            firstError = r;
            haveFirstError = true;
        }
        // Terminal failures stop the ladder outright: input/filesystem-
        // invariant codes repeat identically on every rung, and
        // UnknownCvException is the caught-native-OOM bucket — relaunching a
        // full stitch after an OOM re-peaks the process and reproduces the
        // documented iPad jetsam kill (2026-08-17 RCA; the per-rung headroom
        // re-gate is NOT sufficient because the failed rung's memory is
        // RAII-freed before the gate re-reads rss).  Full rationale on
        // retailens::ladderErrorIsTerminal.
        if (retailens::ladderErrorIsTerminal(r.errorCode)) {
            log_info(logFn, "[stitch-retry]",
                     "%s failed code=%d (terminal — see ladderErrorIsTerminal)"
                     " — stopping ladder", label,
                     static_cast<int>(r.errorCode));
            break;
        }
        // 2026-08-17 review — DYNAMIC spherical extra rung.  A PANORAMA rung
        // launched with a non-spherical warper that failed on a shape
        // spherical compose geometry can fix (LowQualityStitch: marooned /
        // disjoint / predictive-utilization reject; WarpFailed: degenerate
        // or oversized plane canvas) earns ONE fresh spherical launch,
        // inserted immediately after it — reservation-gated and budget-
        // checked like any rung.  This replaces the deleted in-place
        // re-compose (UB: OpenCV clears seam_est_imgs_ after the first
        // compose) AND restores the old full spherical rescue's coverage
        // for wide-rotation captures.  Capped at one per ladder.
        if (retailens::sphericalExtraRungWarranted(
                rung.mode, r.errorCode,
                entry.sphericalWarper || config.warperType == "spherical",
                sphericalExtraEnqueued)
            && plan.size() < static_cast<size_t>(kMaxLadderRungs)) {
            sphericalExtraEnqueued = true;
            plan.insert(plan.begin() + static_cast<std::ptrdiff_t>(i) + 1,
                        PlanEntry{ rung, true });
            log_info(logFn, "[stitch-fallback]",
                     "%s failed code=%d (%s) — enqueueing spherical extra "
                     "rung (fresh run, no in-place re-compose)", label,
                     static_cast<int>(r.errorCode), r.errorMessage.c_str());
            continue;
        }
        log_info(logFn, "[stitch-retry]",
                 "%s failed code=%d (%s) — continuing ladder", label,
                 static_cast<int>(r.errorCode), r.errorMessage.c_str());
    }

    if (bestRung != SIZE_MAX) {
        log_info(logFn, "[stitch-retry]",
                 "ladder done — promoting best partial: rung %zu (%s, %d/%zu "
                 "frames)", bestRung + 1, modeName(best.stitchModeUsed),
                 best.framesIncluded, framePaths.size());
        return promoteRung(bestRung, best);
    }
    unlinkRungTmps(SIZE_MAX);
    if (haveFirstError) {
        // Primary-model honesty: the caller asked for the primary mode, so
        // the primary rung's code/message leads; the trace records what the
        // rest of the ladder tried and how each rung failed.
        firstError.errorMessage += " [ladder: " + rungTrace + "]";
        return finish(firstError);
    }
    // Unreachable in practice (rung 1 always launches → success is recorded
    // as promoted/best, failure as firstError); defensive fallthrough keeps
    // the function total.
    StitchResult noResult;
    noResult.errorCode = StitchErrorCode::UnknownCvException;
    noResult.errorMessage =
        "ladder produced no result [ladder: " + rungTrace + "]";
    noResult.framesRequested = static_cast<int32_t>(framePaths.size());
    return finish(noResult);
    } catch (const std::exception& e) {
        // cv::Exception derives from std::exception, so this also covers cv
        // throws from wrapper-level code; std::bad_alloc from the wrapper's
        // own failure-path string work lands here instead of crossing the
        // ObjC++/JNI boundary.  The handler allocates one small message —
        // the same accepted residual as the impl's catch ladder.
        StitchResult r;
        r.errorCode = StitchErrorCode::UnknownCvException;
        r.errorMessage = std::string("stitch orchestration exception: ")
                         + (e.what() ? e.what() : "(no message)");
        r.framesRequested = static_cast<int32_t>(framePaths.size());
        log_error(logFn, "[stitch]", "%s", r.errorMessage.c_str());
        return finish(r);
    } catch (...) {
        StitchResult r;
        r.errorCode = StitchErrorCode::UnknownCvException;
        r.errorMessage = "stitch orchestration unknown exception";
        r.framesRequested = static_cast<int32_t>(framePaths.size());
        log_error(logFn, "[stitch]", "%s", r.errorMessage.c_str());
        return finish(r);
    }
}

// ─────────────────────────────────────────────────────────────────────
// Single-keyframe output — shared by the incremental (stitchFramePathsImpl_)
// and manual (stitchFramePathsManual) entrypoints so the ">= 1 frame" boundary
// AND the rotation stay identical on both paths.  A lone keyframe is a valid
// one-shot panorama: bake the capture-orientation rotation into it with the
// SAME `bake_rotation` primitive + `config` the multi-frame path applies to its
// final image, then JPEG-encode with the quality CLAMPED to [0, 100] to match
// the multi-frame writers (the single-frame path previously passed jpegQuality
// through unclamped).  Mutates + returns the caller's `result`.
static StitchResult& writeSingleFrameResult(
    StitchResult&                   result,
    const std::string&              framePath,
    const std::string&              outputPath,
    const StitchConfig&             config,
    const LogFn&                    logFn,
    const char*                     logTag) {
    log_info(logFn, "[stitch]", "%s: processing 1 frame via bake_rotation", logTag);
    cv::Mat mat = cv::imread(framePath, cv::IMREAD_COLOR);
    if (mat.empty()) {
        result.errorCode = StitchErrorCode::ImageReadFailed;
        result.errorMessage = "Could not read single keyframe: " + framePath;
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    cv::Mat finalImage = bake_rotation(mat, config.captureOrientation, logFn);
    const int q = std::max(0, std::min(100, config.jpegQuality));
    std::vector<int> params = { cv::IMWRITE_JPEG_QUALITY, q };
    if (!cv::imwrite(outputPath, finalImage, params)) {
        result.errorCode = StitchErrorCode::ImageWriteFailed;
        result.errorMessage = "Could not write single keyframe to " + outputPath;
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    result.errorCode = StitchErrorCode::Ok;
    result.success = true;
    result.width = finalImage.cols;
    result.height = finalImage.rows;
    result.framesRequested = 1;
    result.framesIncluded = 1;
    return result;
}

// 2026-05-22 (audit follow-up) — renamed inner entry point so the
// public `stitchFramePaths` wrapper above can layer its retry
// orchestration on top (today: the flattened 4-rung ladder, or the
// manual opt-in's opposite-mode fallback).  This used to be the public
// function.
static StitchResult stitchFramePathsImpl_(
    const std::vector<std::string>& framePaths,
    const std::string&              outputPath,
    const StitchConfig&             config,
    LogFn                           logFn)
{
    // V2 routing — when caller opts in, hand off to the manual
    // cv::detail::* pipeline.  See stitcher.hpp::StitchConfig::
    // useManualPipeline for the tradeoffs.  Routing here keeps the
    // call-site signature identical so existing bridges (iOS Obj-C++,
    // Android JNI) don't need to know which path runs internally.
    //
    // 2026-06-16 — SCANS always uses the high-level pipeline: the manual path is
    // homography-only (no affine matcher/estimator/warper), so SCANS+manual
    // would silently run a homography stitch.  The old mode-fallback enforced
    // this in runOnce; now that runOnce is warper-only, enforce it here so a
    // not-yet-migrated caller passing stitchMode=scans + useManualPipeline=true
    // (e.g. iOS pre-Phase-2) still gets the correct affine SCANS path.
    if (config.useManualPipeline && config.stitchMode != StitchMode::Scans) {
        StitchResult r =
            stitchFramePathsManual(framePaths, outputPath, config, logFn);
        // 2026-06-15 — AUTO SPHERICAL FALLBACK.  The manual pipeline defaults to
        // the PLANE warper (flat, natural for narrow / 1x pans).  Plane is
        // unbounded, so a wide / off-axis pan can maroon content in a corner;
        // validateStitchOutput rejects that as LowQualityStitch (the utilization
        // / disjoint guard) BEFORE writing any file.  Rather than fail, retry
        // ONCE with the SPHERICAL warper, which bounds both axes — flat when
        // plane works, bounded only when it doesn't.  Skipped when the caller
        // already asked for spherical, or the failure wasn't a quality rejection
        // (OOM abort / read error won't be fixed by a different warper).
        if (!r.success
            && r.errorCode == StitchErrorCode::LowQualityStitch
            && config.warperType != "spherical") {
            log_info(logFn, "[stitch-bc]",
                     "manual '%s' marooned (LowQualityStitch) — retrying once "
                     "with spherical (bounded both axes)",
                     config.warperType.c_str());
            StitchConfig sph = config;
            sph.warperType = "spherical";
            return stitchFramePathsManual(framePaths, outputPath, sph, logFn);
        }
        return r;
    }

    const auto t0 = std::chrono::steady_clock::now();
    StitchResult result;
    result.framesRequested = static_cast<int32_t>(framePaths.size());

    // 2026-06-16 (review #1) — outer crash-catch ladder over the WHOLE high-level
    // body.  Now that high-level is the default pipeline, an OOM-class throw must
    // NOT escape: cv::Stitcher PANORAMA internals (MultiBand pyramids, GraphCut,
    // STL vectors) can throw std::bad_alloc (NOT a cv::Exception, so the narrow
    // inner catch(cv::Exception&) misses it), and the post-stitch clone/crop/bake
    // ops can throw cv::Exception(StsNoMem) OUTSIDE the inner catches — either
    // would cross the JNI C-ABI → std::terminate/SIGABRT.  Mirror the manual
    // path's ladder so any throw becomes a clean StitchResult error (which the
    // outer rung ladder can then act on).  The JNI adds a backstop too.
    try {
    if (framePaths.empty()) {
        result.errorCode = StitchErrorCode::InvalidArgument;
        result.errorMessage = "Need at least 1 frame to stitch (got 0)";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    if (framePaths.size() == 1) {
        return writeSingleFrameResult(
            result, framePaths[0], outputPath, config, logFn, "single-frame stitch");
    }
    if (outputPath.empty()) {
        result.errorCode = StitchErrorCode::InvalidArgument;
        result.errorMessage = "outputPath must not be empty";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    log_info(logFn, "[stitch]",
             "stitchFramePaths: frames=%zu warper=%s blender=%s seam=%s "
             "mode=%s orientation=%s quality=%d inscribedRect=%d",
             framePaths.size(),
             config.warperType.c_str(),
             config.blenderType.c_str(),
             config.seamFinderType.c_str(),
             config.stitchMode == StitchMode::Scans ? "scans" : "panorama",
             config.captureOrientation.c_str(),
             config.jpegQuality,
             config.useInscribedRectCrop ? 1 : 0);
    // Gate the [memstat] phase logs (compile flag + runtime settings.debug).
    const bool memstat = kMemProfilingCompiled && config.enableMemoryProfiling;
    log_memstat(logFn, memstat, "entry");

    // 2026-08-17 (jetsam RCA) — pre-stitch headroom abort, now TWO-STAGE.
    // Stage 1 (here, BEFORE the imread loop): fail fast before the first
    // allocation when the process is already hopeless — a doomed stitch no
    // longer pays the decode cost.  Stage 2 (after the imread loop, below)
    // re-checks with the decoded keyframes IN the measured footprint —
    // review finding: a pre-imread-only check is strictly MORE permissive
    // than the old post-imread check (the frames vector is 150-400 MB with
    // large captures), which would have re-opened the exact near-ceiling
    // band this patch closes.  Same RAM resolution as before (incl. the
    // 4 GB unknown-RAM fallback); both stages skipped when rss is unknown.
    double earlyRamMBResolved = -1.0;
    {
        double earlyRamMB = (config.availableRamMB > 0.0)
            ? config.availableRamMB : device_total_ram_mb();
        if (earlyRamMB <= 0.0) earlyRamMB = 4.0 * 1024.0;
        earlyRamMBResolved = earlyRamMB;
        const double startRssMB = rss_mb();
        if (startRssMB >= 0.0
            && retailens::stitchExceedsMinimalHeadroom(startRssMB, earlyRamMB)) {
            result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
            result.errorMessage =
                "Pre-stitch abort: insufficient memory headroom for high-level "
                "stitch (rss=" + std::to_string(static_cast<int>(startRssMB)) +
                "MB, budget=" + std::to_string(static_cast<int>(
                    retailens::perProcessMemoryBudgetMB(earlyRamMB))) + "MB)";
            log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
            return result;
        }
    }

    // ── 1.  Load input frames ───────────────────────────────────────
    std::vector<cv::Mat> images;
    images.reserve(framePaths.size());
    double totalInputMB = 0.0;
    for (size_t i = 0; i < framePaths.size(); ++i) {
        cv::Mat img = cv::imread(framePaths[i], cv::IMREAD_COLOR);
        if (img.empty()) {
            result.errorCode = StitchErrorCode::ImageReadFailed;
            result.errorMessage = "Failed to load frame: " + framePaths[i];
            log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
            return result;
        }
        const double mb = mat_mb(img);
        totalInputMB += mb;
        log_info(logFn, "[dimstat]",
                 "input[%zu] %dx%d %dch elemSize=%zu data=%.2f MB",
                 i, img.cols, img.rows, img.channels(), img.elemSize(), mb);
        images.push_back(std::move(img));
    }
    log_info(logFn, "[dimstat]", "loaded %zu frames total_input_data=%.2f MB",
             images.size(), totalInputMB);
    log_memstat(logFn, memstat, "after_imread");
    // Stage 2 of the pre-stitch headroom abort (see stage 1 above the imread
    // loop): re-check with the decoded frames now IN the measured footprint —
    // this restores the exact protection level of the pre-2026-08-17 check,
    // which ran here and therefore counted the frames vector.  One rss read;
    // the images vector is RAII-freed on the early return.
    {
        const double loadedRssMB = rss_mb();
        if (loadedRssMB >= 0.0 && earlyRamMBResolved > 0.0
            && retailens::stitchExceedsMinimalHeadroom(loadedRssMB,
                                                       earlyRamMBResolved)) {
            result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
            result.errorMessage =
                "Pre-stitch abort: insufficient memory headroom after loading "
                "frames (rss=" + std::to_string(static_cast<int>(loadedRssMB)) +
                "MB, budget=" + std::to_string(static_cast<int>(
                    retailens::perProcessMemoryBudgetMB(earlyRamMBResolved))) +
                "MB)";
            log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
            return result;
        }
    }

    // ── 2.  Configure cv::Stitcher ──────────────────────────────────
    const cv::Stitcher::Mode cvMode = (config.stitchMode == StitchMode::Scans)
        ? cv::Stitcher::SCANS : cv::Stitcher::PANORAMA;
    cv::Ptr<cv::Stitcher> stitcher;
    try {
        stitcher = cv::Stitcher::create(cvMode);
    } catch (const cv::Exception& e) {
        result.errorCode = StitchErrorCode::UnknownCvException;
        result.errorMessage = std::string("Stitcher::create threw: ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    // Warper only applies in PANORAMA mode (SCANS hard-wires PlaneWarper
    // internally; setting a different warper there silently breaks the
    // affine BA's assumptions — see learning doc on pipeline coherence).
    if (cvMode == cv::Stitcher::PANORAMA) {
        if (auto warper = make_warper(config.warperType)) {
            stitcher->setWarper(warper);
        }
    } else {
        log_info(logFn, "[stitch]",
                 "SCANS mode: skipping setWarper (PlaneWarper hard-wired)");
    }
    stitcher->setBlender(make_blender(config.blenderType));
    stitcher->setSeamFinder(make_seam_finder(config.seamFinderType));

    // Resolution budgets.  Negative => keep cv::Stitcher library default
    // for registration / seam.  compositingResolMP is the exception:
    // cv::Stitcher's library default is ORIG_RESOL (-1.0 = full sensor
    // resolution), which trivially OOMs on Android — so for the high-
    // level entry we substitute 1.0 MP when the caller leaves the
    // sentinel.  (Manual entry uses a different fallback; see
    // stitchFramePathsManual().)
    if (config.registrationResolMP > 0.0) {
        stitcher->setRegistrationResol(config.registrationResolMP);
    }
    if (config.seamEstimationResolMP > 0.0) {
        stitcher->setSeamEstimationResol(config.seamEstimationResolMP);
    }
    // 2026-06-16 (high-level safety) — RAM-aware compositing resolution.
    // cv::Stitcher composes the whole canvas at once (no STREAM mode), so the
    // per-frame compose MP directly sizes the peak.  1.0 MP is fine on 6 GB+
    // (measured peak ~0.7-0.9 GB on the A35); on lower-RAM devices clamp to
    // 0.6 MP so the unguarded high-level path can't out-grow the per-process
    // budget.  An explicit caller override (compositingResolMP > 0) still wins.
    double totalRamMB = (config.availableRamMB > 0.0)
        ? config.availableRamMB : device_total_ram_mb();
    if (totalRamMB <= 0.0) totalRamMB = 4.0 * 1024.0;  // conservative fallback
    const double kHighLevelComposeFallbackMP =
        (totalRamMB >= 5.0 * 1024.0) ? 1.0 : 0.6;
    const double composeMP = (config.compositingResolMP > 0.0)
        ? config.compositingResolMP : kHighLevelComposeFallbackMP;
    stitcher->setCompositingResol(composeMP);
    log_info(logFn, "[dimstat]",
             "cv::Stitcher resol budgets (per frame, MP):"
             " registration=%.3f seam=%.3f compositing=%.3f%s",
             stitcher->registrationResol(),
             stitcher->seamEstimationResol(),
             stitcher->compositingResol(),
             stitcher->compositingResol() < 0
                 ? " (ORIG_RESOL = no downscale!)" : "");

    // ── 3.  Registration: single ladder rung, or legacy C+D retry ───
    //
    // 2026-08-17 (flattened ladder) — TWO regimes:
    //
    //   config.ladderThreshOverride >= 0 (every high-level rung launched by
    //   stitchFramePaths): exactly ONE estimateTransform at that
    //   panoConfidenceThresh with attempt-1 semantics — default matcher
    //   matchConf, consecutive-only range window (2) when the range matcher
    //   is enabled, registration resolution untouched.  No retry loop, no
    //   matcher loosening, no best-attempt recovery: the loosened-matcher
    //   escalation was the 30-minute bundle-adjust wedge AND the source of
    //   the garbage placements the validators then had to catch, and per-rung
    //   output isolation upstairs makes in-place recovery structurally
    //   unnecessary.  An Ok WITH dropped frames returns as a partial success
    //   (framesIncluded < requested) — the OUTER ladder decides what to do.
    //
    //   config.ladderThreshOverride < 0 (manual opt-in fallback's Scans
    //   dispatch + any direct legacy caller): the legacy multi-attempt C+D
    //   ladder below runs unchanged — progressively lower thresholds
    //   [1.0 → 0.5 → 0.3] with matcher/registration escalation and the
    //   best-attempt re-estimation recovery.  Zero regression for opt-ins.
    //
    // cv::Stitcher::leaveBiggestComponent drops frames whose pairwise
    // confidence is below `panoConfidenceThresh`; boundary frames
    // (first/last 1-2) statistically fall below first.
    // (pre-stitch headroom abort moved BEFORE the imread loop — 2026-08-17
    // jetsam RCA; see the block above frame loading.)
    const bool singleRung = (config.ladderThreshOverride >= 0.0);
    log_memstat(logFn, memstat, "before_stitch");
    // 2026-07-01 — combined-lever retry.  Rather than ADD attempts (more
    // wall-clock, still no guaranteed result), the 2nd/3rd reduced-confidence
    // attempts ALSO loosen the feature matcher and raise the registration
    // resolution.  The resolution lever works through finer feature
    // LOCALIZATION, not feature count: cv::Stitcher's ORB detector caps at
    // 500 features per frame regardless of scale, but keypoints detected on
    // a larger working image sit closer to their true sub-pixel positions,
    // which is exactly what weak-overlap pairs need to survive the ratio
    // test + RANSAC.  Attempt 1 is left untouched so the happy path keeps
    // its speed; the extra registration cost is paid ONLY on captures that
    // already failed attempt 1.  PANORAMA only: SCANS uses an affine
    // matcher/estimator/warper, so we never swap its matcher.
    //
    // match_conf semantics (verified against the vendored OpenCV 4.10.0):
    // BestOf2NearestMatcher's ratio test keeps a match iff
    //   d0 < (1 - match_conf) * d1,
    // and cv::Stitcher::create(PANORAMA)'s default matcher uses
    // match_conf = 0.3 (ratio < 0.70).  LOWER match_conf = LOOSER matching.
    // So the escalation must go BELOW 0.3: 0.25 (ratio < 0.75) then 0.20
    // (ratio < 0.80).  [2026-07-01 adversarial review: the first cut used
    // 0.5/0.4, which is STRICTER than the default — inverted lever.]
    struct RetryTune {
        double thresh;      // panoConfidenceThresh
        float  matchConf;   // matcher confidence; <0 -> default 0.3
        double regResolMP;  // registration resolution (MP); <0 -> leave config/default
        int    rangeWidth;  // perf-3b — range-matcher window for THIS attempt
                            // when the range ladder is enabled
                            // (config.rangeMatcherWidth > 0).  0 here == "use
                            // config.rangeMatcherWidth" (the operator-set final
                            // width).  Ignored when the range ladder is off.
    };
    // Measured cliff guard (docs/perf-rca-079-stitch-time.md): registration
    // above ~1.2 MP makes the ORB feature count + BundleAdjusterRay explode
    // super-quadratically — a ~20-25x wall-time cliff that ALSO finds worse
    // matches and drops frames (A35, 5 kf @ 2560x1440: regMP 1.2 = 2.3 s vs
    // 1.3 = 48-60 s, framesIncluded 5/5 -> 3/5). The fallback attempts' rescue
    // value comes from the lower panoConfidenceThresh + the wider range matcher,
    // NOT from raising resolution, so the ladder's registration escalation is
    // capped at this cliff-safe ceiling (1.0 = attempt-2's proven-safe value,
    // with margin below the 1.2-1.3 knee, which shifts with scene texture).
    constexpr double kLadderRegResolCeilingMP = 1.0;
    // LEGACY schedule — only reachable when ladderThreshOverride < 0 (manual
    // opt-in fallback / direct legacy callers).  High-level rungs use the
    // single-attempt entry built below instead; the 0.25/0.20 matchConf and
    // width-3 range escalation here are exactly the levers the flattened
    // ladder removed (wedge + garbage source), retained verbatim for the
    // opt-in path's zero-regression guarantee.
    const RetryTune kRetries[] = {
        //  thresh  matchConf  regResolMP                 rangeWidth
        {   1.0,    -1.0f,     -1.0,                       2 },   // attempt 1: consecutive-only (fast happy path)
        {   0.5,     0.25f,    1.0,                        2 },   // attempt 2: consecutive + looser matcher + 1.0 MP
        {   0.3,     0.20f,    kLadderRegResolCeilingMP,   0 },   // attempt 3: min thresh + widest matcher; reg capped (was 1.3 — the cliff)
    };
    // Attempt schedule actually run: one attempt-1-semantics rung at the
    // override threshold, or the full legacy ladder.
    std::vector<RetryTune> attempts;
    if (singleRung) {
        attempts.push_back({ config.ladderThreshOverride, -1.0f, -1.0, 2 });
    } else {
        attempts.assign(kRetries,
                        kRetries + sizeof(kRetries) / sizeof(kRetries[0]));
    }
    const int kNumAttempts = static_cast<int>(attempts.size());
    cv::Mat panorama;
    cv::Stitcher::Status status = cv::Stitcher::ERR_NEED_MORE_IMGS;
    int framesIncluded = 0;
    double finalThreshold = -1.0;
    int finalAttempt = 0;
    // Best successful attempt so far (partial-success recovery: a later,
    // harder attempt must not discard an earlier viable estimate).
    int bestAttempt = -1;
    int bestFrames = -1;
    // Last cv::Exception message seen inside the ladder — surfaced in the
    // final error when every attempt fails so the diagnostic isn't just an
    // opaque status code.
    std::string lastExceptionWhat;
    const double baseRegResol = stitcher->registrationResol();
    for (int attempt = 0; attempt < kNumAttempts; ++attempt) {
        const RetryTune& tune = attempts[static_cast<size_t>(attempt)];
        const double thresh = tune.thresh;
        // Legacy-only: SCANS skips the ladder's higher thresholds (its cv
        // default is already 0.3).  A ladder RUNG must run at exactly its
        // threshold — the flattened plan deliberately runs scans@1.0/@0.5
        // (scans@0.3 is the banned garbage regime), so the skip rule would
        // invert the design if applied to the override path.
        if (!singleRung && cvMode == cv::Stitcher::SCANS && thresh > 0.31)
            continue;
        stitcher->setPanoConfidenceThresh(thresh);
        // Registration escalation on the fallback attempts (PANORAMA only).
        // Swapping in a BestOf2NearestMatcher would break SCANS (affine
        // matcher/estimator/warper), so the whole escalation is gated to
        // PANORAMA to keep SCANS behaviour byte-for-byte unchanged.
        float  appliedMatchConf  = -1.0f;
        double appliedRegResolMP = -1.0;
        int    appliedRangeWidth = 0;
        if (cvMode == cv::Stitcher::PANORAMA) {
            if (config.rangeMatcherWidth > 0) {
                // perf-3b — RANGE-MATCHER LADDER.  Match only capture-adjacent
                // keyframes (|i-j| < width); keyframes are capture-ordered (JNI
                // preserves accept order), so on a linear pan the non-adjacent
                // pairs share ~no overlap and their O(N^2) matching is waste.
                // The window WIDENS across the ladder: 2 (consecutive-only) on
                // the fast attempts, then out to config.rangeMatcherWidth on the
                // final, minimum-threshold attempt — so a chain broken at a weak
                // consecutive link gets BRIDGED (distance-2+) only as a last
                // resort.  This REPLACES the full-pairwise matcher on every
                // attempt: distant-overlap (pan-back) captures are handled at
                // capture time (perf-5 capture-pause), not by a full-matcher
                // rescue here.  match_conf tracks the same loosening the legacy
                // ladder used (0.3 -> 0.25 -> 0.20).
                const int rw = (tune.rangeWidth > 0)
                    ? tune.rangeWidth
                    : std::max(2, config.rangeMatcherWidth);
                const float mc = (tune.matchConf > 0.0f) ? tune.matchConf : 0.3f;
                stitcher->setFeaturesMatcher(
                    cv::makePtr<cv::detail::BestOf2NearestRangeMatcher>(rw, false, mc));
                appliedMatchConf = mc;
                appliedRangeWidth = rw;
            } else if (tune.matchConf > 0.0f) {
                // Legacy full-pairwise ladder (range matcher OFF): loosened FULL
                // matcher on the fallback attempts; attempt 1 keeps the create()
                // default matcher.  Byte-identical to pre-perf-3b.  Swapping a
                // matcher would break SCANS (affine family), so this whole block
                // is PANORAMA-gated.
                stitcher->setFeaturesMatcher(
                    cv::makePtr<cv::detail::BestOf2NearestMatcher>(false, tune.matchConf));
                appliedMatchConf = tune.matchConf;
            }
            if (tune.regResolMP > 0.0) {
                // Cap the ladder's escalation at the cliff-safe ceiling (defence
                // in depth against any future rung value), but still honour an
                // explicit caller registrationResolMP above it — the caller owns
                // that time/quality trade-off; escalation may only RAISE
                // resolution, never clobber an explicit caller value downward.
                const double capped = std::min(tune.regResolMP, kLadderRegResolCeilingMP);
                const double rr = std::max(capped, config.registrationResolMP);
                stitcher->setRegistrationResol(rr);
                appliedRegResolMP = rr;
            }
        }
        finalAttempt = attempt + 1;
        finalThreshold = thresh;
        log_info(logFn, "[stitch-retry]",
                 "attempt %d/%d panoConfidenceThresh=%.2f matchConf=%.2f regResolMP=%.2f rangeWidth=%d",
                 finalAttempt, kNumAttempts, thresh,
                 appliedMatchConf, appliedRegResolMP, appliedRangeWidth);
        try {
            // 2026-06-16 (review #2) — TWO-PHASE: estimateTransform (registration
            // + BA + leaveBiggestComponent at this threshold) here; composePanorama
            // runs ONCE after the canvas-union guard below.  This is the ONLY way
            // to inspect the estimated canvas BEFORE the warp/blend allocates it.
            status = stitcher->estimateTransform(images);
        } catch (const cv::Exception& e) {
            // 2026-07-01 (review f2) — an exception at attempt N does NOT
            // abort the ladder: the next attempt's estimateTransform()
            // re-runs the whole registration from scratch (fresh
            // features/matches/BA at the new tune), so it is unaffected by
            // the throwing attempt's half-mutated internal state.  Only a
            // throw on the FINAL attempt with NO earlier success is
            // terminal here; a final-attempt throw with an earlier OK
            // falls out of the loop into the best-attempt recovery below.
            lastExceptionWhat = e.what() ? e.what() : "(no message)";
            log_error(logFn, "[stitch-retry]",
                      "attempt %d threw cv::Exception: %s — %s",
                      finalAttempt, lastExceptionWhat.c_str(),
                      (attempt + 1 < kNumAttempts)
                          ? "trying next tune"
                          : "no attempts left");
            // Don't let a previous attempt's stale OK leak out of the loop.
            status = cv::Stitcher::ERR_NEED_MORE_IMGS;
            if (attempt + 1 >= kNumAttempts && bestAttempt < 0) {
                result.errorCode = StitchErrorCode::UnknownCvException;
                result.errorMessage =
                    std::string("Stitcher::estimateTransform threw on attempt ") +
                    std::to_string(finalAttempt) + ": " + lastExceptionWhat;
                log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
                return result;
            }
            continue;
        }
        if (status != cv::Stitcher::OK) {
            log_info(logFn, "[stitch-retry]",
                     "attempt %d FAILED with status=%d, trying next threshold",
                     finalAttempt, static_cast<int>(status));
            continue;
        }
        const std::vector<int>& component = stitcher->component();
        framesIncluded = static_cast<int>(component.size());
        if (framesIncluded > bestFrames) {
            bestFrames = framesIncluded;
            bestAttempt = attempt;
        }
        log_info(logFn, "[stitch-retry]",
                 "attempt %d OK: framesIncluded=%d of %zu (thresh=%.2f)",
                 finalAttempt, framesIncluded, framePaths.size(), thresh);
        if (framesIncluded >= static_cast<int>(framePaths.size())) {
            break;  // all retained — done
        }
        if (attempt + 1 < kNumAttempts) {
            log_info(logFn, "[stitch-retry]",
                     "%d frames dropped — retrying with lower threshold",
                     (int)framePaths.size() - framesIncluded);
        } else {
            log_info(logFn, "[stitch-retry]",
                     "%d frames dropped at lowest threshold %.2f — accepting result",
                     (int)framePaths.size() - framesIncluded, thresh);
        }
    }
    // Partial-success recovery (2026-07-01 f1/f2; hardened 2026-07-02 review):
    // when the ladder's final attempt FAILED or THREW but an EARLIER attempt
    // estimated OK, that earlier estimate is no longer loaded in the stitcher
    // (the failed final attempt overwrote its internal camera/transform
    // state), so composePanorama would have nothing valid to warp.  Re-apply
    // the best earlier attempt's exact tune and run ONE more estimateTransform
    // to RELOAD a working registration.  This can only ever REPLACE a
    // no-result-yet failure with a success — it has nothing to demote.
    //
    // We deliberately DO NOT recover when the final attempt SUCCEEDED with
    // fewer frames than an earlier attempt: that result is already a working
    // panorama loaded in the stitcher, and re-estimating to chase a few more
    // frames could itself fail and demote a good stitch to a hard error.
    // cv::Stitcher registration is NOT reproducible run-to-run (multi-threaded
    // bundle adjustment has no fixed reduction order and RANSAC draws from the
    // shared thread-local RNG), so a recovery re-run is never guaranteed to
    // reproduce the earlier frame count.  Keeping the working final result is
    // the safe, engineered-enough choice.
    bool recoveryAttempted = false;
    bool recoveryThrew = false;
    std::string recoveryFailureWhat;
    // singleRung guard is structural, not just belt-and-braces: a rung has
    // ONE attempt, so a failed rung has nothing earlier to recover (and the
    // outer ladder's per-rung tmp isolation is the replacement mechanism —
    // an earlier rung's good output is already safe on disk).
    if (!singleRung && status != cv::Stitcher::OK && bestAttempt >= 0) {
        recoveryAttempted = true;
        // Re-apply the best attempt's exact tune — including RESETTING
        // matcher/resol to attempt-1 defaults (default match_conf /
        // baseRegResol) when the best attempt didn't touch them.
        const RetryTune& best = attempts[static_cast<size_t>(bestAttempt)];
        stitcher->setPanoConfidenceThresh(best.thresh);
        if (cvMode == cv::Stitcher::PANORAMA) {
            // perf-3b — reproduce the BEST attempt's exact matcher.  With the
            // range ladder on, that means the range matcher at the best
            // attempt's own window (2 on attempts 1-2, config width on the
            // final attempt) + its match_conf — NOT the full matcher, or the
            // recovery wouldn't reproduce the attempt it claims to.
            if (config.rangeMatcherWidth > 0) {
                const int rw = (best.rangeWidth > 0)
                    ? best.rangeWidth
                    : std::max(2, config.rangeMatcherWidth);
                const float mc = (best.matchConf > 0.0f) ? best.matchConf : 0.3f;
                stitcher->setFeaturesMatcher(
                    cv::makePtr<cv::detail::BestOf2NearestRangeMatcher>(rw, false, mc));
            } else {
                stitcher->setFeaturesMatcher(
                    best.matchConf > 0.0f
                        ? cv::makePtr<cv::detail::BestOf2NearestMatcher>(false, best.matchConf)
                        : cv::makePtr<cv::detail::BestOf2NearestMatcher>(false));
            }
            stitcher->setRegistrationResol(
                best.regResolMP > 0.0
                    // Same cliff-safe ceiling as the forward ladder (:1057) — a
                    // true invariant across both registration-apply sites, so a
                    // future rung raised above the ceiling can't reach the cliff
                    // via the recovery re-estimate either. Caller override above
                    // the ceiling still wins (never clobbered down).
                    ? std::max(std::min(best.regResolMP, kLadderRegResolCeilingMP),
                               config.registrationResolMP)
                    : baseRegResol);
        }
        // Telemetry reflects what actually ran LAST (review f3): from here
        // on the stitcher state carries the best attempt's tune whether the
        // re-estimate succeeds or not.
        finalAttempt = bestAttempt + 1;
        finalThreshold = best.thresh;
        log_info(logFn, "[stitch-retry]",
                 "final attempt failed — recovering best attempt %d "
                 "(thresh=%.2f, previously %d frames)",
                 bestAttempt + 1, best.thresh, bestFrames);
        try {
            status = stitcher->estimateTransform(images);
        } catch (const cv::Exception& e) {
            recoveryThrew = true;
            recoveryFailureWhat = e.what() ? e.what() : "(no message)";
            status = cv::Stitcher::ERR_NEED_MORE_IMGS;
        }
        if (status == cv::Stitcher::OK) {
            framesIncluded = static_cast<int>(stitcher->component().size());
            result.errorCode = StitchErrorCode::Ok;
            result.errorMessage.clear();
        } else if (!recoveryThrew) {
            recoveryFailureWhat =
                "estimateTransform returned status code " +
                std::to_string(static_cast<int>(status));
        }
    }
    if (status != cv::Stitcher::OK) {
        // Review f3 — the error must tell the truth about recovery: that it
        // ran, which tune it re-applied, and why it failed.  A recovery
        // exception maps to UnknownCvException (not the generic
        // status-derived code).
        result.errorCode = recoveryThrew ? StitchErrorCode::UnknownCvException
                                         : statusToErrorCode(status);
        if (recoveryAttempted) {
            char threshBuf[32];
            std::snprintf(threshBuf, sizeof(threshBuf), "%.2f",
                          attempts[static_cast<size_t>(bestAttempt)].thresh);
            result.errorMessage =
                "Stitcher::estimateTransform failed across the " +
                std::to_string(kNumAttempts) +
                "-attempt ladder; best-attempt recovery (attempt " +
                std::to_string(bestAttempt + 1) + ", thresh=" + threshBuf +
                ", previously " + std::to_string(bestFrames) +
                " frames) was attempted and failed: " + recoveryFailureWhat;
        } else {
            result.errorMessage = "Stitcher::estimateTransform failed at all " +
                std::to_string(finalAttempt) + " thresholds, last status code " +
                std::to_string(static_cast<int>(status));
            if (!lastExceptionWhat.empty()) {
                result.errorMessage +=
                    "; last cv::Exception: " + lastExceptionWhat;
            }
        }
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    // 2026-08-17 review — cannot-improve early-out (ladder rungs only).  The
    // outer ladder's tie rule is strict-greater (ladderRungBeatsBest), so an
    // estimate that retained no more frames than the ladder's best so far can
    // NEVER be promoted — composing it would spend the expensive, jetsam-
    // relevant stage (warp + seam + multiband blend + JPEG encode) on output
    // that is discarded by construction.  Registration already ran (cheap
    // relative to compose); skip everything downstream.
    if (singleRung
        && retailens::rungCannotImproveBest(framesIncluded,
                                            config.ladderBestFramesSoFar)) {
        result.errorCode = StitchErrorCode::AllFramesDroppedByConfidence;
        result.errorMessage =
            "rung cannot improve best (" + std::to_string(framesIncluded) +
            " <= " + std::to_string(config.ladderBestFramesSoFar) +
            ") — compose skipped";
        log_info(logFn, "[stitch-retry]", "%s", result.errorMessage.c_str());
        return result;
    }

    // 2026-06-16 (review #2) — degenerate-canvas guard BEFORE composePanorama.
    // estimateTransform succeeded; composePanorama will now warp+blend a canvas
    // whose size is set by the estimated focals/rotations.  A divergent BA
    // produces absurd focals → a gigapixel canvas → an lmkd/jetsam kill MID
    // allocation that NO try/catch can intercept.  Project the warp-canvas union
    // from cameras() + the configured warper at the registration/WORK scale
    // (conservative: a valid pano is well under 1 MP here, so a union over
    // kMaxCanvasPixels (50 MP) is unambiguously degenerate) and abort cleanly
    // before the allocation.  Valid wide pans are bounded separately by the
    // RAM-aware compositingResol above, so this only fires on a divergent
    // estimate — the rung fails cleanly and the outer ladder moves on.
    //
    // 2026-08-17 — `effectiveWarperType` tracks the warper the compose will
    // ACTUALLY run with: the PRE-compose oversize reroute below swaps it in
    // place (setWarper before the FIRST compose — the estimate is warper-
    // independent, so that swap needs no re-estimation).  There is
    // deliberately NO post-compose in-place retry: cv::Stitcher clears
    // seam_est_imgs_ during its first composePanorama (OpenCV 4.10
    // stitcher.cpp:215 "Release unused memory"), so a second parameterless
    // compose on the same instance indexes a destroyed vector — UB.  A
    // validation failure that spherical geometry could fix is instead
    // retried by the OUTER ladder as a fresh reservation-gated spherical
    // extra rung (sphericalExtraRungWarranted).
    // `appliedComposeMP` tracks the compositing resolution after any budget
    // downscale: the collapsed-placement validator compares coverage against
    // a frame's area at the compose scale that actually ran.
    std::string effectiveWarperType = config.warperType;
    double appliedComposeMP = composeMP;
    try {
        const std::vector<cv::detail::CameraParams> cams = stitcher->cameras();
        if (!cams.empty()) {
            std::vector<double> focals;
            focals.reserve(cams.size());
            for (const auto& c : cams) focals.push_back(c.focal);
            std::sort(focals.begin(), focals.end());
            const double warpScale = focals[focals.size() / 2];  // median focal
            // v0.25 — project with the model the compose ACTUALLY uses (jetsam
            // RCA durable fix): SCANS hard-wires cv::AffineWarper internally,
            // so projecting its canvas with a rotation warper (the old
            // make_warper(config.warperType)) was geometrically invalid and
            // could under-estimate the real canvas — an effectively unguarded
            // compose on the SCANS leg.
            cv::Ptr<cv::WarperCreator> wc =
                (cvMode == cv::Stitcher::SCANS)
                    ? cv::Ptr<cv::WarperCreator>(cv::makePtr<cv::AffineWarper>())
                    : make_warper(effectiveWarperType);
            // v0.25 review — an unknown warperType returned null here, which
            // skipped the WHOLE guard (incl. reservation enforcement).  Project
            // with spherical instead: that is what cv::Stitcher actually
            // composes with when setWarper was skipped for the unknown name.
            if (!wc) wc = cv::makePtr<cv::SphericalWarper>();
            if (wc) {
                const double workScale = stitcher->workScale();
                // Per-frame ROI union projection at work scale — extracted so
                // the in-place spherical reroute below can re-project with the
                // bounded geometry without duplicating the loop.  Returns
                // false when there was nothing to project.
                auto projectUnion =
                    [&](const cv::Ptr<cv::detail::RotationWarper>& w,
                        int64_t& outW, int64_t& outH,
                        double& outCoveredAreaPx) -> bool {
                    int64_t minX = 0, minY = 0, maxX = 0, maxY = 0;
                    bool seeded = false;
                    outCoveredAreaPx = 0.0;
                    for (size_t i = 0; i < cams.size() && i < images.size(); ++i) {
                        cv::Mat K;
                        cams[i].K().convertTo(K, CV_32F);
                        const cv::Size workSz(
                            std::max(1, (int)std::lround(images[i].cols * workScale)),
                            std::max(1, (int)std::lround(images[i].rows * workScale)));
                        const cv::Rect roi = w->warpRoi(workSz, K, cams[i].R);
                        outCoveredAreaPx +=
                            static_cast<double>(roi.width) * roi.height;
                        if (!seeded) {
                            minX = roi.x; minY = roi.y;
                            maxX = (int64_t)roi.x + roi.width;
                            maxY = (int64_t)roi.y + roi.height;
                            seeded = true;
                        } else {
                            minX = std::min<int64_t>(minX, roi.x);
                            minY = std::min<int64_t>(minY, roi.y);
                            maxX = std::max<int64_t>(maxX, (int64_t)roi.x + roi.width);
                            maxY = std::max<int64_t>(maxY, (int64_t)roi.y + roi.height);
                        }
                    }
                    if (!seeded) return false;
                    outW = maxX - minX;
                    outH = maxY - minY;
                    return true;
                };
                cv::Ptr<cv::detail::RotationWarper> w =
                    wc->create(static_cast<float>(warpScale));
                int64_t unionW = 0, unionH = 0;
                double coveredAreaPx = 0.0;  // Σ per-frame ROI areas (v0.25 predictive check)
                const bool seeded =
                    projectUnion(w, unionW, unionH, coveredAreaPx);
                if (seeded && canvasExceedsGuard(unionW, unionH)) {
                    result.errorCode = StitchErrorCode::WarpFailed;
                    result.errorMessage =
                        "Degenerate high-level estimate: warp-canvas union " +
                        std::to_string(unionW) + "x" +
                        std::to_string(unionH) +
                        " px (work scale) exceeds guard — aborting before "
                        "composePanorama to avoid an OOM kill";
                    log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
                    return result;  // rung fails cleanly → outer ladder moves on
                }
                // v0.25 — PREDICTIVE utilization check (jetsam RCA durable fix):
                // the post-compose validator caught a divergent canvas only
                // AFTER ~1 GB of blend had been spent.  Run the same
                // utilization test on the PROJECTED geometry: Σ per-frame ROI
                // areas vs the union.  Σ areas double-counts overlap, so the
                // predicted utilization is an OVER-estimate — this check is
                // strictly LENIENT vs the post validator and only rejects
                // clearly-divergent canvases (union ≫ what the frames can
                // cover), never a legitimately-wide pan.  Rejection reuses the
                // LowQualityStitch code + "stitch validation" phrasing so the
                // JS classifier maps it to the same user guidance.
                if (seeded) {
                    const double unionAreaPx =
                        static_cast<double>(unionW) * static_cast<double>(unionH);
                    if (retailens::stitchOutputUnderutilized(
                            coveredAreaPx, unionAreaPx,
                            static_cast<int>(cams.size()))) {
                        result.errorCode = StitchErrorCode::LowQualityStitch;
                        result.errorMessage =
                            "stitch validation (predictive): projected frame "
                            "coverage " + std::to_string(coveredAreaPx / 1e6) +
                            " MP over a " + std::to_string(unionAreaPx / 1e6) +
                            " MP canvas union — divergent warp rejected before "
                            "composePanorama";
                        log_error(logFn, "[stitch]", "%s",
                                  result.errorMessage.c_str());
                        return result;  // rung fails cleanly → next rung
                    }
                }

                // 2026-06-16 (review #2 + on-device data) — VALID-but-large canvas
                // budget.  A wide PLANE pan peaked ~2520 MB on the 6 GB A35 (above
                // its red line; OOM on 4 GB) — not degenerate (passed the guard
                // above), just unbounded-plane-large.  Project the COMPOSE-scale
                // canvas from the work-scale union (same geometry; resolution
                // ratio composeScale/workScale) and, if it exceeds the RAM canvas
                // budget, bound it BEFORE composePanorama: downscale
                // compositingResol when a modest (≤2×) shrink suffices, else
                // swap the COMPOSE warper to spherical (its geometry bounds the
                // canvas at FULL resolution — data: ~5× lower peak).  This is
                // the manual path's composeCanvasBudgetMP downscale, ported to
                // high-level.  2026-08-17: the spherical arm is now an IN-PLACE
                // setWarper swap — the old return-to-wrapper re-ran the entire
                // stitch for a decision that only affects the compose stage.
                if (seeded && workScale > 0.0 && !images.empty()
                    && images[0].total() > 0) {
                    const double fullArea =
                        static_cast<double>(images[0].cols) * images[0].rows;
                    const double composeScale =
                        std::min(1.0, std::sqrt(composeMP * 1e6 / fullArea));
                    const double ratioCS = composeScale / workScale;
                    const double workCanvasMP =
                        static_cast<double>(unionW) * static_cast<double>(unionH)
                        / 1e6;
                    const double composeCanvasMP = workCanvasMP * ratioCS * ratioCS;
                    // v0.25 — the wrapper's reservation (if any) CAPS the
                    // canvas budget, so an admitted launch can never compose
                    // more than the headroom gate reserved for it (soundness by
                    // enforcement — the downscale below realises the cap).
                    const double deviceBudgetMP =
                        retailens::composeCanvasBudgetMP(totalRamMB);
                    const double canvasBudgetMP =
                        (config.rescueCanvasBudgetMPOverride > 0.0)
                            ? std::min(deviceBudgetMP,
                                       config.rescueCanvasBudgetMPOverride)
                            : deviceBudgetMP;
                    if (composeCanvasMP > canvasBudgetMP) {
                        const double over = composeCanvasMP / canvasBudgetMP;
                        // v0.25 — under SCANS the spherical arm is meaningless
                        // (SCANS hard-wires its affine warper; setWarper is
                        // skipped), so ALWAYS take the downscale branch there.
                        // A rescue reservation also always downscales — the
                        // reservation is the ceiling the gate measured to fit,
                        // whatever the overage.
                        if (over <= 2.0 || effectiveWarperType == "spherical"
                            || cvMode == cv::Stitcher::SCANS
                            // review: force the downscale only when the
                            // reservation actually TIGHTENS the budget — a
                            // healthy-process primary (cap == device budget)
                            // keeps the spherical arm, byte-identical to
                            // pre-reservation behaviour.
                            || (config.rescueCanvasBudgetMPOverride > 0.0
                                && config.rescueCanvasBudgetMPOverride
                                       < deviceBudgetMP)) {
                            // v0.25 review — invert with the EFFECTIVE compose
                            // MP: cv::Stitcher clamps composeScale at 1.0, so
                            // when keyframes are SMALLER than composeMP (640 px
                            // captures) a composeMP-based inversion is a no-op
                            // and the canvas would exceed the cap/reservation.
                            // effMP/over is exact in both regimes.
                            const double effMP =
                                std::min(composeMP, fullArea / 1e6);
                            const double targetMP = effMP / over;
                            stitcher->setCompositingResol(targetMP);
                            appliedComposeMP = targetMP;
                            log_info(logFn, "[stitch]",
                                     "canvas %.1f MP > budget %.1f MP (warp=%s) — "
                                     "downscaled compositingResol %.2f→%.2f MP",
                                     composeCanvasMP, canvasBudgetMP,
                                     effectiveWarperType.c_str(), composeMP,
                                     targetMP);
                        } else if (singleRung) {
                            // 2026-08-17 (flattened ladder) — IN-PLACE spherical
                            // reroute.  The old behaviour returned WarpFailed so
                            // the outer wrapper re-ran the WHOLE stitch with the
                            // spherical warper — pure waste: the warper plays no
                            // role in estimateTransform, only the compose
                            // consumes it.  Swap the compose warper here (no
                            // re-estimation) and re-apply the budget against the
                            // bounded spherical projection so the reservation
                            // cap still holds for what actually composes.
                            log_info(logFn, "[stitch-fallback]",
                                     "pre-compose spherical reroute (no "
                                     "re-estimation): canvas %.1f MP >> budget "
                                     "%.1f MP for warper '%s'",
                                     composeCanvasMP, canvasBudgetMP,
                                     effectiveWarperType.c_str());
                            effectiveWarperType = "spherical";
                            stitcher->setWarper(make_warper("spherical"));
                            cv::Ptr<cv::detail::RotationWarper> sw =
                                make_warper("spherical")->create(
                                    static_cast<float>(warpScale));
                            int64_t sphW = 0, sphH = 0;
                            double sphCovered = 0.0;
                            if (projectUnion(sw, sphW, sphH, sphCovered)) {
                                const double sphCanvasMP =
                                    static_cast<double>(sphW)
                                    * static_cast<double>(sphH)
                                    / 1e6 * ratioCS * ratioCS;
                                if (sphCanvasMP > canvasBudgetMP) {
                                    const double sphOver =
                                        sphCanvasMP / canvasBudgetMP;
                                    const double effMP =
                                        std::min(composeMP, fullArea / 1e6);
                                    const double targetMP = effMP / sphOver;
                                    stitcher->setCompositingResol(targetMP);
                                    appliedComposeMP = targetMP;
                                    log_info(logFn, "[stitch]",
                                             "spherical canvas %.1f MP > budget "
                                             "%.1f MP — downscaled "
                                             "compositingResol %.2f→%.2f MP",
                                             sphCanvasMP, canvasBudgetMP,
                                             composeMP, targetMP);
                                }
                            }
                        } else {
                            // Legacy multi-attempt path only (manual opt-in's
                            // Scans dispatch never reaches this rotation-warper
                            // arm, so this is defensive): >2× over on plane/
                            // cylindrical surfaces as a classified WarpFailed —
                            // nothing upstairs re-runs a spherical stitch any
                            // more.
                            result.errorCode = StitchErrorCode::WarpFailed;
                            result.errorMessage =
                                "high-level canvas " +
                                std::to_string(static_cast<int>(composeCanvasMP)) +
                                " MP >> budget " +
                                std::to_string(static_cast<int>(canvasBudgetMP)) +
                                " MP for warper '" + effectiveWarperType +
                                "' — spherical compose geometry required but "
                                "not configured";
                            log_info(logFn, "[stitch]", "%s",
                                     result.errorMessage.c_str());
                            return result;
                        }
                    }
                }
            }
        }
    } catch (const cv::Exception& e) {
        // warpRoi can itself throw on a degenerate camera — treat as degenerate.
        result.errorCode = StitchErrorCode::WarpFailed;
        result.errorMessage =
            std::string("Degenerate high-level estimate (warpRoi threw): ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    // Canvas bounded → compose.
    try {
        status = stitcher->composePanorama(panorama);
    } catch (const cv::Exception& e) {
        result.errorCode = StitchErrorCode::UnknownCvException;
        result.errorMessage =
            std::string("Stitcher::composePanorama threw: ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    if (status != cv::Stitcher::OK) {
        result.errorCode = statusToErrorCode(status);
        result.errorMessage = "Stitcher::composePanorama failed, status " +
            std::to_string(static_cast<int>(status));
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    log_info(logFn, "[dimstat]",
             "post-stitch panorama %dx%d %dch data=%.2f MB"
             " (framesIncluded=%d/%zu, finalThresh=%.2f, attempts=%d)",
             panorama.cols, panorama.rows, panorama.channels(),
             mat_mb(panorama),
             framesIncluded, framePaths.size(), finalThreshold, finalAttempt);
    log_memstat(logFn, memstat, "after_stitch");

    // ── 4.  Crop (coverage-aware inscribed rect, or bbox) ───────────
    // Pull cv::Stitcher's coverage mask (0xFF filled / 0 unfilled). It is
    // computed during stitch(), so this is free and exact — dark content
    // a frame painted is kept; only never-covered wedges drop.
    cv::Mat coverage;
    {
        const cv::UMat rm = stitcher->resultMask();
        if (!rm.empty()) {
            rm.copyTo(coverage);  // download UMat → Mat
        }
    }

    // Compose-scale MEAN frame area — the collapsed-placement yardstick.
    // Scale proof (verified against the pinned OpenCV 4.10.0,
    // modules/stitching/src/stitcher.cpp:247-251): composePanorama computes
    //   compose_scale = min(1.0, sqrt(compose_resol_ * 1e6 / full_img.area()))
    // ONCE, from the FIRST composed image, then warps every frame at that
    // scale — and the panorama canvas + resultMask() (the source of
    // validateStitchOutput's totalArea) are allocated at that same compose
    // scale.  So frame i contributes ~area_i * compose_scale^2 canvas px,
    // and the MEAN over frames is the right per-frame yardstick for
    // heterogeneous input sizes (a single frame's area would over- or
    // under-demand coverage).  appliedComposeMP reflects any budget
    // downscale applied above (setCompositingResol → compose_resol_), so
    // the yardstick tracks the scale that actually composed.
    double composedFrameAreaPx = 0.0;
    if (!images.empty()) {
        const double area0 =
            static_cast<double>(images[0].cols) * images[0].rows;
        double meanFullArea = 0.0;
        for (const cv::Mat& im : images) {
            meanFullArea += static_cast<double>(im.cols) * im.rows;
        }
        meanFullArea /= static_cast<double>(images.size());
        const double composeScaleSq =
            (area0 > 0.0) ? std::min(1.0, appliedComposeMP * 1e6 / area0)
                          : 0.0;
        composedFrameAreaPx = meanFullArea * composeScaleSq;
    }

    // 2026-06-16 (high-level safety) — validate the output BEFORE cropping/
    // writing.  The manual path has had this since v0.16; the high-level path
    // shipped whatever cv::Stitcher produced.  Rejects the black-canvas /
    // fragmented-output / collapsed-placement failures as LowQualityStitch so
    // the host can surface a "retry" instead of a broken image.  Fails open
    // on an empty coverage mask.  The collapsed-placement leg is armed ONLY
    // for flattened-ladder SCANS rungs (retailens::collapsedCheckArmed): the
    // legacy multi-attempt path — including the manual opt-in's high-level
    // SCANS dispatch, which is promised byte-identical — must not gain a new
    // rejection, and PANORAMA captures legitimately re-cover the same area
    // (pan-back / stationary-hold force-accepts), where a frame-count-linear
    // coverage floor would deterministically false-reject valid output.  A
    // reject here simply fails THIS rung; any spherical salvage is the OUTER
    // ladder's job as a fresh extra rung — never a second compose on this
    // stitcher instance (UB; see the effectiveWarperType note above).
    {
        std::string validateMessage;
        const double collapsedYardstickPx =
            retailens::collapsedCheckArmed(singleRung, config.stitchMode)
                ? composedFrameAreaPx : 0.0;
        const StitchErrorCode validateCode = validateStitchOutput(
            panorama, coverage, framesIncluded, collapsedYardstickPx, logFn,
            validateMessage);
        if (validateCode != StitchErrorCode::Ok) {
            result.errorCode = validateCode;
            result.errorMessage = validateMessage;
            log_error(logFn, "[stitch]", "high-level REJECTED — %s",
                      validateMessage.c_str());
            return result;
        }
    }

    cv::Mat cropMask;
    const cv::Rect cropRect = choose_crop_rect(
        panorama, coverage, config.useInscribedRectCrop, logFn, cropMask);
    cv::Mat cropped = panorama(cropRect).clone();
    // Crop the binary mask to the same rect → coverage sidecar (debug).
    cv::Mat croppedCoverage;
    if (cropMask.size() == panorama.size()) {
        croppedCoverage = cropMask(cropRect).clone();
    }
    log_info(logFn, "[dimstat]",
             "post-crop %dx%d → %dx%d data=%.2f MB (inscribedRect=%d, coverage=%d)",
             panorama.cols, panorama.rows, cropped.cols, cropped.rows,
             mat_mb(cropped),
             config.useInscribedRectCrop ? 1 : 0,
             coverage.empty() ? 0 : 1);
    log_memstat(logFn, memstat, "after_crop");

    // ── 5.  Bake rotation per capture orientation ───────────────────
    cv::Mat final_image = bake_rotation(cropped, config.captureOrientation, logFn);
    log_info(logFn, "[dimstat]",
             "post-bake_rotation %dx%d data=%.2f MB",
             final_image.cols, final_image.rows, mat_mb(final_image));
    log_memstat(logFn, memstat, "after_bake_rotation");

    // ── 6.  Write JPEG ──────────────────────────────────────────────
    const int q = std::max(0, std::min(100, config.jpegQuality));
    std::vector<int> params{cv::IMWRITE_JPEG_QUALITY, q};
    bool wrote = false;
    try {
        wrote = cv::imwrite(outputPath, final_image, params);
    } catch (const cv::Exception& e) {
        result.errorCode = StitchErrorCode::ImageWriteFailed;
        result.errorMessage = std::string("cv::imwrite threw: ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    if (!wrote) {
        result.errorCode = StitchErrorCode::ImageWriteFailed;
        result.errorMessage = "cv::imwrite returned false (path=" + outputPath + ")";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    log_info(logFn, "[stitch]",
             "output written: %s (%dx%d)",
             outputPath.c_str(), final_image.cols, final_image.rows);
    log_memstat(logFn, memstat, "after_imwrite");

    // Best-effort coverage sidecar (<output>.coverage.png), bake-rotated
    // to align with the JPEG, for the debug harness. Never fails stitch.
    if (!croppedCoverage.empty()) {
        try {
            const cv::Mat coverageRotated =
                bake_rotation(croppedCoverage, config.captureOrientation, logFn);
            cv::imwrite(outputPath + ".coverage.png", coverageRotated);
        } catch (...) {
            // sidecar is debug-only — ignore failures
        }
    }

    // ── 7.  Fill the result ─────────────────────────────────────────
    const auto t1 = std::chrono::steady_clock::now();
    result.success                = true;
    result.errorCode              = StitchErrorCode::Ok;
    result.width                  = final_image.cols;
    result.height                 = final_image.rows;
    result.framesIncluded         = framesIncluded;
    result.finalConfidenceThresh  = finalThreshold;
    result.durationMs             = std::chrono::duration_cast<std::chrono::milliseconds>(
                                       t1 - t0).count();
    // DEV overlay — cv::Stitcher owns its own compositing; for PANORAMA mode it
    // uses GraphCut seams + MultiBand blend by default.  `warp=` reports the
    // warper that ACTUALLY composed (effectiveWarperType — may be spherical
    // after the pre-compose oversize reroute, or because the outer ladder's
    // spherical extra rung launched this run with warperType=spherical).
    // Surfaced on the preview in __DEV__.
    result.debugSummary =
        std::string("pipe=highlevel;warp=") + effectiveWarperType +
        ";route=batch;seam=graphcut;blend=multiband";
    return result;
    } catch (const cv::Exception& e) {
        result.success = false;
        result.errorCode = StitchErrorCode::UnknownCvException;
        result.errorMessage =
            std::string("high-level cv::Exception (uncaught): ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    } catch (const std::exception& e) {
        // std::bad_alloc from cv::Stitcher's STL internals lands here (it is NOT
        // a cv::Exception).  Clean error instead of std::terminate.
        result.success = false;
        result.errorCode = StitchErrorCode::UnknownCvException;
        result.errorMessage =
            std::string("high-level std::exception (likely OOM): ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    } catch (...) {
        result.success = false;
        result.errorCode = StitchErrorCode::UnknownCvException;
        result.errorMessage = "high-level unknown exception (uncaught)";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
}


// ════════════════════════════════════════════════════════════════════
// stitchFramePathsManual — manual cv::detail::* pipeline
// ════════════════════════════════════════════════════════════════════
//
// Ported from OpenCVStitcher.mm:stitchFramePaths: (the iOS-only
// ~1500-line method, lines 401-1911 of that file).  Pipeline matches
// cv::Stitcher::PANORAMA's internal algorithm EXCEPT we drive the
// stages ourselves so we can:
//
//   * Run GraphCutSeamFinder at SEAM_MP (0.1) instead of compose_MP —
//     ~100× faster.
//   * Re-warp at COMPOSE_MP (0.6-1.0) after BA runs at REGISTRATION_MP
//     (0.3) — gives us back the cylindrical-era sharpness without the
//     OOM that comes from composing at ORIG_RESOL.
//   * Retry leaveBiggestComponent at PRUNE granularity (cheap) rather
//     than around the whole stitch (cv::Stitcher's C+D approach).
//   * Catch BA exceptions and fall back to estimator cameras instead
//     of aborting.
//
// The 9-step structure is preserved with the original Step N: comments
// so iOS↔shared traceability stays intact.  Many of the comments
// reference iOS-specific incidents (V12.x / V16 phases, Ram's traces,
// Console.app rate-limit behaviour) — those references are KEPT so
// the institutional memory survives the port.  The behaviours they
// describe still apply on Android too: cv::resize allocator state,
// jetsam/lmkd-equivalent OOM-kill behaviour, BA convergence failures
// on landscape inputs.
//
// Notable differences from the iOS original:
//   * No @autoreleasepool / ARC machinery — pure C++ stack semantics
//     handle scope-exit cleanup.  The fix-10 "capture failure into
//     strong local + break out of pool" pattern collapses to ordinary
//     C++ early-returns / goto-style break-out — we use a do/while(0)
//     wrapper so all failure paths set the result and `break` once.
//   * No os_log — replaced with the shared log_info/log_error
//     callbacks.  Originals' OS_LOG_TYPE_FAULT importance is encoded
//     by routing to log_error.
//   * No `loadFramesOrFail` helper — we inline the cv::imread loop
//     because the shared cpp/stitcher.cpp already has its own loader
//     pattern for the high-level path; reusing the same approach
//     keeps the file's load-error handling consistent.
//   * No EXIF-tag JPEG writer (WriteJPEGWithEXIFTag).  iOS's
//     ImageIO-backed writer baked an EXIF Orientation=1 tag into the
//     output.  We rely on `bake_rotation` already rotating the pixels
//     in-place, so a tag-less cv::imwrite gives the same visual
//     result on iOS image renderers.  When this function is wired to
//     iOS, if EXIF=1 is required, the iOS bridge can re-encode after
//     the fact OR we add an EXIF-aware writer to the shared layer
//     (see TODO[shared-stitcher-port-part-2] below).
//   * Pre-stitch memory abort + kMaxFramesForStitch=8 frame cap are
//     KEPT — they exist for the same reason on both platforms.
//   * StitcherResidentMB / phys_footprint reporting collapses to the
//     existing rss_mb() helper.  rss_mb reads /proc/self/statm which
//     works on both Android (Linux procfs) and iOS (procfs is mounted
//     in the simulator at least; on real device this falls back to
//     -1.0 which is harmless).  TODO below covers re-introducing the
//     mach task_info path if iOS reports -1 in production traces.
StitchResult stitchFramePathsManual(
    const std::vector<std::string>& framePaths,
    const std::string&              outputPath,
    const StitchConfig&             config,
    LogFn                           logFn)
{
    const auto t0 = std::chrono::steady_clock::now();
    StitchResult result;
    result.framesRequested = static_cast<int32_t>(framePaths.size());

    // V12.14.2 — FAULT-level sentinel.  Survives Console.app rate-limit;
    // proves the function entered.  If a future trace doesn't show this
    // line for a crashed run, the crash is BEFORE stitchFramePaths
    // (e.g., in extractFramesFromVideoAtPath or in the dispatch_async
    // block in StitcherBridge).
    const double kStartResidentMB = rss_mb();
    log_info(logFn, "[stitch-bc]",
             "STITCH START: %zu frames mem=%.1fMB",
             framePaths.size(), kStartResidentMB);
    // 2026-05-18 (Iss #1 diag): mirror the high-level path's entry log so we
    // can verify captureOrientation propagation through the manual pipeline.
    // The high-level entry logs "orientation=" at line 280-290; the manual
    // path was silent on this field, leaving us unable to tell, from a
    // device-log dump alone, whether bake_rotation got the right input.
    log_info(logFn, "[stitch]",
             "stitchFramePathsManual: frames=%zu warper=%s blender=%s seam=%s "
             "orientation=%s quality=%d inscribedRect=%d",
             framePaths.size(),
             config.warperType.c_str(),
             config.blenderType.c_str(),
             config.seamFinderType.c_str(),
             config.captureOrientation.c_str(),
             config.jpegQuality,
             config.useInscribedRectCrop ? 1 : 0);

    // V16 Phase 1b.fix1 — device-aware pre-stitch memory abort.
    //
    // Original V12.14.8 fixed the threshold at 700 MB, sized for legacy
    // iPhones (~2 GB total RAM, ~720 MB jetsam kill point on camera-
    // active foreground apps).  That ceiling is irrelevant on modern
    // hardware: iPhone 16 Pro has 8 GB RAM and a per-process limit of
    // ~3 GB on iOS 26 (confirmed by JetsamEvent at 3.38 GB).
    //
    // Also, the V12.14.8 assumption — "vision-camera CameraView is
    // unmounted before stitch, so baseline drops to ~350-450 MB" —
    // doesn't hold for the V16 batch-keyframe flow, where the AR
    // session keeps running during stitch (baseline naturally 600-800
    // MB).  AR pause is now done at the bridge level (Phase 1b.fix1
    // in IncrementalStitcher.swift), but even with that, the
    // 700 MB threshold throttles modern devices for no reason.
    //
    // New formula: max(700, totalRAMGB × 300).  Leaves ~30% headroom
    // below the per-process limit for the stitch peak.
    //   2 GB device → 700  MB threshold (clamped, legacy protection)
    //   4 GB device → 1200 MB
    //   6 GB device → 1800 MB
    //   8 GB device → 2400 MB
    //
    // Total-RAM source: prefer the caller-provided StitchConfig::
    // availableRamMB (plumbed via NSProcessInfo.processInfo.physicalMemory
    // on iOS, ActivityManager.getMemoryInfo().totalMem or sysconf on
    // Android — see the StitchConfig field doc in stitcher.hpp).  When
    // the caller leaves the sentinel, fall back to the conservative
    // 4 GB assumption so the threshold lands at 1200 MB — high enough
    // not to throttle real devices, low enough to still abort on
    // degenerate baselines.  Plumbing the actual physicalMemory is
    // important on modern iOS hardware: iPhone 16 Pro has 8 GB → 2400
    // MB threshold (real-device headroom) rather than 1200 MB (legacy
    // protection that caps a high-RAM device at low-RAM headroom).
    const double kAssumedTotalRAMGB = 4.0;
    // Single source of truth for device RAM, shared by the pre-stitch abort
    // AND the step-7.7 canvas budget below.  Prefer the caller's value (iOS
    // plumbs NSProcessInfo.physicalMemory); else read it natively (Android
    // sets none); else fall back to the conservative 4 GB assumption.
    double totalRamMB = (config.availableRamMB > 0.0)
        ? config.availableRamMB
        : device_total_ram_mb();
    if (totalRamMB <= 0.0) totalRamMB = kAssumedTotalRAMGB * 1024.0;

    // Issue 6 — headroom-scoped pre-stitch gate (replaces the old flat
    // `max(700, ram×300)` RSS ceiling).
    //
    // The old check compared whole-process RSS against a flat fraction of
    // DEVICE RAM, so a memory-heavy HOST app could trip it even when the
    // stitch itself was tiny — and on trip it HARD-ABORTED with no attempt
    // to route to the lighter STREAM path.  We can't isolate the stitch's
    // own allocation from the shared process RSS (OpenCV uses malloc; no
    // per-library accounting), so instead of a device ceiling we reason
    // about HEADROOM: estimate the per-process kill ceiling
    // (perProcessMemoryBudgetMB) and abort here ONLY when the process is
    // already so close to it that even a MINIMAL streaming stitch
    // (kMinStreamStitchMB) won't fit on top of the current footprint.  That
    // makes this a genuine last resort scoped to the stitch's minimal
    // incremental demand — a heavy host with headroom remaining proceeds,
    // and everything in between is handled downstream by the step-8 STREAM
    // routing (now also headroom-aware — see lowBatchHeadroom there) and the
    // step-7.7 canvas-budget downscale, which size to what the stitch needs
    // rather than aborting.
    const double perProcessBudgetMB =
        retailens::perProcessMemoryBudgetMB(totalRamMB);
    if (retailens::stitchExceedsMinimalHeadroom(kStartResidentMB, totalRamMB)) {
        log_error(logFn, "[stitch-bc]",
                  "PRE-STITCH ABORT: rss=%.1fMB + minStitch=%.0fMB > "
                  "perProcessBudget=%.1fMB (totalRamMB=%.0f) — no headroom for "
                  "even a minimal streaming stitch",
                  kStartResidentMB, retailens::kMinStreamStitchMB,
                  perProcessBudgetMB, totalRamMB);
        // Sentinel return: success=false + stable code so both bridges see a
        // clean failure.  Classified to STITCH_OOM in JS (classifyStitchError
        // matches "memory abort").
        result.errorCode = StitchErrorCode::PreStitchMemoryAbort;
        result.errorMessage =
            "Pre-stitch memory abort: insufficient headroom for the stitch";
        // framesIncluded reflects best-known retained count at the
        // abort site — nothing has been loaded or matched yet.
        result.framesIncluded = 0;
        return result;
    }

    if (framePaths.empty()) {
        result.errorCode = StitchErrorCode::InvalidArgument;
        result.errorMessage = "Need at least 1 frame to stitch (got 0)";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    if (framePaths.size() == 1) {
        return writeSingleFrameResult(
            result, framePaths[0], outputPath, config, logFn, "single-frame stitch (manual)");
    }
    if (outputPath.empty()) {
        result.errorCode = StitchErrorCode::InvalidArgument;
        result.errorMessage = "outputPath must not be empty";
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    // V12.14.2 — defensive frame cap.  Ram's V12.14 traces showed a
    // landscape capture with 12 frames (144 pairwise) crash inside
    // BundleAdjusterRay.  A 7-frame capture (49 pairwise) succeeded.
    // Above ~10 frames the BA solver becomes unstable on landscape
    // inputs — most likely the Levenberg-Marquardt Jacobian conditions
    // get bad with the wider aspect ratio + more pairwise constraints.
    // Cap framePaths to kMaxFramesForStitch evenly-spaced indices
    // BEFORE the imread loop so we don't even pay the imread cost
    // for the discarded frames.  Trade-off: long pans get slightly
    // less overlap (a 5-second pan at 3 fps = 15 frames is downsampled
    // to 8 evenly-spaced).  Quality regression is minor; stability is
    // huge — this kills the EXC_BAD_ACCESS deterministically.
    static const size_t kMaxFramesForStitch = 8;
    std::vector<std::string> workFramePaths = framePaths;
    if (workFramePaths.size() > kMaxFramesForStitch) {
        std::vector<std::string> downsampled;
        downsampled.reserve(kMaxFramesForStitch);
        const size_t origCount = workFramePaths.size();
        for (size_t i = 0; i < kMaxFramesForStitch; i++) {
            size_t idx = (i * (origCount - 1)) / (kMaxFramesForStitch - 1);
            downsampled.push_back(workFramePaths[idx]);
        }
        workFramePaths = std::move(downsampled);
        log_info(logFn, "[stitch-bc]",
                 "downsampled %zu -> %zu frames (BA stability cap)",
                 origCount, kMaxFramesForStitch);
    }

    // Load all input frames before invoking the stitcher.  Memory cost is
    // N × decoded frame size.  NOTE — keyframe resolution is PLATFORM-SPLIT
    // (verified 2026-06): on Android the keyframe JPEGs are pre-clamped to
    // ~640px long edge at encode time (YuvImageConverter; the
    // AR_KEYFRAME_MAX_LONG_EDGE guard fires on dimensions, so it covers the
    // NON-AR path too), so the resident footprint here is ~0.3 MP/frame
    // regardless of the chosen capture format.  On iOS the keyframes are
    // written at NATIVE capture resolution (OpenCVKeyframeCollector, no
    // clamp), so the footprint scales with the selected video format.
    //
    // V12.13 — breadcrumb each load.  If the landscape-only crash is
    // in cv::imread (e.g., decoding a JPEG produced by the new
    // per-frame autoreleasepool extract) the LAST log line tells us
    // which frame index + path triggered it.
    std::vector<cv::Mat> frames;
    frames.reserve(workFramePaths.size());
    for (size_t idx = 0; idx < workFramePaths.size(); ++idx) {
        const std::string& path = workFramePaths[idx];
        cv::Mat img = cv::imread(path);
        log_info(logFn, "[stitch-bc]",
                 "loadFrames %zu/%zu: %s -> %dx%d (channels=%d, empty=%d)",
                 idx, workFramePaths.size(),
                 path.c_str(), img.cols, img.rows, img.channels(),
                 (int)img.empty());
        if (img.empty()) {
            result.errorCode = StitchErrorCode::ImageReadFailed;
            result.errorMessage = "Could not read image at path: " + path;
            log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
            // framesIncluded reflects best-known retained count at the
            // abort site — number of frames successfully loaded so far.
            result.framesIncluded = static_cast<int32_t>(frames.size());
            return result;
        }
        frames.push_back(img);
    }

    // ── Hand-rolled stitch via cv::detail::* with CylindricalWarper ────
    //
    // The high-level cv::Stitcher::PANORAMA uses SphericalWarper, which
    // produces the "panorama bowl" shape on short shelf-scan arcs.
    // Calling setWarper(CylindricalWarper) on the high-level stitcher
    // crashes (PANORAMA's BundleAdjusterRay's R-matrix outputs are
    // structured for spherical warp).  So we drive the pipeline
    // ourselves, replicating PANORAMA's algorithm exactly EXCEPT we
    // swap the warper at the end.  This is also the same path
    // Phase 5 will populate with AR-derived poses (skipping
    // features→matching→BA when poses are known).
    //
    // Pipeline:
    //   1. ORB features per frame
    //   2. BestOf2NearestMatcher (PANORAMA's default)
    //   3. HomographyBasedEstimator → camera initial guesses
    //   4. BundleAdjusterRay (PANORAMA's default) refines cameras
    //   5. CylindricalWarper warps each frame using cameras
    //   6. GraphCutSeamFinder + MultiBandBlender produce final panorama
    cv::Mat panorama;
    // v0.15 — the blender's dst_mask (TRUE frame coverage) hoisted to
    // outer scope so the crop + sidecar below use it instead of a
    // brightness threshold (which drops dark content like a mirror).
    cv::Mat coverageMask;
    // Breadcrumbs in the device console.  If the next stitch
    // crashes, the last logged step pinpoints the failure point —
    // makes debugging without Xcode much faster.  Prefix is
    // grep-able in Console.app / logcat.
    log_info(logFn, "[BatchStitcher]", "start: %zu frames", frames.size());

    // V16 fix-10 (2026-05-13) — STRUCTURAL: NO return statement
    // executes inside the @autoreleasepool block.  Failure paths
    // capture the result into a strong local declared above the
    // pool, then `break` out of the do/while(0) wrapper.  In the
    // pure-C++ port the @autoreleasepool is gone (no ObjC autorelease
    // semantics), but we keep the do/while(0) wrapper so all failure
    // paths converge on a single result-construction site below.
    // Helps reading + matches the iOS original's control flow line
    // for line.
    bool failedInsidePool = false;
    bool sentinelInsidePool = false;
    StitchErrorCode capturedErrorCode = StitchErrorCode::UnknownCvException;
    std::string     capturedErrorMessage;

    do {
    try {
        // Two-stage resolution pipeline (matches cv::Stitcher::PANORAMA):
        //
        //   REGISTRATION_MP (0.3): downscale used for features, matching,
        //   BA, wave-correct.  The expensive optimisation stages run
        //   here.  cv::Stitcher uses 0.6; we use 0.3 because BA still
        //   converges reliably on shelf-scan inputs and the smaller
        //   matrices make BA noticeably faster on iPhone.
        //
        //   COMPOSE_MP (0.6-1.0): RE-WARP + blend at this larger resolution
        //   to produce the FINAL panorama.  cv::Stitcher uses ORIG_RESOL
        //   (full input size) — gorgeous output but iPhones at 12 MP × N
        //   frames blow past the jetsam threshold.  1.0 MP is the
        //   sweet spot: ~2× linear sharpness over single-stage 0.3 MP,
        //   with peak compose memory still under ~120 MB thanks to the
        //   per-frame release pattern in the blender feed loop below.
        //
        // The cylindrical-era sharpness came from cv::Stitcher's
        // automatic two-stage flow.  When we hand-rolled the pipeline
        // to use PlaneWarper safely, we collapsed it to a single 0.3 MP
        // stage — output went from ~1500×800 (cylindrical) to ~700×400
        // (plane).  This restores the multi-stage structure while
        // keeping the PlaneWarper that the host app actually wants.
        //
        // V2 port note: registrationResolMP / compositingResolMP from
        // StitchConfig let the CALLER override the defaults if needed
        // (e.g., a low-RAM device build can drop COMPOSE_MP to 0.4).
        // When config values are <0, we use the hard-coded defaults
        // ported from the iOS original (REGISTRATION=0.3, COMPOSE=0.6).
        const double REGISTRATION_MP = (config.registrationResolMP > 0.0)
            ? config.registrationResolMP : 0.3;
        // 0.6 MP matches cv::Stitcher::PANORAMA's registration_resol
        // default and is the "safe sharp" setting on Debug builds —
        // 1.0 MP was visibly sharper but pushed memory peak into iOS
        // jetsam territory (Sentry caught WatchdogTermination + the
        // EXC_BAD_ACCESS-during-tear-down variant under the same root
        // cause).  Release builds free ~200-300 MB of RN baseline
        // overhead and would tolerate 1.0 MP fine; if/when a Release
        // build is the test target, bump this back up.
        const double COMPOSE_MP = (config.compositingResolMP > 0.0)
            ? config.compositingResolMP : 0.6;

        // Capture original size BEFORE downscaling — we need it later
        // to compute the compose scale relative to full-res input.
        int origCols = frames[0].cols;
        int origRows = frames[0].rows;
        double origMp = (double)origCols * origRows / 1e6;

        // Stage 1: downscale to REGISTRATION_MP for features+matching+BA.
        std::vector<cv::Mat> workFrames;
        workFrames.reserve(frames.size());
        double work_scale = (origMp > REGISTRATION_MP)
            ? std::sqrt(REGISTRATION_MP / origMp)
            : 1.0;
        if (work_scale < 1.0) {
            for (const auto& f : frames) {
                cv::Mat scaled;
                cv::resize(f, scaled, cv::Size(), work_scale, work_scale,
                           cv::INTER_AREA);
                workFrames.push_back(scaled);
            }
        } else {
            for (const auto& f : frames) workFrames.push_back(f);
        }

        log_info(logFn, "[BatchStitcher]",
                 "step1: features (work scale %d×%d)",
                 workFrames.empty() ? 0 : workFrames[0].cols,
                 workFrames.empty() ? 0 : workFrames[0].rows);
        // V12.14 Commit B — paired fprintf(stderr) breadcrumb.  iOS'
        // Console.app rate-limits NSLog under high-frequency emission
        // (Ram's V12.13 trace had loadFrames 4-7 + step1 missing while
        // loadFrames 0-3 + step2 made it through).  Stderr is not rate-
        // limited and flushes promptly, so the LAST stderr line before
        // the crash reliably pinpoints the failing stage.
        //
        // Shared-port note: this breadcrumb collapses to log_info under
        // the shared LogFn callback.  On Android the bridge sinks to
        // logcat (not rate-limited).  On iOS, when wired up, the bridge
        // can still emit to os_log if rate-limit pressure returns.
        log_info(logFn, "[stitch-bc]",
                 "step1 enter (work %d×%d, %zu frames)",
                 workFrames.empty() ? 0 : workFrames[0].cols,
                 workFrames.empty() ? 0 : workFrames[0].rows,
                 workFrames.size());

        // Step 1: features.  800 ORB features is enough for matching
        // ~50% overlap between adjacent frames; 1500 was overkill and
        // doubled the matching work for marginal quality gain.
        auto featuresFinder = cv::ORB::create(800);
        std::vector<cv::detail::ImageFeatures> imgFeatures(workFrames.size());
        for (size_t i = 0; i < workFrames.size(); i++) {
            cv::detail::computeImageFeatures(featuresFinder, workFrames[i],
                                             imgFeatures[i]);
            imgFeatures[i].img_idx = (int)i;
            log_info(logFn, "[stitch-bc]",
                     "step1 frame %zu: %zu features",
                     i, imgFeatures[i].keypoints.size());
        }
        log_info(logFn, "[stitch-bc]", "step1 done");

        // Step 2: pairwise matching.  match_conf=0.65 matches what
        // cv::Stitcher::PANORAMA uses internally — looser values
        // (counter-intuitively) hurt BA convergence by letting through
        // contradictory low-confidence matches that don't fit a
        // consistent rotation model.  Stick with the proven default.
        // V16 fix-11 (2026-05-13) — REVERTED the AffineBestOf2NearestMatcher
        // swap.  The swap (commit 505c6f1) targeted the validPairs=0
        // symptom on translation-heavy captures, but produced a downstream
        // regression: "Warp stage failed: matrix.cpp:246 setSize s >= 0".
        //
        // Root cause: cv::Stitcher's pipeline has TWO coherent end-to-end
        // modes documented in OpenCV:
        //
        //   PANORAMA: BestOf2NearestMatcher → HomographyBasedEstimator →
        //             BundleAdjusterRay → SphericalWarper/etc.
        //             All stages assume rotation-only camera motion.
        //
        //   SCANS:    AffineBestOf2NearestMatcher → AffineBasedEstimator →
        //             BundleAdjusterAffinePartial → AffineWarper.
        //             All stages assume affine (rotation+translation+
        //             scale+shear) camera motion.
        //
        // Swapping ONLY step 2 to affine while keeping the rotation-only
        // estimator/BA/warper downstream produced incoherent camera
        // parameters: the affine matcher passed inliers with parallax-
        // induced inconsistencies that the rotation estimator turned into
        // non-orthonormal "rotation" matrices.  The warper then computed
        // negative destination canvas sizes and the cv::Mat::setSize
        // assertion fired at step 8b.
        //
        // Fix: revert to BestOf2NearestMatcher so the WHOLE pipeline is
        // coherent in PANORAMA mode.  Translation-heavy captures fall
        // back to validPairs=0 → sentinel result → clean toast (no
        // crash, thanks to fix-10's @autoreleasepool restructure).  The
        // gate's translation-budget force-accept (`flowMaxTranslationCm`
        // in Settings) is the operator's lever to keep per-pair
        // translation small enough that BestOf2NearestMatcher's
        // rotation-homography RANSAC produces useful inliers.
        //
        // Longer-term: see docs/site-content/design/2026-05-13-stitch-
        // pipeline-mode-selection.md for the architectural answer —
        // motion-classified per-capture routing between PANORAMA and
        // SCANS modes at finalize() time.
        log_info(logFn, "[BatchStitcher]", "step2: matching");
        log_info(logFn, "[stitch-bc]",
                 "step2 enter: BestOf2Nearest matching (PANORAMA mode — coherent end-to-end)");
        cv::detail::BestOf2NearestMatcher matcher(false, 0.65f);
        std::vector<cv::detail::MatchesInfo> pairwise;
        matcher(imgFeatures, pairwise);
        matcher.collectGarbage();
        log_info(logFn, "[stitch-bc]",
                 "step2 done: %zu pairwise entries", pairwise.size());

        // Step 3: leave-best-of-2 keeps only well-connected images at
        // confThresh=1.0 — also matches cv::Stitcher::PANORAMA's
        // default.  Pairs with weaker overlap get dropped before BA.
        // Pre-check: count how many pairwise matches actually have
        // non-trivial features matched.  cv::Stitcher's
        // leaveBiggestComponent / HomographyBasedEstimator fire
        // CV_Assert internally if no useful pairwise data exists —
        // and CV_Assert can SIGABRT in our build (signal not caught
        // by C++ try/catch).  Throwing our own structured error here
        // is the only way to fail-fast before that abort.
        int validPairs = 0;
        for (const auto& m : pairwise) {
            if (m.confidence > 0.0 && m.matches.size() >= 6) {
                validPairs++;
            }
        }
        log_info(logFn, "[BatchStitcher]",
                 "step2.5: %d valid pairwise matches", validPairs);
        if (validPairs < 1) {
            // V16 fix-attempt 9 (NULL TEST, 2026-05-13).  Eight prior
            // attempts chased a deterministic SEGV inside Swift's try-bridge
            // on this *error→throw path.  ASan-on-device with Sentry
            // disabled (incident-2026-05-13-172125.ips) showed
            // EXC_BAD_ACCESS at 0x60007a530 (UNMAPPED VM, ASan
            // ReportDeadlySignal — no shadow-memory match) firing inside
            // objc_retain immediately after this return.  By returning a
            // non-nil SENTINEL result (width=0, height=0) instead of
            // populating *error and returning nil, we bypass Swift's
            // autoreleasing NSError out-parameter retain entirely.  The
            // Swift caller in IncrementalStitcher.finalize checks
            // `r.width == 0` and constructs a Swift-native NSError to pass
            // to its completion block.
            //
            // Hypothesis under test:
            //   (A) If this path no longer crashes → the throw bridge IS
            //       the proximate trigger.  Permanent: keep sentinel,
            //       document why.
            //   (B) If it still crashes the same way → corruption is
            //       upstream of our return (likely inside opencv2.framework
            //       stitcher allocator pool).  Revert and escalate to C3
            //       (stitch on isolated DispatchQueue).
            //
            // See: docs/site-content/design/2026-05-12-finalize-crash-investigation.md
            //
            // Shared-port note: in the pure-C++ port there is no ObjC
            // autoreleasing-NSError pad, so the historical reason for
            // the sentinel is gone.  We still mark success=false +
            // emit a structured StitchErrorCode so the JS layer sees
            // a clean failure (it's the JS surface that surfaces the
            // "all frames dropped" toast).  Kept the long comment
            // because it explains WHY the iOS bridge added a
            // sentinel-path check — historically valuable.
            log_error(logFn, "[BatchStitcher]",
                      "step2.5: 0 valid pairs — sentinel result (port: signalling AllFramesDroppedByConfidence)");
            capturedErrorCode = StitchErrorCode::AllFramesDroppedByConfidence;
            capturedErrorMessage = "Stitcher found 0 valid pairwise matches — frames may not overlap enough.";
            // framesIncluded reflects best-known retained count at the
            // abort site — pre-prune so all loaded frames are still in
            // play even though none have valid pairwise overlap.
            result.framesIncluded = static_cast<int32_t>(imgFeatures.size());
            sentinelInsidePool = true;
            break;
        }

        log_info(logFn, "[BatchStitcher]", "step3: leave-biggest");
        log_info(logFn, "[stitch-bc]", "step3 enter: leave-biggest");
        // leaveBiggestComponent mutates imgFeatures and pairwise IN
        // PLACE to drop frames that aren't part of the biggest
        // connected component.  We MUST also subset workFrames to
        // match — otherwise cameras.size() (built from the trimmed
        // imgFeatures) will be smaller than workFrames.size() and the
        // warp loop reads cameras[i] out of bounds.  That's a likely
        // root cause of the SIGABRT seen on second-stitch attempts.
        //
        // C+D progressive-confidence retry at PRUNE granularity.
        // Mirrors the high-level entry's [1.0, 0.5, 0.3] threshold
        // sweep, but the retry only re-runs leaveBiggestComponent
        // (cheap) rather than every stage of cv::Stitcher::stitch
        // (5-10× more expensive).  cv::detail::leaveBiggestComponent
        // MUTATES imgFeatures + pairwise in place, so we keep
        // defensive backup copies and restore them before each retry
        // (approach (a) — copy beats rematching, since
        // BestOf2NearestMatcher is the dominant cost).
        //
        // SCANS mode skips thresholds > 0.31 — its default is already
        // 0.3 and dropping pairs at 1.0 / 0.5 produces vacuous results.
        // 2026-05-18 (Issue #2 RCA): the previous break condition was
        // `workFrames.size() >= 2` which exited on the FIRST attempt
        // that retained the minimum-stitchable count.  But
        // leaveBiggestComponent is monotonic in inclusion: lower
        // threshold = MORE frames retained.  So if attempt 1
        // (thresh=1.0) retains 2/4, attempts 2/3 (thresh=0.5/0.3)
        // might retain 3/4 or 4/4.  The early break threw away that
        // signal, so user-visible captures of 4 keyframes
        // consistently shipped with only 2 in the panorama at
        // thresh=1.0, never benefiting from the retry sweep.
        //
        // New behaviour: ONLY break early when all input frames are
        // retained (no point trying lower thresholds — they can't do
        // better).  Otherwise let the loop run to its lowest
        // threshold; the resulting workFrames carries the most
        // inclusive prune at the end.  pruneSucceeded flips true on
        // any attempt that yields >=2 frames; pruneThresholdUsed
        // tracks the threshold of the latest successful attempt.
        const float kPruneThresholds[] = {1.0f, 0.5f, 0.3f};
        const int kNumPruneAttempts =
            sizeof(kPruneThresholds) / sizeof(kPruneThresholds[0]);
        const std::vector<cv::detail::ImageFeatures> imgFeaturesBackup =
            imgFeatures;
        const std::vector<cv::detail::MatchesInfo> pairwiseBackup = pairwise;
        const std::vector<cv::Mat> workFramesBackup = workFrames;
        const std::vector<cv::Mat> framesBackup = frames;
        const size_t initialFrameCount = imgFeatures.size();
        float pruneThresholdUsed = -1.0f;
        bool pruneSucceeded = false;
        for (int attempt = 0; attempt < kNumPruneAttempts; ++attempt) {
            const float thresh = kPruneThresholds[attempt];
            if (config.stitchMode == StitchMode::Scans && thresh > 0.31f) {
                continue;
            }
            // Panorama: skip the 0.3 floor.  leaveBiggestComponent is
            // monotonic in the threshold, so 0.3 only ever FORCES IN a weak
            // boundary frame that survived neither 1.0 nor 0.5 — exactly the
            // frame BundleAdjusterRay can't refine, which then mis-places under
            // the unbounded plane warp and marooned the content in a corner
            // ("black canvas").  Drop it and let that frame be pruned.  Scans
            // keeps 0.3 (it floors there by design — see the >0.31 skip above).
            if (config.stitchMode != StitchMode::Scans && thresh < 0.4f) {
                continue;
            }
            // Restore from backups before each attempt — leaveBiggest-
            // Component mutated them last time.  First attempt sees the
            // originals (backup == current), subsequent attempts get a
            // clean slate.
            if (attempt > 0) {
                imgFeatures = imgFeaturesBackup;
                pairwise    = pairwiseBackup;
                workFrames  = workFramesBackup;
                frames      = framesBackup;
            }
            log_info(logFn, "[stitch-bc]",
                     "step3 prune-retry attempt %d: thresh=%.2f",
                     attempt + 1, thresh);
            std::vector<int> indices = cv::detail::leaveBiggestComponent(
                imgFeatures, pairwise, thresh);
            // Trim BOTH workFrames AND the full-res frames using the same
            // indices.  workFrames feeds BA below; full-res frames feed the
            // compose stage further down (re-warped at COMPOSE_MP).  Both
            // must stay aligned with cameras[i] / imgFeatures[i] post-trim.
            std::vector<cv::Mat> trimmedWorkFrames;
            std::vector<cv::Mat> trimmedFrames;
            trimmedWorkFrames.reserve(indices.size());
            trimmedFrames.reserve(indices.size());
            for (int idx : indices) {
                if (idx >= 0 && idx < (int)workFrames.size()) {
                    trimmedWorkFrames.push_back(workFrames[idx]);
                    trimmedFrames.push_back(frames[idx]);
                }
            }
            workFrames = std::move(trimmedWorkFrames);
            frames     = std::move(trimmedFrames);
            log_info(logFn, "[BatchStitcher]",
                     "step3.5: thresh=%.2f kept %zu of %zu frames in biggest component",
                     thresh, workFrames.size(), initialFrameCount);
            if (workFrames.size() >= 2) {
                pruneThresholdUsed = thresh;
                pruneSucceeded = true;
            }
            if (workFrames.size() == initialFrameCount) {
                // All retained — no point trying lower thresholds.
                log_info(logFn, "[stitch-bc]",
                         "step3 prune-retry attempt %d: all %zu frames "
                         "retained — stopping retry sweep",
                         attempt + 1, initialFrameCount);
                break;
            }
            // Partial retention.  Either keep trying lower thresholds
            // (might retain more), or — if this is the last attempt
            // — accept the partial result that pruneSucceeded captured.
            if (attempt + 1 < kNumPruneAttempts) {
                log_info(logFn, "[stitch-bc]",
                         "step3 prune-retry attempt %d kept only %zu/%zu "
                         "frames — retrying with lower threshold",
                         attempt + 1, workFrames.size(), initialFrameCount);
            } else {
                log_info(logFn, "[stitch-bc]",
                         "step3 prune-retry attempt %d kept %zu/%zu "
                         "frames at lowest threshold %.2f — accepting "
                         "(success=%d)",
                         attempt + 1, workFrames.size(), initialFrameCount,
                         thresh, pruneSucceeded ? 1 : 0);
            }
        }
        if (!pruneSucceeded) {
            // V16 fix-attempt 9 (NULL TEST) — same rationale as the
            // validPairs<1 sentinel above.  Bypass the *error→throw bridge
            // by returning a width=0/height=0 sentinel result instead.
            log_error(logFn, "[BatchStitcher]",
                      "step3.5: <2 frames after leaveBiggestComponent at all thresholds — sentinel result");
            capturedErrorCode = StitchErrorCode::AllFramesDroppedByConfidence;
            capturedErrorMessage = "Less than 2 frames remain after leaveBiggestComponent at all retry thresholds.";
            // framesIncluded reflects best-known retained count at the
            // abort site — the most recent attempt's trim outcome.
            result.framesIncluded = static_cast<int32_t>(workFrames.size());
            sentinelInsidePool = true;
            break;
        }

        // Step 4: estimator
        log_info(logFn, "[BatchStitcher]", "step4: estimator");
        log_info(logFn, "[stitch-bc]", "step4 enter: estimator");
        cv::detail::HomographyBasedEstimator estimator;
        std::vector<cv::detail::CameraParams> cameras;
        if (!estimator(imgFeatures, pairwise, cameras)) {
            // V16 fix-attempt 9 — sentinel return (see validPairs<1 site
            // above for full RCA).  Estimator failures are a real production
            // hazard on borderline-dissimilar frame sequences (typical mode:
            // user pans through occluded regions or featureless walls
            // mid-arc).  Returning sentinel keeps the failure surface clean
            // even though the immediate V16 batch-keyframe repro doesn't
            // typically reach this path.
            log_error(logFn, "[BatchStitcher]",
                      "step4: HomographyBasedEstimator failed — sentinel result");
            capturedErrorCode = StitchErrorCode::HomographyEstimationFailed;
            capturedErrorMessage = "HomographyBasedEstimator failed.";
            // framesIncluded reflects best-known retained count at the
            // abort site — the post-prune workFrames count.
            result.framesIncluded = static_cast<int32_t>(workFrames.size());
            sentinelInsidePool = true;
            break;
        }
        for (auto& cam : cameras) {
            cv::Mat R32;
            cam.R.convertTo(R32, CV_32F);
            cam.R = R32;
        }

        // Step 5: bundle adjustment (the slow step).  BundleAdjusterRay
        // is what cv::Stitcher::PANORAMA uses internally.  confThresh=1.0
        // matches cv::Stitcher's default — drops weak match-pair
        // constraints from the optimisation so BA converges reliably.
        // Cap iterations at 100 (default 1000) so a poorly-conditioned
        // problem can't run away into a 60s timeout.  BA typically
        // converges in 20-50 iters on good input; if 100 isn't enough,
        // the inputs themselves are unstitchable and we want to fail
        // fast rather than spin.
        {
            auto _t = std::chrono::steady_clock::now();
            double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                _t - t0).count();
            log_info(logFn, "[BatchStitcher]",
                     "step5: bundle adjustment (t+%.0fms)", _ms);
            log_info(logFn, "[stitch-bc]", "step5 enter: bundle adjustment");
        }
        auto adjuster = cv::makePtr<cv::detail::BundleAdjusterRay>();
        adjuster->setConfThresh(1.0f);
        adjuster->setTermCriteria(cv::TermCriteria(
            cv::TermCriteria::EPS + cv::TermCriteria::COUNT,
            100,
            DBL_EPSILON));

        // V12.14.2 — FAULT-level sentinel + camera sanity dump.  These
        // bracket the BA call so a future trace can pinpoint whether
        // the crash is BEFORE BA invocation, INSIDE BA, or AFTER BA.
        // Also dump the first camera's R[0,0] + focal so we can see if
        // estimator produced NaN/Inf values that would crash BA's
        // Levenberg-Marquardt.
        {
            double r00 = cameras.empty() ? 0.0 :
                (cameras[0].R.empty() ? 0.0 : (double)cameras[0].R.at<float>(0, 0));
            double focal = cameras.empty() ? 0.0 : cameras[0].focal;
            log_info(logFn, "[stitch-bc]",
                     "step5 BA INVOKE: cameras=%zu cam0.R[0,0]=%.4f cam0.focal=%.2f",
                     cameras.size(), r00, focal);
        }

        // V12.14.2 — wrap BA in try/catch.  Catches cv::Exception (most
        // likely if BA detects a bad input) and std::exception (defensive).
        // On exception, fall back to the estimator cameras (skipping the
        // BA refinement step).  Pano quality is slightly lower without
        // BA but it WON'T CRASH.  Note: this catches C++ exceptions;
        // raw SIGSEGV from BA's internal pointer deref would still
        // terminate the process — for that, the kMaxFramesForStitch=8
        // cap above is the primary defence.
        bool baSucceeded = false;
        try {
            baSucceeded = (*adjuster)(imgFeatures, pairwise, cameras);
        } catch (const cv::Exception& e) {
            log_error(logFn, "[stitch-bc]",
                      "step5 BA threw cv::Exception: %s — fallback to estimator cameras",
                      e.what());
            baSucceeded = false;
        } catch (const std::exception& e) {
            log_error(logFn, "[stitch-bc]",
                      "step5 BA threw std::exception: %s — fallback to estimator cameras",
                      e.what());
            baSucceeded = false;
        } catch (...) {
            log_error(logFn, "[stitch-bc]",
                      "step5 BA threw unknown exception — fallback to estimator cameras");
            baSucceeded = false;
        }

        if (!baSucceeded) {
            // Fall through with the cameras the estimator produced —
            // step5.5 wave correction + step6+ compose can still run on
            // unrefined cameras.  Result quality will be lower (no global
            // optimisation) but the engine returns a panorama instead of
            // crashing.
            log_info(logFn, "[stitch-bc]",
                     "step5 BA SKIPPED — proceeding with estimator cameras");
        } else {
            log_info(logFn, "[stitch-bc]", "step5 BA OK");
        }

        // Step 5.5: WAVE CORRECTION.  cv::Stitcher::PANORAMA does
        // this automatically; my hand-rolled pipeline was missing it.
        // After BA produces camera rotation matrices, waveCorrect
        // globally rotates them so all cameras share a consistent
        // up-vector.  Without this, the cylindrical (or spherical)
        // projection produces visible "wavy" top / bottom edges where
        // edge frames hit the projection surface at slightly
        // different vertical angles.
        //
        // WAVE_CORRECT_HORIZ — this is what was working yesterday for
        // BOTH portrait+horizontal-pan and landscape+vertical-pan.
        // Why it works for both: HORIZ aligns each camera's "up" vector
        // to the world Y axis (gravity).  vision-camera writes mp4s
        // with `outputOrientation="device"` so the saved frames are
        // already in the user's view orientation; after BA + waveCorrect
        // HORIZ, the panorama's vertical axis matches world's vertical
        // axis regardless of pan direction.
        //
        // I briefly switched to autoDetectWaveCorrectKind thinking it'd
        // handle vertical pans better — it actually picked the wrong
        // kind for portrait+horizontal pans, breaking yesterday's
        // working normal-mode capture.  Reverting.
        {
            auto _t = std::chrono::steady_clock::now();
            double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                _t - t0).count();
            log_info(logFn, "[BatchStitcher]",
                     "step5.5: wave correction (BA done, t+%.0fms)", _ms);
            log_info(logFn, "[stitch-bc]", "step5.5 enter: wave correction");
        }
        std::vector<cv::Mat> rmats;
        rmats.reserve(cameras.size());
        for (const auto& cam : cameras) {
            rmats.push_back(cam.R.clone());
        }
        try {
            cv::detail::waveCorrect(rmats, cv::detail::WAVE_CORRECT_HORIZ);
            for (size_t i = 0; i < cameras.size(); i++) {
                cameras[i].R = rmats[i];
            }
        } catch (const cv::Exception& e) {
            // Wave correction can fail on degenerate input (only 1-2
            // cameras with collinear rotations).  Swallow the failure
            // and continue without correction — the panorama will have
            // the wave artifact but is still better than aborting.
            log_info(logFn, "[BatchStitcher]",
                     "wave correction skipped: %s", e.what());
        }

        // Step 6: COMPOSE rescale.  This is the key step that gives us
        // back the cylindrical-era sharpness.  cv::Stitcher does this
        // internally as `composePanorama`: rescale camera intrinsics
        // by (compose_scale / work_scale), recreate the warper at
        // the new scale, then warp+blend on freshly-resized frames at
        // COMPOSE_MP.  Without this step, output stays at REGISTRATION_MP
        // and is visibly blurry.
        double compose_scale = (origMp > COMPOSE_MP)
            ? std::sqrt(COMPOSE_MP / origMp)
            : 1.0;
        double compose_work_aspect = compose_scale / work_scale;
        log_info(logFn, "[BatchStitcher]",
                 "step6: compose rescale "
                 "(work_scale=%.3f → compose_scale=%.3f, aspect=%.3f)",
                 work_scale, compose_scale, compose_work_aspect);
        for (auto& cam : cameras) {
            cam.focal *= compose_work_aspect;
            cam.ppx  *= compose_work_aspect;
            cam.ppy  *= compose_work_aspect;
        }

        // Step 6.5: median focal length determines the warper scale.
        // Computed AFTER compose rescale so warpedScale is already in
        // compose units — matches cv::Stitcher's flow.
        std::vector<double> focals;
        for (const auto& cam : cameras) focals.push_back(cam.focal);
        std::sort(focals.begin(), focals.end());
        float warpedScale =
            focals.empty() ? 1.0f
                           : (float)focals[focals.size() / 2];

        // Step 7: PLANE warper.  The crucial swap.
        //
        // For close-up shelf scans (~30° pan, mostly translational
        // gesture across a planar product face), plane projection is
        // the right choice — it produces a flat output with no
        // cylindrical curve and no spherical bowl.
        //
        // Cylindrical/spherical only buy you something for wider arcs
        // where the per-frame perspective curves matter.  Below ~45°
        // arc, plane is empirically the most natural-looking option
        // and exactly what SCANS mode used (just SCANS coupled it
        // with affine BA which we just established was the wrong
        // estimator for our motion).
        log_info(logFn, "[BatchStitcher]",
                 "step7: warper (%s)", config.warperType.c_str());
        log_info(logFn, "[stitch-bc]",
                 "step7 enter: warper=%s", config.warperType.c_str());
        // Plane / Cylindrical / Spherical — runtime-selectable so
        // the host's settings UI can A/B test which projection looks
        // best for the operator's actual gesture (close-up planar
        // subject vs partial-arc rotation vs wide pan).
        cv::Ptr<cv::WarperCreator> warperCreator;
        if (config.warperType == "cylindrical") {
            warperCreator = cv::makePtr<cv::CylindricalWarper>();
        } else if (config.warperType == "spherical") {
            warperCreator = cv::makePtr<cv::SphericalWarper>();
        } else {
            // "plane" is the default — straight verticals/horizontals,
            // good for close-up subjects.  Hourglass shape produced
            // by partial arcs is removed by the rectangular-crop step
            // below.
            warperCreator = cv::makePtr<cv::PlaneWarper>();
        }
        // V12.14.3 — FAULT breadcrumbs around each sub-step within
        // step7 → step7.5.  Ram's V12.14.2 trace had the crash here
        // (last visible log was step7 enter; step7.5 never fired).
        // These pinpoint which sub-step actually crashes.
        cv::Ptr<cv::detail::RotationWarper> warper =
            warperCreator->create(warpedScale);
        log_info(logFn, "[stitch-bc]",
                 "step7a: warper created (warpedScale=%.2f)", warpedScale);

        // Step 7.5: build composeFrames at COMPOSE_MP from full-res
        // input.  Warp + blend run at this resolution to produce the
        // sharp final output.  Release workFrames first — BA is done,
        // so we don't need the small set anymore.  Sequential release
        // ensures the two big arrays never coexist at peak.
        for (auto& wf : workFrames) wf.release();
        workFrames.clear();
        log_info(logFn, "[stitch-bc]",
                 "step7b: workFrames released, building composeFrames "
                 "(N=%zu, compose_scale=%.3f)",
                 frames.size(), compose_scale);

        // V12.14.3 — wrap the resize loop in try/catch so a bad input
        // Mat doesn't terminate the process.  Per-frame resize on
        // bogus/corrupt cv::Mat data has historically been a SIGSEGV
        // source on consecutive captures.
        std::vector<cv::Mat> composeFrames;
        composeFrames.reserve(frames.size());
        try {
            for (size_t i = 0; i < frames.size(); i++) {
                const auto& f = frames[i];
                log_info(logFn, "[stitch-bc]",
                         "step7c: resize frame %zu (%dx%d, channels=%d, "
                         "data=%p)", i, f.cols, f.rows, f.channels(),
                         (const void*)f.data);

                // V12.14.4 — defensive validation.  Skip frames with NULL
                // data ptr, zero dimensions, or non-positive total — they
                // would SIGSEGV inside cv::resize regardless of interp mode.
                if (f.data == nullptr || f.empty() || f.total() == 0
                    || f.cols <= 0 || f.rows <= 0) {
                    log_error(logFn, "[stitch-bc]",
                              "step7c: SKIPPING frame %zu — invalid Mat "
                              "(data=%p empty=%d total=%zu)",
                              i, (const void*)f.data, (int)f.empty(),
                              (size_t)f.total());
                    continue;
                }

                // V12.14.4 — original wraps each iteration in
                // @autoreleasepool so any ObjC temporaries cv::resize
                // might autorelease internally get drained between
                // frames.  In the pure-C++ port this is a no-op: pure
                // C++ has no autoreleased temporaries; cv::Mat's RAII
                // dtor runs at iteration scope exit naturally.
                cv::Mat scaled;
                if (std::abs(compose_scale - 1.0) > 1e-3) {
                    // V12.14.4 — pre-allocate `scaled` with explicit dims
                    // BEFORE cv::resize so the internal `dst.create()` is a
                    // no-op.  Skips the allocator state corruption Ram's
                    // V12.14.3 trace pointed at: cv::resize crashed on the
                    // 5th consecutive resize when iOS recycled mmap regions
                    // from a prior capture, suggesting cv::resize's internal
                    // allocator path was hitting stale state.
                    //
                    // Plus: switch INTER_AREA → INTER_LINEAR.  INTER_AREA
                    // uses precomputed cached interpolation tables that
                    // appear to be the corrupted state.  INTER_LINEAR uses
                    // a different code path (no cached table).  Slightly
                    // less crisp at extreme downscales but for our 0.538×
                    // shelf-image downscale the visual difference is
                    // negligible — and stability >> sharpness.
                    int newCols = (int)std::round(f.cols * compose_scale);
                    int newRows = (int)std::round(f.rows * compose_scale);
                    scaled.create(newRows, newCols, f.type());
                    cv::resize(f, scaled, scaled.size(), 0, 0, cv::INTER_LINEAR);
                } else {
                    scaled = f.clone();
                }
                composeFrames.push_back(scaled);
            }
        } catch (const cv::Exception& e) {
            // V12.14.7 — %{public}s so the message survives Console.app
            // privacy redaction.  Without this, e.what() shows as "<private>"
            // and we can't see which assertion fired.
            log_error(logFn, "[stitch-bc]",
                      "step7c: cv::resize threw cv::Exception: %s",
                      e.what());
            capturedErrorCode = StitchErrorCode::ComposeResizeFailed;
            capturedErrorMessage = std::string("Compose-stage resize failed: ") + e.what();
            // framesIncluded reflects best-known retained count at the
            // abort site — cameras has been populated by step4 so it's
            // the most accurate post-prune count.
            result.framesIncluded = static_cast<int32_t>(cameras.size());
            failedInsidePool = true;
            break;
        } catch (...) {
            log_error(logFn, "[stitch-bc]",
                      "step7c: cv::resize threw unknown exception");
            capturedErrorCode = StitchErrorCode::ComposeResizeFailed;
            capturedErrorMessage = "Compose-stage resize failed (unknown).";
            result.framesIncluded = static_cast<int32_t>(cameras.size());
            failedInsidePool = true;
            break;
        }
        log_info(logFn, "[stitch-bc]",
                 "step7d: composeFrames built (N=%zu)",
                 composeFrames.size());

        // Release full-res `frames` now that composeFrames has its
        // own resized copies.  Frees ~50-100 MB for a typical 8-frame
        // stitch — a critical part of staying under iOS' jetsam
        // threshold (the ACTUAL cause of the "u != 0" /
        // WatchdogTermination crashes we were debugging — Sentry
        // confirmed those were OOM kills, not OpenCV bugs).
        for (auto& f : frames) f.release();
        frames.clear();
        log_info(logFn, "[stitch-bc]",
                 "step7e: full-res frames released mem=%.1fMB",
                 rss_mb());
        log_info(logFn, "[BatchStitcher]",
                 "step7.5: composeFrames %d×%d "
                 "(compose_scale=%.3f)",
                 composeFrames.empty() ? 0 : composeFrames[0].cols,
                 composeFrames.empty() ? 0 : composeFrames[0].rows,
                 compose_scale);

        // Step 7.6: cylindrical-fallback pre-pass.  The configured warper
        // (plane by default) projects as ~tan(theta), so a wide ultra-wide
        // (0.5x) sweep can blow a single frame's warp canvas past the
        // 100 MP guard and hard-fail with "degenerate camera params".
        // warpRoi() is a cheap corner projection (no pixel work), so probe
        // every frame here; if any would diverge AND we're not already on
        // the bounded cylindrical projection, fall back to cylindrical for
        // the one real warp pass below.  Everything downstream (seam /
        // blender / compose / crop) consumes the warper's OUTPUTS, so the
        // swap is transparent.  If even cylindrical diverges, the in-loop
        // guard (step8b) still throws — the genuine-failure safety net.
        // The projection actually in use after the step7.6 fallback.  The
        // fallback swaps `warper` but NOT warperCreator, so the step7.7 cap
        // below (which re-creates the warper at a smaller scale) must
        // re-create via THIS — otherwise it would silently revert
        // cylindrical→plane on exactly the wide pan the fallback rescued.
        std::string activeWarperType = config.warperType;
        if (config.warperType != "cylindrical" && !composeFrames.empty()) {
            bool wouldDiverge = false;
            size_t divergeFrame = 0;
            for (size_t i = 0; i < composeFrames.size(); i++) {
                if (composeFrames[i].empty()) continue;
                cv::Mat preK;
                cameras[i].K().convertTo(preK, CV_32F);
                const cv::Rect r = warper->warpRoi(
                    composeFrames[i].size(), preK, cameras[i].R);
                if (warpRoiExceedsGuard(r.width, r.height)) {
                    wouldDiverge = true;
                    divergeFrame = i;
                    break;
                }
            }
            // Issue 4 — quality-driven projection.  PlaneWarper projects
            // ~tan(theta), so on a WIDE sweep the frames at the pan extremes
            // get visibly stretched/sheared — the "perspective at the ends"
            // users notice — even when the warp wouldn't OOM.  Estimate the
            // total angular sweep from the bundle-adjusted camera optical
            // axes (first vs last frame); beyond kWidePanSweepDeg switch from
            // plane to the bounded cylindrical projection (~theta), which
            // keeps angular spacing uniform across the pan.
            //
            // The sweep angle (sweepDeg, below) is AXIS-AGNOSTIC — the 3D
            // angle between the first/last optical axes — so a Mode-A
            // landscape *vertical* pan trips this gate just as a horizontal
            // one does.  cylindrical bounds only the HORIZONTAL angle; its
            // vertical axis is UNBOUNDED, so a vertical sweep's end frames
            // project to runaway coordinates and shear apart (fragmented
            // output — confirmed on-device 2026-06-14, a regression from
            // 6b11da0 vs the v0.6 plane baseline).  Use SPHERICAL, which
            // bounds BOTH axes, so vertical AND horizontal wide pans stay
            // coherent.  (Divergence guard + step-7.7 canvas budget cap are
            // unaffected — spherical is still a bounded projection.)
            constexpr double kWidePanSweepDeg = 45.0;
            const char* kWidePanWarper = "spherical";
            double sweepDeg = 0.0;
            if (cameras.size() >= 2) {
                auto opticalAxis = [](const cv::Mat& R) -> cv::Vec3d {
                    cv::Mat Rd;
                    R.convertTo(Rd, CV_64F);
                    // Camera looks along +Z; world view dir = R·e_z = col 2.
                    return cv::Vec3d(Rd.at<double>(0, 2),
                                     Rd.at<double>(1, 2),
                                     Rd.at<double>(2, 2));
                };
                const cv::Vec3d a0 = opticalAxis(cameras.front().R);
                const cv::Vec3d aN = opticalAxis(cameras.back().R);
                const double n0 = cv::norm(a0);
                const double nN = cv::norm(aN);
                if (n0 > 1e-9 && nN > 1e-9) {
                    double c = a0.dot(aN) / (n0 * nN);
                    c = std::max(-1.0, std::min(1.0, c));
                    sweepDeg = std::acos(c) * 180.0 / CV_PI;
                }
            }
            const bool widePan = sweepDeg >= kWidePanSweepDeg;

            // Only switch the warper when a frame's warp ACTUALLY blows past
            // the size guard (genuine divergence).  The `widePan` sweep-angle
            // heuristic (>= kWidePanSweepDeg) was too aggressive — it fired on
            // a NORMAL moderate vertical Mode-A pan and switched away from the
            // PLANE warper that v0.6 used cleanly into the bounded-warper path,
            // which fragmented/doubled the output (confirmed on-device
            // 2026-06-14: stock cv::Stitcher AND v0.6 both stitch the exact
            // same frames cleanly on the default/plane warper; only the
            // post-v0.6 sweep-triggered switch broke it).  Keep the divergence
            // guard (the real OOM/garbage protection) + spherical for that
            // case; `widePan` stays only for the diagnostic log below.
            if (wouldDiverge) {
                log_info(logFn, "[stitch-bc]",
                         "step7.6: switching '%s' -> %s (diverge=%d wide=%d "
                         "sweep=%.1fdeg, frame %zu) for a bounded projection",
                         config.warperType.c_str(), kWidePanWarper,
                         wouldDiverge ? 1 : 0, widePan ? 1 : 0, sweepDeg,
                         divergeFrame);
                if (auto bounded = make_warper(kWidePanWarper)) {
                    warper = bounded->create(warpedScale);
                    activeWarperType = kWidePanWarper;
                }
            }
        }

        // Post-cap projected canvas megapixels — drives the step-8 path
        // choice (wide canvases route to the low-memory STREAM+feather path).
        // Set inside step 7.7 below.
        double composeCanvasMpFinal = 0.0;
        // Post-cap BATCH held-set = Σ of every warped frame's area.  This —
        // not the union — is the real driver of BATCH blend memory (N warped
        // frames + N exposure-comp UMat copies + MultiBand pyramids, all held
        // at once).  A SMALL union with big/overlapping frames (e.g. the ~6×
        // higher-res AR keyframes) can still blow a huge held-set, so step 8's
        // STREAM route keys on this too, not just the union.  Set in step 7.7.
        double composeHeldSetMpFinal = 0.0;
        // Step 7.7: RAM-aware output-canvas budget cap (wide-pan blend-OOM
        // fix).  A VALID but wide pan produces a large UNION canvas, and the
        // BATCH + MultiBand blend peak scales with it (on a 6 GB A35 a
        // ~70 MP union hit ~2.97 GB RSS and was lmkd-killed mid-blend, never
        // reaching step11).  Unlike the degenerate-warp guards (per-frame
        // 100 MP / cumulative 50 MP), this is a capture we want to COMPLETE,
        // not reject — so cap the canvas to a memory budget by reducing
        // compose scale, yielding a slightly-lower-res but complete pano.
        // warpRoi() here is corner-only/cheap (no pixel warp yet) and reuses
        // the EXACT union math blender->prepare() will allocate, so the probe
        // predicts the real canvas.  No-op for normal panos: the budget floor
        // (12 MP) exceeds the widest valid 360° pano (~9 MP), so the 13
        // bounded captures see byte-identical behavior.
        if (!composeFrames.empty()) {
            std::vector<cv::Point> capCorners(composeFrames.size());
            std::vector<cv::Size>  capSizes(composeFrames.size());
            bool capOk = true;
            for (size_t i = 0; i < composeFrames.size(); i++) {
                if (composeFrames[i].empty()) { capOk = false; break; }
                cv::Mat capK;
                cameras[i].K().convertTo(capK, CV_32F);
                const cv::Rect r = warper->warpRoi(
                    composeFrames[i].size(), capK, cameras[i].R);
                capCorners[i] = r.tl();
                capSizes[i]   = r.size();
            }
            if (capOk) {
                int64_t cw = 0, ch = 0;
                blendCanvasUnion(capCorners, capSizes, cw, ch);
                const double canvasMP = (double)cw * (double)ch / 1e6;
                const double manualDeviceBudgetMP = composeCanvasBudgetMP(totalRamMB);
        // v0.25 review — the manual pipeline honours the reservation cap too
        // (it was high-level-only; the manual path otherwise relied on its
        // STREAM routing + pre-abort alone).
        const double budgetMP =
            (config.rescueCanvasBudgetMPOverride > 0.0)
                ? std::min(manualDeviceBudgetMP,
                           config.rescueCanvasBudgetMPOverride)
                : manualDeviceBudgetMP;
                const double downscale =
                    canvasDownscaleForBudget(canvasMP, budgetMP);
                composeCanvasMpFinal = canvasMP * downscale * downscale;
                double heldSetMpRaw = 0.0;
                for (const auto& s : capSizes) {
                    heldSetMpRaw += (double)s.width * (double)s.height / 1e6;
                }
                composeHeldSetMpFinal = heldSetMpRaw * downscale * downscale;
                // Always-on probe — confirms the RAM read (totalRamMB), the
                // budget, the active projection, and whether the cap fired.
                // Used to calibrate kBlendBytesPerUnionPx from real traces.
                log_info(logFn, "[stitch-bc]",
                         "step7.7: canvas probe union=%lldx%lld (%.1f MP) "
                         "budget=%.1f MP totalRamMB=%.0f warper=%s downscale=%.3f",
                         (long long)cw, (long long)ch, canvasMP, budgetMP,
                         totalRamMB, activeWarperType.c_str(), downscale);
                if (downscale < 1.0) {
                    log_info(logFn, "[stitch-bc]",
                             "step7.7: CAPPED downscale=%.3fx (canvasMP %.1f -> "
                             "~%.1f, budget %.1f) — re-resizing composeFrames",
                             downscale, canvasMP,
                             canvasMP * downscale * downscale, budgetMP);
                    // Co-scale EVERY quantity warpRoi depends on so the post-
                    // cap canvas actually lands at ~budget: warpedScale,
                    // compose_scale (read by the step9 seam aspect), and each
                    // camera's intrinsics (focal/ppx/ppy — NOT R; mirrors the
                    // step6 compose rescale; K() rebuilds on demand).
                    warpedScale = (float)(warpedScale * downscale);
                    compose_scale *= downscale;
                    for (auto& cam : cameras) {
                        cam.focal *= downscale;
                        cam.ppx   *= downscale;
                        cam.ppy   *= downscale;
                    }
                    // Re-resize composeFrames in place at the new scale.
                    // INTER_LINEAR + pre-allocated dst + try/catch mirror the
                    // step7c recycled-mmap SIGSEGV stability fix; on failure,
                    // break out to the same failure handler step7c uses.
                    try {
                        for (size_t i = 0; i < composeFrames.size(); i++) {
                            if (composeFrames[i].empty()) continue;
                            const int nw = std::max(1,
                                (int)std::round(composeFrames[i].cols * downscale));
                            const int nh = std::max(1,
                                (int)std::round(composeFrames[i].rows * downscale));
                            cv::Mat resized(nh, nw, composeFrames[i].type());
                            cv::resize(composeFrames[i], resized,
                                       resized.size(), 0, 0, cv::INTER_LINEAR);
                            composeFrames[i] = resized;
                        }
                    } catch (const cv::Exception& e) {
                        log_error(logFn, "[stitch-bc]",
                                  "step7.7: compose re-resize threw: %s", e.what());
                        capturedErrorCode = StitchErrorCode::ComposeResizeFailed;
                        capturedErrorMessage =
                            std::string("Canvas-cap resize failed: ") + e.what();
                        result.framesIncluded =
                            static_cast<int32_t>(cameras.size());
                        failedInsidePool = true;
                        break;
                    }
                    // Re-create the warper at the new scale via the ACTIVE
                    // projection (plane, or the step7.6 cylindrical fallback).
                    if (auto w = make_warper(activeWarperType)) {
                        warper = w->create(warpedScale);
                    }
                    log_info(logFn, "[stitch-bc]",
                             "step7.7: cap applied new warpedScale=%.2f "
                             "compose_scale=%.3f", warpedScale, compose_scale);
                }
            }
        }

        // Step 8: warp + (optional) seam finder + blender feed.
        //
        // Two paths based on caller's seamFinderType:
        //
        //   "graphcut" — BATCH path.  Warp all frames into memory,
        //     run GraphCutSeamFinder for optimal seams, then feed
        //     the blender.  Higher peak memory (all warped frames
        //     coexist during seam finding) but produces clean seams
        //     that pair beautifully with MultiBandBlender.  Same
        //     algorithm cv::Stitcher::PANORAMA uses internally.
        //
        //   "skip"     — STREAM path.  Warp + feed each frame in the
        //     same loop, releasing immediately.  Never holds more
        //     than one warped frame in memory.  ~40-50 MB lower peak
        //     at 1.0 MP × 8 frames.  Right choice for low-RAM
        //     devices; the host's per-device defaults pick this
        //     path on devices with <2 GB physical RAM.
        //
        // Both paths feed the SAME blender (selected per caller's
        // blenderType).  Final blend happens after either path
        // completes.
        // Wide-canvas low-memory routing.  BATCH + MultiBand holds every
        // warped frame at once + N exposure-comp UMat copies + builds
        // Laplacian pyramids; on a 6 GB device a ~28 MP canvas peaked ~3 GB
        // in the blend/exposure stage and was lmkd-killed — even after the
        // step-9 cappedSeamAspect fix bounded the seam finder.  Above
        // kLowMemCanvasMP, force the STREAM path (one warped frame at a time,
        // no held set, no exposure copies, no GraphCut) + the FEATHER blender
        // (single-pass, no pyramids) so a wide pan COMPLETES at full
        // resolution instead of OOMing.  Below it, keep BATCH + MultiBand +
        // GraphCut for the crisp seams typical small-canvas captures get.
        // 2026-06-15 — RAM-gated STREAM-routing caps.  On high-RAM devices
        // (≥5 GB physical → 6 GB+ nominal; Android sysconf reads a few hundred
        // MB under the marketing figure, so the gate is 5000 not 6000) raise the
        // caps so wide pans STAY on the sharp BATCH (GraphCut + MultiBand) path
        // instead of dropping to STREAM+feather (softer).  Low-RAM devices keep
        // the conservative 10/15 MP thresholds.  The lowHeadroom trigger below
        // still backstops ACTUAL memory pressure regardless of these static
        // caps, so raising them only lifts the pre-emptive ceiling, not the
        // safety net (a memory-pressured 6 GB device still routes to STREAM).
        const bool kHighRamDevice = totalRamMB >= 5000.0;
        const double kLowMemCanvasMP = kHighRamDevice ? 16.0 : 10.0;
        // Held-set guard: BATCH stayed safe at Σ-warped-area ≲13 MP but a
        // 6-frame AR pan with a 9.6 MP union (under kLowMemCanvasMP) yet a
        // ~32 MP held-set hit 3.6 GB and was lmkd-killed.  Route to STREAM on
        // EITHER axis so a small-union/large-held-set capture (bigger or
        // heavily-overlapping frames, e.g. high-res AR keyframes) can't slip
        // into BATCH.  15 MP sits safely between the observed safe (≲13) and
        // fatal (~32) held-sets.
        const double kMaxBatchHeldSetMP = kHighRamDevice ? 22.0 : 15.0;
        // Issue 6 — headroom-aware routing.  In addition to the fixed
        // canvas/held-set MP thresholds (which bound the stitch's OWN size),
        // route to STREAM when the process's CURRENT free headroom is thin —
        // i.e. whatever else is resident (host app, RN, residual buffers)
        // leaves little room for BATCH's multiband spike.  This is the
        // "route, don't abort" half of Issue 6: under memory pressure we drop
        // to the lighter STREAM+feather path instead of risking an OOM (or a
        // hard pre-stitch abort).  It only ever makes routing MORE
        // conservative, so it can't cause an OOM the fixed thresholds avoided.
        const double rssAtRouteMB = rss_mb();
        const bool lowHeadroom =
            retailens::lowBatchHeadroom(rssAtRouteMB, totalRamMB);
        const bool lowMemCanvas =
            composeCanvasMpFinal > kLowMemCanvasMP
            || composeHeldSetMpFinal > kMaxBatchHeldSetMP
            || lowHeadroom;
        // perf-3b — the manual (BATCH) route runs GraphCutSeamFinder; it has
        // no Voronoi variant. Route ANY non-skip finder here so an opt-in
        // 'voronoi' falls back to GRAPHCUT (safe, high-quality) on this path
        // rather than to STREAM/NoSeamFinder (which an earlier bug did —
        // 'voronoi' failed the graphcut-only equality and silently disabled
        // seam finding, double-exposing overlaps). Only an explicit 'skip'/'no'
        // (or the low-mem canvas guard) streams without a seam. Voronoi's real
        // speedup is delivered on the HIGH-LEVEL path (make_seam_finder, :876),
        // which iOS-only manual finalize does not use.
        const bool useSeam =
            (config.seamFinderType != "skip" && config.seamFinderType != "no")
            && !lowMemCanvas;
        if (lowMemCanvas) {
            log_info(logFn, "[stitch-bc]",
                     "step8: union=%.1f MP held-set=%.1f MP rss=%.0fMB "
                     "budget=%.0fMB (union>%.1f or held>%.1f or lowHeadroom=%d)"
                     " — routing to STREAM+feather",
                     composeCanvasMpFinal, composeHeldSetMpFinal, rssAtRouteMB,
                     perProcessBudgetMB, kLowMemCanvasMP, kMaxBatchHeldSetMP,
                     lowHeadroom ? 1 : 0);
        }
        log_info(logFn, "[BatchStitcher]",
                 "step8: %s",
                 useSeam ? "BATCH (warp-all + seam + feed)"
                         : "STREAM (warp+feed per frame)");
        log_info(logFn, "[stitch-bc]",
                 "step8 enter: %s", useSeam ? "BATCH" : "STREAM");

        // DEV overlay (2026-06-14) — record the choices made for THIS output so
        // the preview can show them in __DEV__.  `warp` is the configured warper
        // (a divergence-only switch to spherical is rare + logged separately);
        // route/seam/blend are the decisions just resolved above.  See
        // StitchResult::debugSummary.
        {
            const bool useFeather =
                (config.blenderType == "feather") || lowMemCanvas;
            result.debugSummary =
                std::string("pipe=manual;warp=") + config.warperType +
                ";route=" + (useSeam ? "batch" : "stream") +
                ";seam=" + (useSeam ? "graphcut" : "none") +
                ";blend=" + (useFeather ? "feather" : "multiband");
        }

        // Build the blender once — both paths feed into it.
        //
        // The "u != 0" UMat assertion we previously hit when running
        // MultiBand or GraphCut was a SYMPTOM of iOS jetsam OOM-kill
        // (confirmed via Sentry's WatchdogTermination signature),
        // not a bug in MBB / GraphCut.  With the OOM fixes now in
        // place (autoreleasepool wrapping, camera pause during
        // stitch, per-frame Mat releases, plus this stream path for
        // low-mem devices), both should run cleanly.
        cv::Ptr<cv::detail::Blender> blender;
        if (config.blenderType == "feather" || lowMemCanvas) {
            // FEATHER for the wide-canvas low-memory path (lowMemCanvas) too —
            // MultiBand's pyramids are the dominant blend allocation we're
            // avoiding.
            blender = cv::detail::Blender::createDefault(
                cv::detail::Blender::FEATHER, false);
            auto fb = blender.dynamicCast<cv::detail::FeatherBlender>();
            if (fb) fb->setSharpness(0.02f);
        } else {
            // "multiband" — Laplacian pyramids per fed frame.
            // More memory than Feather but much sharper seams when
            // paired with GraphCut.
            blender = cv::detail::Blender::createDefault(
                cv::detail::Blender::MULTI_BAND, false);
            auto mbb = blender.dynamicCast<cv::detail::MultiBandBlender>();
            if (mbb) mbb->setNumBands(5);
        }
        log_info(logFn, "[BatchStitcher]",
                 "step10: blender = %s", config.blenderType.c_str());

        if (useSeam) {
            // ── BATCH path ─────────────────────────────────────────────
            const size_t N = composeFrames.size();
            std::vector<cv::Point> corners(N);
            std::vector<cv::Mat> imagesWarped(N);
            std::vector<cv::Mat> masksWarped(N);
            std::vector<cv::Size> sizes(N);
            log_info(logFn, "[stitch-bc]",
                     "step8a: BATCH warp loop (N=%zu)", N);
            // V12.14.6 — defensive measures around the warp loop.  Same
            // recycled-mmap pattern that hit cv::resize in V12.14.3
            // logs (Ram's 4th-capture crash).  cv::PlaneWarper::warp
            // uses cv::remap internally which has its own cached state
            // keyed on input addresses.
            try {
                for (size_t i = 0; i < N; i++) {
                    log_info(logFn, "[stitch-bc]",
                             "step8b: warp frame %zu (%dx%d, data=%p)", i,
                             composeFrames[i].cols, composeFrames[i].rows,
                             (const void*)composeFrames[i].data);
                    // Per-iteration scope drains any autoreleased temps in
                    // the iOS original; pure C++ does this via RAII.
                    cv::Mat K;
                    cameras[i].K().convertTo(K, CV_32F);

                    // 2026-06-16 (audit #5/L4) — warp composeFrames[i] DIRECTLY.
                    // The old V12.14.6 `freshInput = composeFrames[i].clone()` (a
                    // full-res malloc+memcpy+free per frame, as a recycled-mmap
                    // defense) is disproven by the STREAM warp path below, which
                    // passes the frame raw with no clone.  warp READS the input +
                    // writes a SEPARATE output Mat, and composeFrames[i] is
                    // released just after — so the copy bought nothing.  Removes N
                    // full-res copies per BATCH stitch.

                    // V12.14.6 — pre-allocate output Mats via warpRoi() so
                    // cv::remap doesn't need to call create() internally
                    // (the suspect path that crashed in cv::resize too).
                    cv::Rect roi = warper->warpRoi(
                        composeFrames[i].size(), K, cameras[i].R);
                    // 2026-05-18 (Issue #1 guard): cv::Stitcher's estimator
                    // + BA can produce wildly wrong camera parameters on
                    // degenerate input (low feature count, near-duplicate
                    // frames, poor texture).  warpRoi() then returns an
                    // absurd rectangle (we observed 191 GB allocation on a
                    // standard 4-frame capture).  Without this guard the
                    // imagesWarped[i].create() below tries to allocate
                    // hundreds of GB and either OOMs or hard-OOMs the
                    // process.  Cap at 100 MP (~400 MB at 3 channels) —
                    // any panorama frame requiring more than 100 MP of
                    // intermediate storage is from a broken estimator,
                    // not a real capture worth completing.
                    const int64_t roiPixels =
                        static_cast<int64_t>(roi.width)
                        * static_cast<int64_t>(roi.height);
                    // Final safety net.  If we reach here the warper in use
                    // is already cylindrical (either the host chose it, or
                    // the step7.6 pre-pass fell back to it) and STILL
                    // diverges — a genuinely broken estimate, so fail.
                    if (warpRoiExceedsGuard(roi.width, roi.height)) {
                        log_error(logFn, "[stitch-bc]",
                                  "step8b: warpRoi degenerate for frame "
                                  "%zu (%dx%d = %lld px > %lld limit) — "
                                  "treating as warp failure",
                                  i, roi.width, roi.height,
                                  (long long)roiPixels,
                                  (long long)kMaxWarpPixels);
                        // Message + envelope built by the shared helper so
                        // all four degenerate-warp throw sites stay in sync
                        // (see degenerateFrameException above).  Lands in the
                        // step8b catch below → WarpFailed.
                        throw degenerateFrameException(
                            roi.width, roi.height, config.stitchMode, i);
                    }
                    imagesWarped[i].create(roi.size(), composeFrames[i].type());
                    masksWarped[i].create(roi.size(), CV_8U);

                    cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
                    corners[i] = warper->warp(
                        composeFrames[i], K, cameras[i].R, cv::INTER_LINEAR,
                        cv::BORDER_CONSTANT, imagesWarped[i]);
                    warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST,
                                 cv::BORDER_CONSTANT, masksWarped[i]);
                    sizes[i] = imagesWarped[i].size();
                    // V12.14.7 — release composeFrames[i] inside the loop
                    // (was: released only after the entire loop at step8c).
                    // Frees ~14 MB per frame mid-loop, keeping peak working
                    // set ~50-100 MB lower for an 8-frame batch — directly
                    // targets the jetsam OOM kill that struck V12.14.6
                    // after cv::Exception was caught (process died despite
                    // managed throw).  composeFrames[i] is no longer needed
                    // after warp populates imagesWarped[i] / masksWarped[i].
                    composeFrames[i].release();
                }
            } catch (const cv::Exception& e) {
                // V12.14.7 — %{public}s to unredact the message under
                // Console.app privacy filtering.  e.what() was showing as
                // "<private>" in V12.14.6's caught traces.
                log_error(logFn, "[stitch-bc]",
                          "step8b: warper->warp threw cv::Exception: %s",
                          e.what());
                capturedErrorCode = StitchErrorCode::WarpFailed;
                capturedErrorMessage = std::string("Warp stage failed: ") + e.what();
                // framesIncluded reflects best-known retained count at
                // the abort site — cameras is fully populated by step6.
                result.framesIncluded = static_cast<int32_t>(cameras.size());
                failedInsidePool = true;
                break;
            } catch (...) {
                log_error(logFn, "[stitch-bc]",
                          "step8b: warper->warp threw unknown exception");
                capturedErrorCode = StitchErrorCode::WarpFailed;
                capturedErrorMessage = "Warp stage failed (unknown).";
                result.framesIncluded = static_cast<int32_t>(cameras.size());
                failedInsidePool = true;
                break;
            }
            log_info(logFn, "[stitch-bc]",
                     "step8c: warp loop done mem=%.1fMB", rss_mb());
            // composeFrames has done its job — release before we
            // allocate the float UMat shadow set for seam finding.
            // V12.14.7: most/all of these are already released inside
            // the warp loop above; the .clear() drops the now-empty
            // Mat headers from the vector.
            for (auto& cf : composeFrames) cf.release();
            composeFrames.clear();

            // Step 9: GraphCutSeamFinder at SEAM_MP (~0.1 MP).
            //
            // GraphCut's runtime is roughly quadratic in pixel count
            // because it solves a max-flow on a per-pixel grid graph.
            // Running it at compose scale (1.0 MP) takes ~100× longer
            // than at the ~0.1 MP that cv::Stitcher::PANORAMA uses
            // internally (`seam_est_resol_ = 0.1`).  At 1.0 MP we
            // observed >60s stitch-timeouts in JS; at 0.1 MP it
            // finishes in <1s.  Pattern matches cv::Stitcher's flow:
            //   1. Downscale imagesWarped + masksWarped + corners to
            //      seam scale.
            //   2. Run seam finder on the small images.
            //   3. Upscale the seam-optimised masks back to compose
            //      scale.
            //   4. Bitwise-AND with the original masks so we don't
            //      include pixels outside each frame's warped region.
            const double SEAM_MP = (config.seamEstimationResolMP > 0.0)
                ? config.seamEstimationResolMP : 0.1;
            double seam_scale = std::min(1.0, std::sqrt(SEAM_MP / origMp));
            // Aspect from compose scale → seam scale (the rescale we
            // apply to existing compose-scale data, not the original).
            double seam_compose_aspect = seam_scale / compose_scale;
            // BUGFIX (wide-pan GraphCut OOM): the aspect above is derived from
            // the INPUT frame size (origMp), but the resize below is applied to
            // the WARPED images, which span the whole canvas and can be many×
            // larger (a ~0.3 MP frame warps across a multi-MP canvas on a wide
            // pan).  Left uncapped, GraphCut ran on multi-MP seam images and
            // its per-pixel max-flow graph exploded to GBs (a 19 MP-canvas
            // capture was lmkd-killed here — 3.16 GB RSS + 2.1 GB swap).  Re-cap
            // against the LARGEST warped frame so every seam image is ≤ SEAM_MP,
            // which is what cv::Stitcher's seam_est_resol actually targets.
            double maxWarpedMp = 0.0;
            for (size_t i = 0; i < N; i++) {
                maxWarpedMp = std::max(
                    maxWarpedMp,
                    (double)sizes[i].width * (double)sizes[i].height / 1e6);
            }
            seam_compose_aspect =
                cappedSeamAspect(seam_compose_aspect, maxWarpedMp, SEAM_MP);
            {
                auto _t = std::chrono::steady_clock::now();
                double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                    _t - t0).count();
                log_info(logFn, "[BatchStitcher]",
                         "step9: graph-cut seam finder (maxWarpedMP=%.1f "
                         "compose→seam aspect=%.4f → seamMP≈%.2f, t+%.0fms)",
                         maxWarpedMp, seam_compose_aspect,
                         maxWarpedMp * seam_compose_aspect * seam_compose_aspect,
                         _ms);
            }
            auto _seamStart = std::chrono::steady_clock::now();
            log_info(logFn, "[stitch-bc]",
                     "step9a: seam-scale resize loop (aspect=%.3f)",
                     seam_compose_aspect);
            std::vector<cv::UMat> imagesWarpedF_seam(N);
            std::vector<cv::UMat> masksWarpedU_seam(N);
            std::vector<cv::Point> corners_seam(N);
            for (size_t i = 0; i < N; i++) {
                cv::Mat seamImage, seamMask;
                cv::resize(imagesWarped[i], seamImage, cv::Size(),
                           seam_compose_aspect, seam_compose_aspect,
                           cv::INTER_LINEAR);
                cv::resize(masksWarped[i], seamMask, cv::Size(),
                           seam_compose_aspect, seam_compose_aspect,
                           cv::INTER_NEAREST);
                seamImage.convertTo(imagesWarpedF_seam[i], CV_32F);
                seamMask.copyTo(masksWarpedU_seam[i]);
                corners_seam[i] = cv::Point(
                    cvRound(corners[i].x * seam_compose_aspect),
                    cvRound(corners[i].y * seam_compose_aspect));
            }
            log_info(logFn, "[stitch-bc]",
                     "step9b: seam-scale resize done, GraphCut find starting");
            cv::Ptr<cv::detail::SeamFinder> seamFinder =
                cv::makePtr<cv::detail::GraphCutSeamFinder>(
                    cv::detail::GraphCutSeamFinder::COST_COLOR);
            seamFinder->find(imagesWarpedF_seam, corners_seam,
                             masksWarpedU_seam);
            log_info(logFn, "[stitch-bc]", "step9c: GraphCut find done");
            {
                auto _t = std::chrono::steady_clock::now();
                double _seamMs = std::chrono::duration_cast<std::chrono::milliseconds>(
                    _t - _seamStart).count();
                log_info(logFn, "[BatchStitcher]",
                         "step9: graph-cut find took %.0fms", _seamMs);
            }
            imagesWarpedF_seam.clear();

            // Upscale seam-optimised masks back to compose scale.
            //
            // CRITICAL: dilate each mask before upscaling so adjacent
            // frames have a small OVERLAP region for the blender to
            // feather across.  Without this, the seam-cut creates a
            // strict pixel partition with NO overlap — MultiBand then
            // has nothing to feather, producing visible HARD seams
            // (the "cuts" we observed in the output).  cv::Stitcher
            // does the same dilation step in its compose pipeline.
            // A 3×3 default kernel at seam scale becomes ~10px of
            // overlap at compose scale (since seam_aspect_compose ≈
            // 0.1 → 10× upscale), which is plenty for MultiBand's
            // Laplacian pyramids to blend smoothly across.
            //
            // The bitwise_and with the original mask keeps each frame's
            // mask within its actual warped region (seam-cut + dilation
            // can spill past edges, especially after linear upscale).
            for (size_t i = 0; i < N; i++) {
                cv::Mat seamMaskCpu, seamMaskDilated, seamMaskFull;
                masksWarpedU_seam[i].copyTo(seamMaskCpu);
                cv::dilate(seamMaskCpu, seamMaskDilated, cv::Mat());
                cv::resize(seamMaskDilated, seamMaskFull,
                           masksWarped[i].size(), 0, 0, cv::INTER_LINEAR);
                cv::bitwise_and(seamMaskFull, masksWarped[i], masksWarped[i]);
            }
            masksWarpedU_seam.clear();

            // Exposure compensation — parity with cv::Stitcher::PANORAMA,
            // which runs a GainCompensator before blending.  Without it,
            // per-frame auto-exposure differences surface as brightness
            // steps at the seams.  The manual path previously skipped this
            // entirely (the high-level path Android uses gets it for free),
            // which is one reason iOS output looked worse.  GAIN_BLOCKS
            // matches cv::Stitcher's default compensator.
            //
            // NOTE: BATCH path only — it has every warped frame in memory,
            // which the compensator needs before it can solve gains.  The
            // STREAM path (low-RAM, one frame at a time) can't feed the
            // compensator globally and keeps its current no-compensation
            // behaviour; see docs/stitch-pipeline-architecture.md.
            auto compensator = cv::detail::ExposureCompensator::createDefault(
                cv::detail::ExposureCompensator::GAIN_BLOCKS);
            {
                // 2026-06-16 (audit #1) — feed the compensator ZERO-COPY views,
                // not deep copies.  With HAVE_OPENCL undefined (both platforms)
                // getUMat(ACCESS_READ) shares the backing Mat buffer (no memcpy,
                // no GPU transfer); feed() takes const& and only READS pixels to
                // solve gains, so the output is byte-identical.  The old copyTo
                // loop DOUBLED the full-res warped held-set (~60-90 MB transient)
                // at the exact BATCH peak the canvas budget is tuned around —
                // imagesWarped[]/masksWarped[] are still live here (released in
                // the feed loop below).
                std::vector<cv::UMat> compImgs(N), compMasks(N);
                for (size_t i = 0; i < N; i++) {
                    compImgs[i]  = imagesWarped[i].getUMat(cv::ACCESS_READ);
                    compMasks[i] = masksWarped[i].getUMat(cv::ACCESS_READ);
                }
                compensator->feed(corners, compImgs, compMasks);
            }

            // Layer-2 guard (cumulative canvas): the union of all positioned
            // warp rects is exactly what blender->prepare() allocates as its
            // CV_16SC3 accumulator.  Every per-frame extent passed the
            // step8b guard above, but a single degenerate corner OFFSET can
            // still blow this union to gigapixels — the real crash-B path
            // (51 MB → 3.7 GB on one rapid pan).  Guard BEFORE prepare().
            int64_t canvasW = 0, canvasH = 0;
            blendCanvasUnion(corners, sizes, canvasW, canvasH);
            if (canvasExceedsGuard(canvasW, canvasH)) {
                log_error(logFn, "[stitch-bc]",
                          "step10a: blend canvas degenerate "
                          "(%lldx%lld px) — treating as warp failure",
                          (long long)canvasW, (long long)canvasH);
                throw degenerateCanvasException(
                    canvasW, canvasH, config.stitchMode, N);
            }
            // Feed the blender, releasing each frame as we go.  Log the union
            // + RSS: the union here MUST equal the step7.7 post-cap probe — a
            // mismatch means a co-scaled quantity was missed.  step10a2
            // isolates the persistent MultiBand accumulator (~the term the
            // canvas budget bounds).
            log_info(logFn, "[stitch-bc]",
                     "step10a: blender->prepare union=%lldx%lld (%.1f MP) mem=%.1fMB",
                     (long long)canvasW, (long long)canvasH,
                     (double)canvasW * (double)canvasH / 1e6, rss_mb());
            blender->prepare(corners, sizes);
            log_info(logFn, "[stitch-bc]",
                     "step10a2: prepared mem=%.1fMB", rss_mb());
            log_info(logFn, "[stitch-bc]",
                     "step10b: feeding blender (N=%zu)", N);
            for (size_t i = 0; i < N; i++) {
                log_info(logFn, "[stitch-bc]", "step10c: feed frame %zu", i);
                // Apply the per-frame exposure gain solved above, in place,
                // before converting + feeding the blender.
                compensator->apply(static_cast<int>(i), corners[i],
                                   imagesWarped[i], masksWarped[i]);
                cv::Mat imgS;
                imagesWarped[i].convertTo(imgS, CV_16S);
                blender->feed(imgS, masksWarped[i], corners[i]);
                imagesWarped[i].release();
                masksWarped[i].release();
                imgS.release();
            }
            imagesWarped.clear();
            masksWarped.clear();
            log_info(logFn, "[stitch-bc]", "step10d: feed loop done");
        } else {
            // ── STREAM path ────────────────────────────────────────────
            // Pre-pass: warp masks ONLY (single-channel, cheap) to
            // compute corners + sizes.  blender->prepare() needs both
            // BEFORE the first feed, so a tiny first pass is unavoidable.
            const size_t N = composeFrames.size();
            std::vector<cv::Point> corners(N);
            std::vector<cv::Size> sizes(N);
            for (size_t i = 0; i < N; i++) {
                cv::Mat K;
                cameras[i].K().convertTo(K, CV_32F);
                // Layer-1 guard (STREAM): probe the cheap warpRoi BEFORE the
                // real mask warp below.  Unlike BATCH, the STREAM path had no
                // per-frame net, so a degenerate ROI would OOM inside
                // warper->warp()'s buildMaps/remap allocation right here.
                const cv::Rect probe = warper->warpRoi(
                    composeFrames[i].size(), K, cameras[i].R);
                if (warpRoiExceedsGuard(probe.width, probe.height)) {
                    log_error(logFn, "[stitch-bc]",
                              "step8b(stream): warpRoi degenerate for frame "
                              "%zu (%dx%d) — treating as warp failure",
                              i, probe.width, probe.height);
                    throw degenerateFrameException(
                        probe.width, probe.height, config.stitchMode, i);
                }
                cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
                cv::Mat tmpMaskWarped;
                corners[i] = warper->warp(
                    mask, K, cameras[i].R, cv::INTER_NEAREST,
                    cv::BORDER_CONSTANT, tmpMaskWarped);
                sizes[i] = tmpMaskWarped.size();
            }

            // Main pass: warp + feed + release per frame.  Never holds
            // more than ONE warped image + ONE warped mask in memory.
            // ~40-50 MB lower peak vs the BATCH path at 1.0 MP × 8
            // frames — the difference between staying under iOS' jetsam
            // threshold on a 2 GB device and getting WatchdogTermination.
            // Layer-2 guard (cumulative canvas) — see the BATCH path for the
            // rationale.  Same union check before the STREAM prepare().
            {
                int64_t canvasW = 0, canvasH = 0;
                blendCanvasUnion(corners, sizes, canvasW, canvasH);
                if (canvasExceedsGuard(canvasW, canvasH)) {
                    log_error(logFn, "[stitch-bc]",
                              "step10(stream): blend canvas degenerate "
                              "(%lldx%lld px) — treating as warp failure",
                              (long long)canvasW, (long long)canvasH);
                    throw degenerateCanvasException(
                        canvasW, canvasH, config.stitchMode, N);
                }
            }
            blender->prepare(corners, sizes);
            for (size_t i = 0; i < N; i++) {
                cv::Mat K;
                cameras[i].K().convertTo(K, CV_32F);
                cv::Mat mask(composeFrames[i].size(), CV_8U, cv::Scalar(255));
                cv::Mat imgWarped, maskWarped;
                warper->warp(composeFrames[i], K, cameras[i].R,
                             cv::INTER_LINEAR, cv::BORDER_CONSTANT, imgWarped);
                warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST,
                             cv::BORDER_CONSTANT, maskWarped);
                cv::Mat imgS;
                imgWarped.convertTo(imgS, CV_16S);
                blender->feed(imgS, maskWarped, corners[i]);
                // Release the input compose frame too — done with it.
                composeFrames[i].release();
                // imgS / imgWarped / maskWarped release at scope exit.
            }
            composeFrames.clear();
        }

        cv::Mat panoramaS, panoramaMask;
        log_info(logFn, "[stitch-bc]", "step11a: blender->blend starting");
        blender->blend(panoramaS, panoramaMask);
        log_info(logFn, "[stitch-bc]",
                 "step11b: blend complete (panoramaS=%dx%d)",
                 panoramaS.cols, panoramaS.rows);
        panoramaS.convertTo(panorama, CV_8U);
        // Keep the blend coverage mask alive past this try scope (ref-
        // counted, so this is cheap) for the crop + sidecar below.
        coverageMask = panoramaMask;
        {
            auto _t = std::chrono::steady_clock::now();
            double _ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                _t - t0).count();
            log_info(logFn, "[BatchStitcher]",
                     "step11: blend complete (output %d×%d, t+%.0fms)",
                     panorama.cols, panorama.rows, _ms);
        }
        log_info(logFn, "[stitch-bc]",
                 "step11c: panorama 8U conversion done (panorama=%dx%d) mem=%.1fMB",
                 panorama.cols, panorama.rows, rss_mb());

        // Issue 3 — post-stitch validation.  Reject a disjoint / fragmented
        // output (frames that survived confidence but didn't fuse into one
        // panorama) so the host gets a clean failure (→ STITCH_LOW_QUALITY,
        // "try again") instead of a broken image.  Fails open on an
        // unreadable mask.  Capture the failure into the strong locals +
        // break out of the do/while(0) like the catch paths do.
        {
            std::string validateMessage;
            // singleFrameAreaPx = 0 → collapsed-placement check DISABLED on
            // the manual path: the collapse evidence (scans@0.3 affine
            // squeeze) is a high-level failure shape, and the manual pipeline
            // is a frozen legacy opt-in — enabling a new rejection here could
            // regress callers the 2026-08-17 redesign promised to leave
            // byte-identical.
            const StitchErrorCode validateCode = validateStitchOutput(
                panorama, panoramaMask,
                static_cast<int>(cameras.size()), 0.0, logFn, validateMessage);
            if (validateCode != StitchErrorCode::Ok) {
                log_error(logFn, "[stitch-bc]",
                          "step11d: REJECTED — %s", validateMessage.c_str());
                capturedErrorCode = validateCode;
                capturedErrorMessage = validateMessage;
                failedInsidePool = true;
                break;
            }
        }

        // Record retained-frame count for telemetry.  In the high-level
        // path this comes from stitcher->component().size() after retry;
        // in the manual path it's whatever leaveBiggestComponent kept
        // at the threshold that succeeded.
        result.framesIncluded = static_cast<int32_t>(cameras.size());
        // Threshold from the C+D progressive-confidence retry at PRUNE
        // granularity above.  Matches the high-level path's telemetry
        // semantics: -1.0 means we never ran the prune (shouldn't
        // happen on success-path), else the threshold that produced
        // ≥ 2 frames in the biggest component.
        result.finalConfidenceThresh = (pruneThresholdUsed > 0.0f)
            ? static_cast<double>(pruneThresholdUsed)
            : 1.0;
    } catch (const cv::Exception& e) {
        // Top-level catch: anything inside the pipeline that wasn't
        // caught by a stage-specific try/catch lands here.  Capture
        // into a strong local + break out of the do/while(0) wrapper.
        capturedErrorCode = StitchErrorCode::UnknownCvException;
        capturedErrorMessage = std::string("OpenCV exception during stitch: ") + e.what();
        failedInsidePool = true;
        break;
    } catch (const std::exception& e) {
        capturedErrorCode = StitchErrorCode::UnknownCvException;
        capturedErrorMessage = std::string("std exception during stitch: ") + e.what();
        failedInsidePool = true;
        break;
    } catch (...) {
        capturedErrorCode = StitchErrorCode::UnknownCvException;
        capturedErrorMessage = "Unknown exception during stitch.";
        failedInsidePool = true;
        break;
    }
    } while (0);

    // V16 fix-10 — handle failure paths captured from inside the pool.
    //
    // HISTORY (V16 fix-10, 2026-05-13): in the iOS original, the
    // closing @autoreleasepool brace USED to live at the very bottom
    // of the function, wrapping the return statement as well.  ARC
    // inserts an autorelease for the return value, which then
    // registered with this @autoreleasepool; the pool drained at the
    // closing brace, deallocating the return object BEFORE the
    // caller could `objc_retain` it.
    //
    // Fix-10 restructure: every failure path captures its return
    // value into a STRONG LOCAL declared above the pool
    // (`result`/`capturedError`) and `break`s out of the do/while(0)
    // wrapper to fall past the pool's closing brace cleanly.  The
    // strong locals survive the drain.
    //
    // In the pure-C++ port there is no @autoreleasepool — the
    // do/while(0) wrapper is kept purely for control-flow parity with
    // the iOS original.  C++ stack-locals have proper RAII lifetimes
    // so the drain UAF is impossible.
    //
    // See docs/site-content/learnings/react-native.md#autoreleasepool-return-uaf
    if (sentinelInsidePool || failedInsidePool) {
        result.errorCode = capturedErrorCode;
        result.errorMessage = capturedErrorMessage;
        const auto t1 = std::chrono::steady_clock::now();
        result.durationMs = std::chrono::duration_cast<std::chrono::milliseconds>(
            t1 - t0).count();
        return result;
    }

    if (panorama.empty()) {
        result.errorCode = StitchErrorCode::EmptyPanorama;
        result.errorMessage = "Stitcher produced an empty panorama.";
        // framesIncluded was already set above (line ~1640 in the
        // success-path block).  Leave it as-is — it reflects the
        // count of cameras that fed the blender.
        return result;
    }

    // Crop the panorama to the bounding box of non-black pixels.
    //
    // The default SphericalWarper from PANORAMA mode lays the
    // captured patch into a much larger sphere-shaped canvas.  For
    // a typical 30-45° shelf-scan arc, that means the actual scene
    // occupies a small region of a much larger black-bordered
    // image (the "panorama bowl" effect).  Cropping to the
    // content's bounding box returns the actual stitched scene
    // without the surrounding empty bowl.  Algorithm:
    //   1. Convert to grayscale.
    //   2. Threshold > 1 to find any non-black pixel.
    //   3. boundingRect of all non-zero pixels.
    //   4. Crop the panorama to that rect.
    //
    // V16 Phase 1b.fix3 — maximum-inscribed-rectangle crop (was bbox).
    // cv::Stitcher's compose stage produces irregular black corners
    // where the warped frames didn't fill; cv::boundingRect was
    // including those.  MaxInscribedRectFromMask finds the largest
    // axis-aligned rectangle entirely inside the non-zero region —
    // clean output with no black corners.  Falls back to bbox
    // (and ultimately the un-cropped panorama) on any OpenCV failure.
    //
    // V16 Phase 1b.fix5 — RCA from Ram's first fix3 capture: the
    // raw inscribed-rect collapsed to a thin sliver in the
    // landscape output.  Cause: cv::Stitcher's compose produces
    // small scattered zero-pixels INSIDE the content region (graph-
    // cut seam, exposure-comp rounding, multi-band blend artifacts).
    // The inscribed-rect algorithm demands a strictly hole-free
    // rectangle, so a single interior zero forces it to either
    // avoid that pixel (collapsing to a thin strip) or skip the
    // affected row entirely.  Python simulation on a realistic
    // 800×200 mask with 0.5% scattered holes:
    //
    //     raw inscribed-rect    →   23×100 = 1.4% of original (BUG)
    //     after 5×5 close       → 642×196 = 78.6% of original (clean)
    //     bounding rect          → 800×200 = 100%
    //
    // Fix: morphologically CLOSE the mask before the inscribed-rect
    // search — a 5×5 close fills holes ≤5 px (more than enough for
    // compose artifacts) without bridging across legitimate concave
    // gaps (which cv::Stitcher panoramas don't really have).  Keep
    // the bbox safety floor: if the inscribed rect still came out
    // < 50% of bbox area, use bbox — the mask shape is pathological
    // and shipping bbox-with-corners is better than a sliver.
    cv::Mat finalImage = panorama;
    // v0.15 — coverage cropped to the same region(s) as finalImage, for
    // the debug-harness sidecar written after rotation below.
    cv::Mat coverageCropped;
    const bool haveCoverage =
        (!coverageMask.empty() && coverageMask.size() == panorama.size());
    try {
        // Prefer the TRUE coverage mask (blender dst_mask): dark content a
        // frame painted is kept; only never-covered pixels drop. Fall back
        // to a brightness mask only if coverage is somehow unavailable.
        cv::Mat mask;
        if (haveCoverage) {
            cv::threshold(coverageMask, mask, 0, 255, cv::THRESH_BINARY);
        } else {
            cv::Mat gray;
            cv::cvtColor(panorama, gray, cv::COLOR_BGR2GRAY);
            cv::threshold(gray, mask, 1, 255, cv::THRESH_BINARY);
        }

        // V16 Phase 1b.fix5c — operator-toggleable crop strategy.
        //
        //   useInscribedRectCrop = NO (v0.15 default):
        //     Final crop is just cv::boundingRect(mask) — preserves all
        //     stitched content at the cost of possible black corners
        //     where cv::Stitcher's projection didn't fill.
        //
        //   useInscribedRectCrop = YES (opt in via prop / settings modal):
        //     Run the full inscribed-rect pipeline (morph-close + 50%
        //     safety floor + column-projection second pass) for a clean
        //     -cornered rectangle.  Can over-aggressively shrink the
        //     output on lopsided masks (1146×1102 bbox → 602×1102 strip
        //     in one field log) — which is why it's opt-in, not the default.
        cv::Rect bbox;
        if (config.useInscribedRectCrop) {
            cv::Mat closedMask;
            cv::morphologyEx(
                mask, closedMask, cv::MORPH_CLOSE,
                cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)));
            bbox = maxInscribedRectFromMask(closedMask);
            cv::Rect bboxFallback = cv::boundingRect(mask);
            const long long inscribedArea =
                (long long)bbox.width * bbox.height;
            const long long fallbackArea =
                (long long)bboxFallback.width * bboxFallback.height;
            if (bbox.width <= 0 || bbox.height <= 0
                || inscribedArea * 2 < fallbackArea) {
                // Either degenerate, or inscribed < 50% of bbox area.
                // Safety floor: ship bbox so the operator gets *something*
                // usable (legacy behaviour pre-fix3) rather than a sliver.
                log_info(logFn, "[BatchStitcher]",
                         "inscribed-rect rejected: "
                         "%dx%d (area=%lld) vs bbox %dx%d (area=%lld); "
                         "using bbox fallback.",
                         bbox.width, bbox.height, inscribedArea,
                         bboxFallback.width, bboxFallback.height, fallbackArea);
                bbox = bboxFallback;
            } else {
                log_info(logFn, "[BatchStitcher]",
                         "inscribed-rect: %dx%d "
                         "(area=%lld, %.0f%% of bbox %dx%d)",
                         bbox.width, bbox.height, inscribedArea,
                         100.0 * (double)inscribedArea / (double)fallbackArea,
                         bboxFallback.width, bboxFallback.height);
            }
        } else {
            bbox = cv::boundingRect(mask);
            log_info(logFn, "[BatchStitcher]",
                     "crop: bbox-only %dx%d "
                     "(useInscribedRectCrop=NO via setting)",
                     bbox.width, bbox.height);
        }
        if (bbox.width > 0 && bbox.height > 0
            && bbox.width <= panorama.cols && bbox.height <= panorama.rows) {
            finalImage = panorama(bbox).clone();
            if (haveCoverage) coverageCropped = coverageMask(bbox).clone();
        }

        // V16 Phase 1b.fix5c — column-projection second pass ALSO gated
        // on the inscribed-rect toggle.  When OFF, skip directly to the
        // write so the operator sees the full bbox-cropped panorama
        // without further trimming.  When ON, keep the existing
        // 95%-then-80%-then-skip relaxation chain.
        if (config.useInscribedRectCrop) {
            // Second pass: rectangular crop.  Find the column range where
            // ≥95% of rows have content, crop to that × full height.
            cv::Mat finalGray;
            cv::cvtColor(finalImage, finalGray, cv::COLOR_BGR2GRAY);
            cv::Mat finalMask;
            cv::threshold(finalGray, finalMask, 30, 255, cv::THRESH_BINARY);
            cv::erode(finalMask, finalMask,
                      cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)),
                      cv::Point(-1, -1), 1);

            int rows = finalMask.rows, cols = finalMask.cols;
            // Reduce mask to per-column content count.  Mask is 0 or 255,
            // so column sum / 255 = number of content rows in that column.
            cv::Mat colSum;
            cv::reduce(finalMask, colSum, 0, cv::REDUCE_SUM, CV_32S);
            const int contentThreshold = (int)(0.95 * rows * 255);
            int cropLeft = -1, cropRight = -1;
            const int* cs = colSum.ptr<int>(0);
            for (int c = 0; c < cols; c++) {
                if (cs[c] >= contentThreshold) {
                    if (cropLeft < 0) cropLeft = c;
                    cropRight = c;
                }
            }
            log_info(logFn, "[BatchStitcher]",
                     "rectCrop col-proj: cols=%d rows=%d threshold=%d cropLeft=%d cropRight=%d",
                     cols, rows, contentThreshold, cropLeft, cropRight);
            // Sanity floor: don't accept a column-projection crop that
            // shrinks the image to less than 30% of the bbox-cropped width.
            // Such an aggressive crop usually means the stitch was poorly
            // aligned and only a tiny vertical band has full multi-frame
            // coverage — applying it produces the "thin sliver" output
            // we observed in the field.  Better to show the user the full
            // bounding-box crop (still trims the all-black borders) than
            // a sliver that's effectively useless.
            const int minRectWidth = (int)(cols * 0.30);
            if (cropLeft >= 0 && cropRight > cropLeft + 10
                && (cropRight - cropLeft + 1) >= minRectWidth) {
                cv::Rect rectCrop(cropLeft, 0,
                                  cropRight - cropLeft + 1, rows);
                finalImage = finalImage(rectCrop).clone();
                if (!coverageCropped.empty())
                    coverageCropped = coverageCropped(rectCrop).clone();
                log_info(logFn, "[BatchStitcher]",
                         "rectCrop applied: %dx%d → %dx%d",
                         cols, rows, finalImage.cols, finalImage.rows);
            } else {
                // No column qualified at 95%, OR the qualifying band is too
                // narrow to trust.  Try a relaxed 80% before giving up.
                const int relaxedThreshold = (int)(0.80 * rows * 255);
                cropLeft = -1;
                cropRight = -1;
                for (int c = 0; c < cols; c++) {
                    if (cs[c] >= relaxedThreshold) {
                        if (cropLeft < 0) cropLeft = c;
                        cropRight = c;
                    }
                }
                log_info(logFn, "[BatchStitcher]",
                         "rectCrop relaxed (80%%): cropLeft=%d cropRight=%d",
                         cropLeft, cropRight);
                if (cropLeft >= 0 && cropRight > cropLeft + 10
                    && (cropRight - cropLeft + 1) >= minRectWidth) {
                    cv::Rect rectCrop(cropLeft, 0,
                                      cropRight - cropLeft + 1, rows);
                    finalImage = finalImage(rectCrop).clone();
                    if (!coverageCropped.empty())
                        coverageCropped = coverageCropped(rectCrop).clone();
                    log_info(logFn, "[BatchStitcher]",
                             "rectCrop relaxed applied: %dx%d → %dx%d",
                             cols, rows, finalImage.cols, finalImage.rows);
                } else {
                    log_info(logFn, "[BatchStitcher]",
                             "rectCrop SKIPPED — best band is "
                             "narrower than 30%% of bbox (%d < %d).  Likely poor "
                             "stitch alignment; keeping bbox crop.",
                             cropRight >= 0 ? (cropRight - cropLeft + 1) : 0,
                             minRectWidth);
                }
            }
        }
    } catch (...) {
        // Crop failed — fall back to the raw stitched output.
        finalImage = panorama;
    }

    // AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
    //
    // Bake-rotation driven by the user's phone-hold orientation at
    // capture start, NOT the output Mat's aspect ratio.  Reasoning:
    //
    //   - fix5d's earlier attempt to key off the same orientation used
    //     `exifOrientation:NSInteger` (an EXIF tag 1/3/6/8), inferred
    //     from frameRotationDegrees, which collapsed landscape-left
    //     and landscape-right to the same value.  Ram's reports made
    //     it clear the two landscape variants need OPPOSITE rotations
    //     (they're mirror images of each other w.r.t. the sensor's
    //     world-up direction), so the EXIF-tag intermediary was lossy.
    //
    //   - fix5e's aspect-ratio approach was correct for 3+ frame
    //     horizontal pans but the threshold "cols > rows" was fragile
    //     at 2 frames (output Mat near-square or even tall) and
    //     conflated "wide because horizontal pan" with "tall because
    //     vertical pan."  The user spec explicitly lists two modes;
    //     using their classification is more robust than guessing
    //     from output geometry.
    //
    // The two supported modes:
    //
    //   Mode A — landscape phone + vertical pan from top
    //     landscape-left  → ROTATE_90_COUNTERCLOCKWISE
    //     landscape-right → ROTATE_90_CLOCKWISE
    //       (mirror-image directions because world-up sits on opposite
    //        sensor edges between landscape-left and landscape-right;
    //        opposite rotations land world-up at output-top for both)
    //
    //   Mode B — portrait phone + horizontal pan from left
    //     portrait              → no rotation (cv::Stitcher's natural
    //                              output already aligns world-up to
    //                              output-top for portrait hold)
    //     portrait-upside-down  → ROTATE_180
    //
    // Anything else: best-effort no rotation.  Unsupported combination
    // (e.g., portrait phone + vertical pan) is treated as Mode B.
    //
    // Properties:
    //   - Compose canvas geometry unchanged from baseline 437c763:
    //     cv::imread default applies EXIF rotation at load time,
    //     producing portrait Mats for portrait hold and landscape
    //     Mats for landscape hold.  No fix5b-style 6-frame OOM.
    //   - Output JPEG always EXIF=1 in the iOS original (ImageIO
    //     writer with kCGImagePropertyOrientation=1).  In the shared
    //     port we use cv::imwrite which doesn't write EXIF — the
    //     pixels are already rotated correctly, so the visual result
    //     matches.  See TODO[shared-stitcher-port-part-2] for a
    //     proper EXIF-aware writer if iOS callers report viewers
    //     that ignore the pixel rotation.
    //   - The cv::rotate happens AFTER BA / blend / seam-find when
    //     their working sets are released — incremental memory cost.
    //   - Per-keyframe JPEGs (OpenCVKeyframeCollector) untouched —
    //     they still carry EXIF=6 so LiveFrameStrip thumbnails show
    //     portrait-correct during capture.
    //
    // Empirically calibrated (Ram's 2026-05-11 test, iteration 2):
    // Iteration 1 swapped both the labels AND the directions — net
    // visual rotation per roll-value was unchanged (output still
    // looked "landscape-left oriented" to Ram).  Iteration 2 flips
    // ONLY the directions; labels stay where they landed.
    //   landscape-left  (roll ≈ -90°, Ram's L-left hold)  → 90° CCW
    //   landscape-right (roll ≈ +90°, Ram's L-right hold) → 90° CW
    // For a roll=-90° capture (what Ram tested), this rotates the
    // OPPOSITE direction from iteration 1.  If iteration 1 put
    // scene-up on the LEFT of the tall image, iteration 2 will put
    // scene-up on the RIGHT.
    // 2026-05-18 (Iss #1 diag): log pre-bake Mat shape so we can
    // tell, from a device-log dump alone, whether the stitcher output
    // is landscape-aspect or portrait-aspect BEFORE the rotation is
    // applied.  bake_rotation already logs the rotated path's input
    // and output dims; the no-rotation branch logs only one pair.
    // Either way, this line is the source-of-truth for the pre-bake
    // shape and the captureOrientation that will be matched against.
    log_info(logFn, "[stitch]",
             "pre-bake finalImage %dx%d orientation=%s",
             finalImage.cols, finalImage.rows,
             config.captureOrientation.c_str());
    cv::Mat finalImageRotated = bake_rotation(finalImage,
                                              config.captureOrientation,
                                              logFn);

    // v0.15 — best-effort coverage sidecar (<output>.coverage.png),
    // cropped + rotated to match the written JPEG, for the debug harness
    // (computeInscribedRect / debugMaskOverlay prefer it over brightness).
    if (!coverageCropped.empty()) {
        try {
            const cv::Mat covRot = bake_rotation(coverageCropped,
                                                 config.captureOrientation,
                                                 logFn);
            cv::imwrite(outputPath + ".coverage.png", covRot);
        } catch (...) {
            // sidecar is debug-only — ignore failures
        }
    }

    // Encode + write the JPEG.  Clamp quality into [0, 100] to defend
    // against caller bugs.
    //
    // V16 Phase 1b.fix3 (iOS original) — write via ImageIO so we can
    // bake the EXIF Orientation tag into the output.  cv::imwrite
    // produces a plain JPEG with no metadata.  In the shared port we
    // rely on `bake_rotation` rotating pixels in-place above, so the
    // EXIF tag is unnecessary for correct display — kept as a TODO
    // below in case downstream consumers expect EXIF=1.
    const int q = std::max(0, std::min(100, config.jpegQuality));
    std::vector<int> params{cv::IMWRITE_JPEG_QUALITY, q};
    bool wrote = false;
    try {
        wrote = cv::imwrite(outputPath, finalImageRotated, params);
    } catch (const cv::Exception& e) {
        result.errorCode = StitchErrorCode::ImageWriteFailed;
        result.errorMessage = std::string("cv::imwrite threw: ") + e.what();
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }
    if (!wrote) {
        result.errorCode = StitchErrorCode::ImageWriteFailed;
        result.errorMessage = "Stitch succeeded but could not write JPEG to " + outputPath;
        log_error(logFn, "[stitch]", "%s", result.errorMessage.c_str());
        return result;
    }

    // V16 Phase 1b.fix5d — report the dimensions of the bytes we
    // actually wrote (rotated, if we baked one in above), not the
    // pre-rotate Mat.  JS-side consumers need the displayable shape.
    const auto t1 = std::chrono::steady_clock::now();
    result.success                = true;
    result.errorCode              = StitchErrorCode::Ok;
    result.width                  = finalImageRotated.cols;
    result.height                 = finalImageRotated.rows;
    result.durationMs             = std::chrono::duration_cast<std::chrono::milliseconds>(
                                       t1 - t0).count();
    return result;
}

}  // namespace retailens
