diff --git a/native_bridge/src/FxGuiServer.cpp b/native_bridge/src/FxGuiServer.cpp index c2b1baf..42b4b0f 100644 --- a/native_bridge/src/FxGuiServer.cpp +++ b/native_bridge/src/FxGuiServer.cpp @@ -49,6 +49,9 @@ extern void fxGuiRequestCapture(void* hwnd, std::string& outB64, int timeoutMs); // Tell RenderFxJob's SHM audio feeder which realtime session is live (the // frontend pushes the session name; "" = session stopped/restarted). extern void fxGuiSetShm(const std::string& name); +// True once the VST editor view is attached (RenderFxJob.cpp) — /show must +// not run its first-paint kick before the editor exists. +extern bool fxGuiEditorAttached(); namespace { @@ -163,6 +166,14 @@ void sendOptions(SOCKET c) { // frame, while the GDI BitBlt view is a stale first paint for GPU-rendered // editors (JUCE/FAST Compressor) and never updates. BitBlt stays as fallback. // Returns JPEG. +// Mean luma (0..255) of the raw BGRA frame — black-frame guard below. +static double frameMean(const unsigned char* px, int w, int h) { + const size_t n = (size_t)w * h; + unsigned long long s = 0; + for (size_t i = 0; i < n; ++i) + s += px[i * 4 + 2] + px[i * 4 + 1] + px[i * 4 + 0]; + return (double)s / (3.0 * (double)n); +} bool captureHwndToJpeg(HWND hwnd, std::vector& out, int quality) { RECT rc; if (!GetClientRect(hwnd, &rc)) return false; @@ -195,8 +206,18 @@ bool captureHwndToJpeg(HWND hwnd, std::vector& out, int quality) // window (child + GPU surfaces). The GDI BitBlt view is a stale first paint // for GPU-rendered editors (JUCE/FAST Compressor) and never updates, which // made the panel look frozen. BitBlt stays only as a fallback. + // Black-frame guard: PrintWindow returns TRUE with an ALL-BLACK frame for + // editors that have not painted yet (iZotope-class, e.g. Nectar — the + // /show first-paint kick fixes the window, but until then BitBlt is the + // only source of real pixels). When PrintWindow is pure black and the GDI + // view has content, send the GDI view instead of a black panel. unsigned char* px = nullptr; - if (okP) px = pxP.data(); + if (okP && okB) { + const double mP = frameMean(pxP.data(), w, h); + const double mB = frameMean(pxB.data(), w, h); + if (mP < 6.0 && mB > 6.0) px = pxB.data(); + else px = pxP.data(); + } else if (okP) px = pxP.data(); else if (okB) px = pxB.data(); SelectObject(mem, oldBmp); DeleteObject(bmp); @@ -251,6 +272,11 @@ std::atomic g_realInput{true}; // always real hardware input std::atomic g_dance{false}; // window temporarily on-screen/topmost std::atomic g_parkUntil{0}; // safety restore deadline (GetTickCount) POINT g_savedCursor = {0, 0}; // cursor to restore after the dance +// iZotope-class editors only paint their surface once the window has been +// activated (SetForegroundWindow) at least once; parked off-screen they stay +// black forever. /show performs a one-time kick (on-screen + foreground + +// invalidate), then demotes the window under the DAW. Reset on /hide. +std::atomic g_paintKicked{false}; // Anchored mode: the frontend sends the embed panel's screen rect (physical // px) with every /input, so the real-input dance places the plugin window // exactly over the panel -- cursor never moves, no park/restore churn. Zero @@ -508,6 +534,7 @@ void runHttpServer(HWND hwnd, std::atomic& stop) { SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); ShowWindow(hwnd, SW_HIDE); + g_paintKicked = false; // next /show re-kicks the first paint sendResponse(c, 200, "application/json", "{\"ok\":true}", 11); } else if (req.path == "/show") { // Optional body {rect:{x,y,w,h}} (screen px) — anchored placement @@ -534,26 +561,70 @@ void runHttpServer(HWND hwnd, std::atomic& stop) { } } if (sroot) std::free(sroot); - // Embedded mode: the native window NEVER comes on-screen — it stays - // parked off-screen at -20000,-20000 (GDI capture + synthetic input - // work regardless of position). The panel rect sizes the host - // window + plugin view so the capture frame matches the embed panel - // (layout bug); /hide hid the window, so re-show it off-screen so - // /frame keeps capturing. - if (rectOk) { + // Embedded mode: the native window stays parked ON-SCREEN under + // the DAW (HWND_BOTTOM at the embed panel rect — invisible, the + // DAW window covers it). iZotope-class editors (Nectar) NEVER + // paint while off-screen: both PrintWindow and BitBlt stay pure + // black, so the panel showed "can't load GUI". On-screen the + // editor paints once (first-paint kick below) and the capture + // surface keeps content. The panel rect sizes the host window + + // plugin view so the capture frame matches the embed panel + // (layout bug); /hide hid the window, so re-show it. + if (rectOk && !g_anchored) { const int cw = g_embedRect.right - g_embedRect.left; const int ch = g_embedRect.bottom - g_embedRect.top; RECT cur = {}; GetWindowRect(hwnd, &cur); - // Skip when already the right size: frontend re-POSTs /show - // every ~500ms; re-laying-out the plugin view every tick is - // wasteful (WM_FXGUI_RESIZE -> checkSizeConstraint/onSize). + const bool samePlace = + cur.left == g_embedRect.left && cur.top == g_embedRect.top && + cur.right - cur.left == cw && cur.bottom - cur.top == ch; + // Skip when already parked right: frontend re-POSTs /show every + // ~500ms; re-laying-out the plugin view every tick is wasteful + // (WM_FXGUI_RESIZE -> checkSizeConstraint/onSize). + if (!samePlace || !IsWindowVisible(hwnd)) + SetWindowPos(hwnd, HWND_BOTTOM, g_embedRect.left, g_embedRect.top, + cw, ch, SWP_NOACTIVATE | SWP_SHOWWINDOW); if (cur.right - cur.left != cw || cur.bottom - cur.top != ch) fxGuiRequestResize(hwnd, cw, ch); - } - if (!IsWindowVisible(hwnd)) + if (!g_paintKicked && fxGuiEditorAttached()) { + // First-paint kick (one-time per /show cycle). iZotope-class + // editors (Nectar) paint ONLY when their window is actually + // visible (not covered) AND activated: parked HWND_BOTTOM + // under the DAW the editor never paints -> black forever, + // and SetForegroundWindow from this background process is + // blocked by the Windows foreground lock (returns FALSE). + // So: raise the window ABOVE the DAW (invisible blink — the + // DAW covers the panel rect anyway), paint it there, then + // demote back under the DAW; the painted surface survives + // the demote (verified: capture stays non-black at HWND_BOTTOM). + g_paintKicked = true; + SetWindowPos(hwnd, HWND_TOP, g_embedRect.left, g_embedRect.top, + cw, ch, SWP_SHOWWINDOW | SWP_NOACTIVATE); + ShowWindow(hwnd, SW_SHOW); + InvalidateRect(hwnd, nullptr, TRUE); + RedrawWindow(hwnd, nullptr, nullptr, + RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); + // Foreground-lock bypass: a real synthesized click (system + // input, not subject to the SetForegroundWindow lock) + // activates the window the same way a user click would. + // iZotope-class editors paint only after such an activation. + Sleep(200); + RECT wr = {}; + GetWindowRect(hwnd, &wr); + const int cx = wr.left + (wr.right - wr.left) / 2; + const int cy = wr.top + 8; // title-bar strip, avoid knobs + SetCursorPos(cx, cy); + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); + Sleep(500); // give the editor a beat to paint while on top + SetWindowPos(hwnd, HWND_BOTTOM, g_embedRect.left, g_embedRect.top, + cw, ch, SWP_NOACTIVATE); + std::cerr << "[FxGui] first-paint kick fg=click @" << cx << "," << cy << std::endl; + } + } else if (!rectOk && !IsWindowVisible(hwnd)) { SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0, SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW); + } // Dance/anchor state is owned by the input relay — /show must not // yank the window off-screen (real input is always on). sendResponse(c, 200, "application/json", "{\"ok\":true}", 11); diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index e3dc866..d880093 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.cpp @@ -1519,6 +1519,11 @@ namespace { const char* kFxGuiWndClass = "SonicForge_FX_GUI_Class"; +// Set once the editor view is attached (fxGuiLoadAttach success) — the +// FxGuiServer /show handler skips its one-time first-paint kick until the +// editor exists, so the kick cannot fire on a bare host window. +std::atomic g_fxGuiEditorAttached{false}; + struct FxGuiCtx { bool closing = false; }; @@ -1658,6 +1663,9 @@ void fxGuiRequestResize(HWND hwnd, int w, int h) { PostMessageA(hwnd, WM_FXGUI_RESIZE, (WPARAM)w, (LPARAM)h); } +// Editor-attach state for the /show first-paint kick (FxGuiServer.cpp). +bool fxGuiEditorAttached() { return g_fxGuiEditorAttached.load(); } + // Capture the GUI plugin state on the pump thread; blocks up to timeoutMs. void fxGuiRequestCapture(void* hwndV, std::string& outB64, int timeoutMs) { HWND hwnd = (HWND)hwndV; @@ -1889,6 +1897,7 @@ static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::strin if (fxGuiAttachSafe(fresh, hwnd)) { fx->stealState(fresh); delete fresh; + g_fxGuiEditorAttached = true; // /show first-paint kick only after editor exists std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl; return 0; }