Fix FX GUI panel frozen: prefer PrintWindow live frame + real-input fallback for GPU-rendered editors

This commit is contained in:
2026-08-18 21:24:33 +07:00
parent 7bc96eebbc
commit d07f626a6c
5 changed files with 156 additions and 31 deletions
+153 -28
View File
@@ -27,6 +27,7 @@
#include <atomic>
#include <cstring>
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
@@ -149,9 +150,10 @@ void sendOptions(SOCKET c) {
// --- frame capture -----------------------------------------------------------
// Capture the window client rect twice — BitBlt (GDI view) and PrintWindow
// with PW_RENDERFULLCONTENT (DWM composition, includes DirectX/GPU surfaces
// that GDI cannot see) — and keep the frame with more visual detail. Ozone 11
// and other GPU-rendered plugins paint WHITE into the GDI DC, so the old
// all-black fallback never fired and a white frame was served. Returns JPEG.
// that GDI cannot see). PrintWindow is preferred: it returns the live composed
// 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.
bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality) {
RECT rc;
if (!GetClientRect(hwnd, &rc)) return false;
@@ -175,29 +177,17 @@ bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality)
bi.bmiHeader.biCompression = BI_RGB;
return GetDIBits(mem, bmp, 0, h, p, &bi, DIB_RGB_COLORS) == h;
};
auto detailScore = [&](const unsigned char* p) -> long long {
// Count sampled pixels that are neither near-black nor near-white.
// GPU-rendered plugins (Ozone 11, ...) are invisible to GDI: BitBlt
// yields a white/black-strip frame with ~2 distinct values, while the
// real UI has many mid-grey levels. Pick the frame with more detail.
long long d = 0;
const int step = 4;
for (int y = 0; y < h; y += step)
for (int x = 0; x < w; x += step) {
const size_t i = ((size_t)y * w + x) * 4;
const unsigned lum = ((unsigned)p[i] * 77u + (unsigned)p[i + 1] * 150u + (unsigned)p[i + 2] * 29u) >> 8;
if (lum >= 28 && lum <= 232) ++d; // exclude near-black/white
}
return d;
};
bool okB = BitBlt(mem, 0, 0, w, h, win, 0, 0, SRCCOPY | CAPTUREBLT) != 0 && grabInto(pxB.data());
// PW_RENDERFULLCONTENT asks DWM to compose the window (includes child
// windows + DirectX surfaces) — the only way to see GPU-rendered editors.
bool okP = PrintWindow(hwnd, mem, PW_RENDERFULLCONTENT) != 0 && grabInto(pxP.data());
// Prefer the PrintWindow frame: PW_RENDERFULLCONTENT composes the live
// 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.
unsigned char* px = nullptr;
if (okB && okP) px = detailScore(pxP.data()) > detailScore(pxB.data()) ? pxP.data() : pxB.data();
else if (okP) px = pxP.data();
if (okP) px = pxP.data();
else if (okB) px = pxB.data();
SelectObject(mem, oldBmp);
DeleteObject(bmp);
@@ -240,32 +230,162 @@ bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality)
return !out.empty();
}
// --- real-input fallback ------------------------------------------------------
// Some editors (JUCE, iZotope) reject synthetic PostMessage input entirely and
// only react to real hardware input while their window is visible. Probe: if 2
// consecutive clicks do not change the frame, switch this plugin to real-input
// mode — briefly move the window topmost to the work-area corner, inject real
// mouse events at the mapped screen position, then restore it off-screen.
std::atomic<bool> g_realInput{false}; // plugin needs real hardware input
// probe: hit (frame changed) resets g_miss; 2 consecutive misses switch
// to real input. A one-off "hit" (e.g. initial settle repaint) must NOT
// lock us onto PostMessage, so there is no permanent "detected" state.
std::atomic<bool> g_dance{false}; // window temporarily on-screen/topmost
std::atomic<DWORD> g_parkUntil{0}; // safety restore deadline (GetTickCount)
POINT g_savedCursor = {0, 0}; // cursor to restore after the dance
int g_miss = 0; // consecutive no-response PostMessage clicks
uint64_t g_preClickHash = 0;
uint64_t frameHash(HWND hwnd) {
std::vector<unsigned char> jpg;
if (!captureHwndToJpeg(hwnd, jpg, 70)) return 0;
uint64_t h = 1469598103934665603ull; // FNV-1a 64
for (unsigned char b : jpg) { h ^= b; h *= 1099511628211ull; }
return h;
}
void danceStart(HWND hwnd) {
if (g_dance) return;
RECT wa = {0}, wr = {0};
SystemParametersInfoA(SPI_GETWORKAREA, 0, &wa, 0);
GetWindowRect(hwnd, &wr);
const int w = wr.right - wr.left, h = wr.bottom - wr.top;
GetCursorPos(&g_savedCursor);
SetWindowPos(hwnd, HWND_TOPMOST, wa.right - w, wa.bottom - h, 0, 0,
SWP_NOSIZE | SWP_SHOWWINDOW);
Sleep(60); // let the compositor repaint so the editor sees itself active
g_dance = true;
g_parkUntil = GetTickCount() + 3000;
}
void danceEnd(HWND hwnd) {
if (!g_dance) return;
SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE);
SetCursorPos(g_savedCursor.x, g_savedCursor.y);
g_dance = false;
g_parkUntil = 0;
}
void realClick(int sx, int sy, bool down, int button) {
SetCursorPos(sx, sy);
Sleep(10);
DWORD flags;
if (button == 2) flags = down ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_RIGHTUP;
else if (button == 1) flags = down ? MOUSEEVENTF_MIDDLEDOWN : MOUSEEVENTF_MIDDLEUP;
else flags = down ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP;
mouse_event(flags, 0, 0, 0, 0);
}
// --- input relay -------------------------------------------------------------
void relayInput(HWND hwnd, const json_object_s* o) {
// The plugin editor is a CHILD window created by IPlugView::attached().
// DefWindowProc on the top-level host does NOT forward mouse/keyboard to
// it, so post directly to the view (fallback to the host window).
HWND target = FindWindowExA(hwnd, nullptr, nullptr, nullptr);
if (!target) target = hwnd;
const std::string type = memberString(o, "type", "");
const int x = (int)memberInt(o, "x", 0);
const int y = (int)memberInt(o, "y", 0);
const int button = (int)memberInt(o, "button", 0);
const int buttons = (int)memberInt(o, "buttons", 0); // DOM e.buttons bitmask
const int deltaY = (int)memberInt(o, "deltaY", 0);
const WPARAM keyCode = (WPARAM)memberInt(o, "keyCode", 0);
const LPARAM lp = MAKELPARAM(x, y);
// DOM buttons: 1=left 2=right 4=middle -> MK_LBUTTON/MK_RBUTTON/MK_MBUTTON.
WPARAM down = 0;
if (buttons & 1) down |= MK_LBUTTON;
if (buttons & 2) down |= MK_RBUTTON;
if (buttons & 4) down |= MK_MBUTTON;
const bool isDown = type == "mousedown";
const bool isUp = type == "mouseup";
// Mouseleave reports -1,-1: finish the drag without clicking a bogus point.
const bool outside = x < 0 || y < 0;
// Probe while still on PostMessage: a changed frame means PostMessage
// responds (reset miss); two consecutive no-response clicks mean the
// editor rejects synthetic input — switch to real hardware input.
if (!g_realInput) {
if (isDown) {
g_preClickHash = frameHash(hwnd);
} else if (isUp && g_preClickHash) {
Sleep(250);
uint64_t nowHash = frameHash(hwnd);
bool resp = nowHash != g_preClickHash;
if (resp) {
g_miss = 0; // PostMessage responded — keep it
} else if (++g_miss >= 2) {
g_realInput = true; // synthetic input rejected — go real
g_miss = 0;
}
g_preClickHash = 0;
}
}
if (g_realInput && (isDown || isUp || type == "mousemove" || type == "wheel")) {
if (isDown) {
danceStart(hwnd);
POINT pt = { x, y };
if (!outside) ClientToScreen(target, &pt);
realClick(pt.x, pt.y, true, button);
} else if (type == "mousemove") {
if (g_dance) {
POINT pt = { x, y };
ClientToScreen(target, &pt);
SetCursorPos(pt.x, pt.y);
g_parkUntil = GetTickCount() + 3000; // keep up during drag
}
} else if (isUp) {
if (g_dance) {
if (!outside) {
POINT pt = { x, y };
ClientToScreen(target, &pt);
realClick(pt.x, pt.y, false, button);
}
danceEnd(hwnd);
}
} else if (type == "wheel") {
danceStart(hwnd);
POINT pt = { x, y };
if (!outside) ClientToScreen(target, &pt);
SetCursorPos(pt.x, pt.y);
mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (DWORD)(-(int64_t)deltaY * 120), 0);
g_parkUntil = GetTickCount() + 600; // stay one beat, then restore
}
return;
}
if (type == "mousemove") {
PostMessageA(hwnd, WM_MOUSEMOVE, 0, lp);
PostMessageA(target, WM_MOUSEMOVE, down, lp); // button state => drag works
} else if (type == "mousedown") {
UINT m = button == 2 ? WM_RBUTTONDOWN : button == 1 ? WM_MBUTTONDOWN : WM_LBUTTONDOWN;
PostMessageA(hwnd, m, MAKEWPARAM(0, button == 2 ? MK_RBUTTON : button == 1 ? MK_MBUTTON : MK_LBUTTON), lp);
PostMessageA(hwnd, WM_MOUSEMOVE, 0, lp); // plugins tracking absolute pos
WPARAM w = button == 2 ? MK_RBUTTON : button == 1 ? MK_MBUTTON : MK_LBUTTON;
PostMessageA(target, m, w, lp);
PostMessageA(target, WM_MOUSEMOVE, w, lp); // plugins tracking absolute pos
SetFocus(target);
} else if (type == "mouseup") {
UINT m = button == 2 ? WM_RBUTTONUP : button == 1 ? WM_MBUTTONUP : WM_LBUTTONUP;
PostMessageA(hwnd, m, 0, lp);
PostMessageA(target, m, 0, lp);
} else if (type == "wheel") {
PostMessageA(hwnd, WM_MOUSEWHEEL, MAKEWPARAM(0, (short)(-(int64_t)deltaY * 120)), lp);
// WM_MOUSEWHEEL lParam carries SCREEN coords of the cursor.
POINT pt = { x, y };
ClientToScreen(target, &pt);
PostMessageA(target, WM_MOUSEWHEEL, MAKEWPARAM(0, (short)(-(int64_t)deltaY * 120)), MAKELPARAM(pt.x, pt.y));
} else if (type == "keydown") {
SetFocus(hwnd);
PostMessageA(hwnd, WM_KEYDOWN, keyCode, 0);
SetFocus(target);
PostMessageA(target, WM_KEYDOWN, keyCode, 0);
} else if (type == "keyup") {
PostMessageA(hwnd, WM_KEYUP, keyCode, 0);
PostMessageA(target, WM_KEYUP, keyCode, 0);
}
}
@@ -308,6 +428,11 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
} else if (req.path == "/ping") {
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/frame") {
// Real-input wheel parks the window on-screen; restore when the
// 600ms beat expires (checked here since /frame polls ~100ms).
if (g_dance && g_parkUntil &&
(int32_t)(GetTickCount() - g_parkUntil) >= 0)
danceEnd(hwnd);
std::vector<unsigned char> jpg;
if (captureHwndToJpeg(hwnd, jpg, 70))
sendResponse(c, 200, "image/jpeg", jpg.data(), jpg.size());