V45.46: GUI embed VST full-panel trong DAW - phuc hoi embed (frame-capture ~10fps, relay input), bo toolbar header 'X Dong GUI' trong VstGuiEmbed + bo bar FX RT telemetry trong Mastering (phan tach GUI vs chain FX slot = border chain strip); dong GUI = chain card / module builtin / chon VST khac / onDead; projects API /disk list+open + api.js wrappers; plugins.py them SF_FXGUI_FLOAT=1 khi spawn bridge GUI; FxGuiServer/RenderFxJob fix GUI embed frame capture + show/hide re-anchor theo panel rect. Rebuild daw_vst_bridge+fx_vst_bridge+plugin_host v=202609032200.
This commit is contained in:
@@ -742,7 +742,8 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True,
|
||||
close_fds=os.name != "nt",
|
||||
env=dict(os.environ, SF_PARENT_PID=str(os.getpid())),
|
||||
env=dict(os.environ, SF_PARENT_PID=str(os.getpid()),
|
||||
SF_FXGUI_FLOAT="1"),
|
||||
creationflags=_gui_creationflags)
|
||||
# Drain stdout NGAY TU DAU (daemon thread) và log TẤT CẢ các dòng
|
||||
# bridge in — gồm cả các dòng in TRƯỚC SF_FXGUI_PORT= ("[FxGui]
|
||||
|
||||
@@ -295,6 +295,54 @@ async def save_project_to_disk(req: SaveProjectRequest, authorization: Optional[
|
||||
}
|
||||
|
||||
|
||||
@router.get("/disk")
|
||||
async def list_disk_projects(authorization: Optional[str] = Header(None)):
|
||||
"""Danh sách file Ctrl-S desktop (Documents/SonicForgeDAW/Projects),
|
||||
mới nhất trước. Auth optional."""
|
||||
try:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
decode_token(authorization.split(" ")[1])
|
||||
except Exception:
|
||||
pass
|
||||
out_dir = _os_projects_dir()
|
||||
items = []
|
||||
try:
|
||||
names = os.listdir(out_dir)
|
||||
except Exception:
|
||||
names = []
|
||||
for fn in names:
|
||||
if not fn.endswith(".sonicforge.json"):
|
||||
continue
|
||||
pth = os.path.join(out_dir, fn)
|
||||
try:
|
||||
mtime = os.path.getmtime(pth)
|
||||
size = os.path.getsize(pth)
|
||||
except Exception:
|
||||
continue
|
||||
items.append({"filename": fn, "name": fn[: -len(".sonicforge.json")], "mtime": mtime, "size_bytes": size})
|
||||
items.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/disk/{filename}")
|
||||
async def get_disk_project(filename: str, authorization: Optional[str] = Header(None)):
|
||||
"""Đọc nội dung 1 file dự án trên đĩa (mở lại sau khi restart)."""
|
||||
try:
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
decode_token(authorization.split(" ")[1])
|
||||
except Exception:
|
||||
pass
|
||||
base = os.path.basename(filename)
|
||||
if not base.endswith(".sonicforge.json"):
|
||||
raise HTTPException(status_code=400, detail="Invalid project file")
|
||||
pth = os.path.join(_os_projects_dir(), base)
|
||||
if not os.path.isfile(pth):
|
||||
raise HTTPException(status_code=404, detail="Project not found on disk")
|
||||
with open(pth, "r", encoding="utf-8") as f:
|
||||
data_json = f.read()
|
||||
return {"filename": base, "name": base[: -len(".sonicforge.json")], "data_json": data_json}
|
||||
|
||||
|
||||
@router.post("/cloud")
|
||||
async def save_cloud_project(req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||
validated_data_json = validate_project_data(req.data_json)
|
||||
|
||||
+270
-98
@@ -8293,7 +8293,7 @@ const SaveProjectModal = ({
|
||||
])));
|
||||
};
|
||||
|
||||
const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
|
||||
const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal, onOpenDisk }) => {
|
||||
if (!isOpen) return null;
|
||||
const [tab, setTab] = useState('cloud');
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -8306,6 +8306,12 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
|
||||
if (api && api.listCloudProjects) {
|
||||
api.listCloudProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); });
|
||||
} else { setLoading(false); }
|
||||
} else if (tab === 'disk') {
|
||||
setLoading(true);
|
||||
var api2 = window.SonicAPI;
|
||||
if (api2 && api2.listDiskProjects) {
|
||||
api2.listDiskProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); });
|
||||
} else { setLoading(false); }
|
||||
}
|
||||
}, [isOpen, tab]);
|
||||
return React.createElement("div", {
|
||||
@@ -8315,12 +8321,16 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
|
||||
}, React.createElement("h3", {
|
||||
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
|
||||
}, "Mở dự án"), React.createElement("div", {
|
||||
className: "grid grid-cols-2 gap-2 text-xs mb-4"
|
||||
className: "grid grid-cols-3 gap-2 text-xs mb-4"
|
||||
}, [
|
||||
React.createElement("button", {
|
||||
key: "cloud-tab", type: "button", onClick: function() { setTab('cloud'); },
|
||||
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
|
||||
}, [React.createElement("span", { key: "t" }, "☁️ Cloud"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Dự án trên server")]),
|
||||
React.createElement("button", {
|
||||
key: "disk-tab", type: "button", onClick: function() { setTab('disk'); },
|
||||
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'disk' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
|
||||
}, [React.createElement("span", { key: "t" }, "📁 Trên máy"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Đã lưu Ctrl-S / Lưu Local")]),
|
||||
React.createElement("button", {
|
||||
key: "local-tab", type: "button", onClick: function() { setTab('local'); },
|
||||
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
|
||||
@@ -8347,6 +8357,27 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
|
||||
]);
|
||||
})
|
||||
)
|
||||
: tab === 'disk' ?
|
||||
React.createElement("div", { className: "space-y-1.5 max-h-64 overflow-y-auto" },
|
||||
loading ? [React.createElement("div", { key: "l", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án trên máy...")]
|
||||
: projects.length === 0 ? [React.createElement("div", { key: "e", className: "text-center py-8 text-xs text-zinc-500" }, "Chưa có dự án nào trong Documents/SonicForgeDAW/Projects. Nhấn Ctrl+S (Save) để lưu vào đây.")]
|
||||
: projects.map(function(p) {
|
||||
return React.createElement("div", {
|
||||
key: p.filename,
|
||||
onClick: function() { onOpenDisk(p.filename, p.name); },
|
||||
className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"
|
||||
}, [
|
||||
React.createElement("div", { key: "meta" }, [
|
||||
React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, p.name),
|
||||
React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, "Lưu lúc: " + new Date((p.mtime || 0) * 1000).toLocaleString())
|
||||
]),
|
||||
React.createElement("button", {
|
||||
onClick: function(e) { e.stopPropagation(); onOpenDisk(p.filename, p.name); },
|
||||
className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"
|
||||
}, "MỞ")
|
||||
]);
|
||||
})
|
||||
)
|
||||
: React.createElement("div", { className: "py-4 text-center text-xs text-zinc-400 space-y-3" },
|
||||
[React.createElement("div", { key: "d", className: "text-zinc-500" }, "Chọn tệp .sfs để mở dự án từ Local."),
|
||||
React.createElement("button", {
|
||||
@@ -8360,7 +8391,6 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
|
||||
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
|
||||
}, "Hủy"))));
|
||||
};
|
||||
|
||||
const SaveAsModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -12884,14 +12914,33 @@ const InteractiveEqPro = ({ track, params, onChange, getModule, applyTo, spectru
|
||||
raf = requestAnimationFrame(draw);
|
||||
const cv = canvasRef.current;
|
||||
if (!cv || !cv.clientWidth) return;
|
||||
const s = st.current;
|
||||
const specMods = (spectrumModules ? spectrumModules() : null) || (getModule ? [getModule()] : []);
|
||||
// V45.46 throttle: đang kéo band hoặc có tín hiệu live → full 60fps; idle
|
||||
// (không kéo, analyser im) → chỉ vẽ ~mỗi 12 frame — curve tĩnh không cần
|
||||
// redraw 60fps (canvas EQ trong FXRack/Mastering modal tốn GPU khi mở).
|
||||
if (s.dragId === null) {
|
||||
let live = false;
|
||||
for (const _m of specMods) {
|
||||
const _an = _m && _m.analyser;
|
||||
if (!_an) continue;
|
||||
try {
|
||||
if (!s._freq || s._freq.length !== _an.frequencyBinCount) s._freq = new Uint8Array(_an.frequencyBinCount);
|
||||
_an.getByteFrequencyData(s._freq);
|
||||
for (let _i = 0; _i < s._freq.length; _i++) { if (s._freq[_i] > 4) { live = true; break; } }
|
||||
} catch (e) {}
|
||||
if (live) break;
|
||||
}
|
||||
s._frame = (s._frame || 0) + 1;
|
||||
if (!live && s._frame % 12 !== 1) return;
|
||||
}
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w * 2) { cv.width = w * 2; cv.height = h * 2; }
|
||||
const c = cv.getContext('2d');
|
||||
c.setTransform(2, 0, 0, 2, 0, 0);
|
||||
// Spectrum sources: every live module analyser (audioclip path + soundfont
|
||||
// path) is overlaid, so playing audio clips OR midi both show up.
|
||||
const specMods = (spectrumModules ? spectrumModules() : null) || (getModule ? [getModule()] : []);
|
||||
renderEqProFrame(c, w, h, st.current, specMods, (typeof getAudioContext === 'function' && getAudioContext()) ? getAudioContext().sampleRate : 44100);
|
||||
renderEqProFrame(c, w, h, s, specMods, (typeof getAudioContext === 'function' && getAudioContext()) ? getAudioContext().sampleRate : 44100);
|
||||
};
|
||||
draw();
|
||||
return () => cancelAnimationFrame(raf);
|
||||
@@ -13368,74 +13417,63 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Embedded VST GUI (PLAN_MASTERBUS_VST_GUI_EMBED.md) --------------------
|
||||
// Frame-capture + input relay: hiển thị native GUI qua HTTP /frame (~10fps),
|
||||
// gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal.
|
||||
// url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng;
|
||||
// onDead khi bridge chết (ping fail) → parent trả graph + toast.
|
||||
// --- Embedded VST GUI (frame-capture embed) ------------------------------------
|
||||
// Native plugin window duoc park de len dung rect cua panel nay (POST /show
|
||||
// kem screen rect, refresh ~250ms de re-anchor khi scroll/resize); pixel cua
|
||||
// no duoc hien trong panel qua JPEG frame capture (GET /frame, ~10fps), su
|
||||
// kien chuot/ban phim relay bang POST /input — khong con cua so noi, khong
|
||||
// can OS focus. Dong panel chi POST /hide (bridge + plugin con song, mo lai
|
||||
// nhanh); dong that su (POST /close) tu closeFxGui/closeMasterGui khi chon
|
||||
// module khac, hoac tu dong khi onDead (bridge chet). Khong con toolbar.
|
||||
const VstGuiEmbed = ({ url, onClose, onDead }) => {
|
||||
const imgRef = React.useRef(null);
|
||||
const preloadRef = React.useRef(null);
|
||||
const lastShowRef = React.useRef(null);
|
||||
const boxRef = React.useRef(null);
|
||||
const deadRef = React.useRef(false);
|
||||
const screenRectRef = React.useRef(null);
|
||||
const [, setDead] = React.useState(false);
|
||||
const [frameLoaded, setFrameLoaded] = React.useState(false);
|
||||
const frameLoadedRef = React.useRef(false);
|
||||
const post = (payload) => { try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {} };
|
||||
|
||||
const post = payload => {
|
||||
try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {}
|
||||
};
|
||||
|
||||
// Poll frame capture ~100ms + ping 2s de phat hien bridge chet.
|
||||
React.useEffect(() => {
|
||||
deadRef.current = false;
|
||||
setDead(false);
|
||||
setFrameLoaded(false);
|
||||
frameLoadedRef.current = false;
|
||||
const tick = setInterval(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img || deadRef.current) return;
|
||||
const pr = preloadRef.current;
|
||||
if (pr && !pr.complete) return; // frame đang tải dở → chờ, không spam /frame
|
||||
const np = pr || new Image();
|
||||
np.onload = () => { if (preloadRef.current === np) { preloadRef.current = null; img.src = np.src; } };
|
||||
np.onerror = () => { if (preloadRef.current === np) preloadRef.current = null; }; // frame lỗi → giữ frame cũ, không flash/trắng
|
||||
np.src = url + '/frame?t=' + Date.now();
|
||||
preloadRef.current = np;
|
||||
if (img && img.complete) img.src = url + '/frame?t=' + Date.now();
|
||||
}, 100);
|
||||
const ping = setInterval(() => {
|
||||
fetch(url + '/ping').then(r => { if (!r.ok) throw new Error('bad'); }).catch(() => {
|
||||
if (!deadRef.current) { deadRef.current = true; setDead(true); onDead && onDead(); }
|
||||
if (!deadRef.current) { deadRef.current = true; onDead && onDead(); }
|
||||
});
|
||||
}, 2000);
|
||||
// Show immediately on mount even before the rect is known: bridge
|
||||
// re-shows the native window at its last position, and the ~500ms
|
||||
// rect refresh below re-anchors it over the panel.
|
||||
// Show ngay khi mount, truoc khi co rect: bridge hien lai native window
|
||||
// o vi tri cu; ~250ms sau rect refresh ben duoi se re-anchor de len panel.
|
||||
try { fetch(url + '/show', { method: 'POST' }).catch(() => {}); } catch (e) {}
|
||||
// Watchdog: plugin load is allowed up to 90s; if no frame arrived and
|
||||
// the bridge did not die (ping alive = process stuck in plugin init/attach),
|
||||
// give up so the panel does not flash "Đang tải plugin VST..." forever.
|
||||
const watchdog = setTimeout(() => {
|
||||
if (!deadRef.current && !frameLoadedRef.current) {
|
||||
deadRef.current = true; setDead(true); onDead && onDead();
|
||||
}
|
||||
}, 90000);
|
||||
return () => {
|
||||
clearInterval(tick); clearInterval(ping); clearTimeout(watchdog); preloadRef.current = null;
|
||||
// Panel closed -> hide the native window (bridge stays alive so
|
||||
// reopening is fast); POST /hide chứ không /close.
|
||||
clearInterval(tick);
|
||||
clearInterval(ping);
|
||||
// Panel dong -> hide native window (bridge con song, mo lai nhanh).
|
||||
// Dong that su (POST /close + teardown engine) tu closeFxGui/closeMasterGui
|
||||
// khi chon module/chain khac, hoac tu dong khi onDead (bridge chet).
|
||||
try { fetch(url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
// Embed panel screen rect (physical px) for the bridge's anchored real-input
|
||||
// dance: Tauri innerPosition + scaleFactor; window.screenX/Y fallback when
|
||||
// not running under Tauri. Refreshed ~500ms so scroll/resize stays current.
|
||||
|
||||
// Screen rect (physical px) cua panel cho bridge re-anchor + real-input:
|
||||
// Tauri innerPosition + scaleFactor; fallback window.screenX/Y khi khong
|
||||
// chay trong Tauri. Refresh ~250ms de theo scroll/resize.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const maybeShow = () => {
|
||||
const r = screenRectRef.current;
|
||||
if (r && r.w >= 4 && r.h >= 4) {
|
||||
const key = Math.round(r.x) + ',' + Math.round(r.y) + ',' + Math.round(r.w) + ',' + Math.round(r.h);
|
||||
if (lastShowRef.current === key) return; // rect không đổi → không spam /show
|
||||
lastShowRef.current = key;
|
||||
// Re-POST /show moi lan refresh de native window re-anchor theo panel
|
||||
// khi scroll/drag/resize. Idempotent: bridge skip SetWindowPos khi
|
||||
// khong co gi doi.
|
||||
try { fetch(url + '/show', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rect: r }) }).catch(() => {}); } catch (e) {}
|
||||
}
|
||||
};
|
||||
@@ -13469,18 +13507,17 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
|
||||
return () => { alive = false; clearInterval(iv); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
const pt = (e) => {
|
||||
|
||||
const pt = e => {
|
||||
const box = boxRef.current, img = imgRef.current;
|
||||
if (!box || !img || !img.naturalWidth || !img.naturalHeight) return null;
|
||||
const r = box.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return null;
|
||||
return {
|
||||
x: Math.round((e.clientX - r.left) / r.width * img.naturalWidth),
|
||||
y: Math.round((e.clientY - r.top) / r.height * img.naturalHeight)
|
||||
};
|
||||
return { x: Math.round((e.clientX - r.left) / r.width * img.naturalWidth), y: Math.round((e.clientY - r.top) / r.height * img.naturalHeight) };
|
||||
};
|
||||
const handle = (e) => {
|
||||
const p = pt(e); if (!p) return;
|
||||
const handle = e => {
|
||||
const p = pt(e);
|
||||
if (!p) return;
|
||||
post({ type: e.type === 'mousedown' ? 'mousedown' : e.type === 'mouseup' ? 'mouseup' : 'mousemove', x: p.x, y: p.y, button: e.button, buttons: e.buttons, rect: screenRectRef.current || undefined });
|
||||
};
|
||||
return (
|
||||
@@ -13496,12 +13533,14 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
|
||||
<span className="text-[11px] text-slate-500 font-mono animate-pulse">Đang tải plugin VST — GUI embed sẽ hiện khi plugin sẵn sàng...</span>
|
||||
</div>
|
||||
)}
|
||||
<img ref={imgRef} alt="VST GUI" className="w-full h-full object-contain select-none pointer-events-none" draggable={false} onLoad={() => { setFrameLoaded(true); frameLoadedRef.current = true; }} />
|
||||
<img ref={imgRef} alt="VST GUI" className="w-full h-full object-contain select-none pointer-events-none" draggable={false} onLoad={() => setFrameLoaded(true)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen, openGui, activeIdx = -1, onSelect = null) => (
|
||||
<div key={idx} onClick={() => onSelect && onSelect(s, idx)} className={`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx === idx ? 'border-teal-400 bg-slate-800/80' : 'border-slate-800 hover:border-slate-600'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); toggleV(idx); }} className={`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass ? 'bg-teal-500 text-slate-950' : 'bg-slate-700 text-slate-300'}`} title={s.bypass ? 'Bypass' : 'Active'}>
|
||||
@@ -13767,6 +13806,29 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
raf = requestAnimationFrame(draw);
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ana = window.__getTrackScopeAnalysers ? window.__getTrackScopeAnalysers(track.id) : null;
|
||||
// V45.46 throttle: inactive (chưa play / paused) → vẽ grid+message ~mỗi 12
|
||||
// frame — rAF vẫn chạy làm poll (play start không re-run effect vì deps
|
||||
// thiếu isPlaying) nhưng skip các frame khác → hết repaint canvas 60fps.
|
||||
if (!ana || scopePaused) {
|
||||
st.frame = (st.frame || 0) + 1;
|
||||
if (st.frame % 12 !== 1) return;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// Grid
|
||||
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillStyle = '#475569';
|
||||
for (let i = 1; i < 6; i++) {
|
||||
const y = (i / 6) * h;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
ctx.fillText('+0.0 dB', 4, 12);
|
||||
ctx.fillText('-6.0 dB', 4, h / 2 - 4);
|
||||
ctx.fillText('-12 dB', 4, h - 6);
|
||||
if (!ana) { ctx.fillStyle = '#334155'; ctx.font = '11px monospace'; ctx.fillText('Không có tín hiệu — bấm Play để xem waveform', w / 2 - 120, h / 2); }
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// Grid
|
||||
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
|
||||
@@ -13780,11 +13842,6 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
ctx.fillText('+0.0 dB', 4, 12);
|
||||
ctx.fillText('-6.0 dB', 4, h / 2 - 4);
|
||||
ctx.fillText('-12 dB', 4, h - 6);
|
||||
const ana = window.__getTrackScopeAnalysers ? window.__getTrackScopeAnalysers(track.id) : null;
|
||||
if (!ana || scopePaused) {
|
||||
if (!ana) { ctx.fillStyle = '#334155'; ctx.font = '11px monospace'; ctx.fillText('Không có tín hiệu — bấm Play để xem waveform', w / 2 - 120, h / 2); }
|
||||
return;
|
||||
}
|
||||
const sr = ana.sr || 44100;
|
||||
const maxS = Math.floor(5 * sr);
|
||||
if (!st.L || st.L.length !== maxS) { st.L = new Float32Array(maxS); st.R = new Float32Array(maxS); st.head = 0; st.len = 0; }
|
||||
@@ -13935,7 +13992,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
}, [ap.g1, ap.g2, ap.g3, ap.g4, activeMod]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[115] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[115] bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200" onClick={e => e.stopPropagation()}>
|
||||
{/* HEADER */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
@@ -14259,22 +14316,72 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
return () => clearInterval(iv);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [masterGuiOpen]);
|
||||
// Modal đóng (modal luôn mounted, KHÔNG unmount): ẩn GUI + xoá persist để
|
||||
// lần sau mở panel KHÔNG tự mở lại GUI (fix auto-open khi vào master chain).
|
||||
// Chỉ POST /hide — bridge sống, click mở lại nhanh.
|
||||
// Modal đóng (modal luôn mounted, KHÔNG unmount): ẩn GUI nhưng GIỮ marker
|
||||
// gui_open_path/name + preset cuối → mở lại panel tự mở lại GUI đúng cài đặt.
|
||||
// Chỉ POST /hide — bridge còn sống, mở lại nhanh. “Đóng GUI” (closeMasterGui)
|
||||
// là thao tác chủ động xoá marker → không auto-mở lại lần sau.
|
||||
React.useEffect(() => {
|
||||
if (isOpen) return;
|
||||
const g = masterGuiOpenRef.current;
|
||||
if (!g) return;
|
||||
try { if (g.path) { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}); } } catch (e) {}
|
||||
try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {}
|
||||
const _mk = 'master:' + g.path;
|
||||
if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; }
|
||||
if (g.path) delete _fxGuiBaselineByPath[g.path];
|
||||
setMasterGuiOpen(null);
|
||||
setActiveMasterVstPath(null);
|
||||
setOzState(prev => { const n = { ...(prev || {}) }; delete n.gui_open_path; delete n.gui_open_name; return n; });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
// P2.1 — Mở lại panel khi marker gui_open còn (chưa bấm “Đóng GUI”): tự mở lại
|
||||
// GUI embed đúng plugin + preset cuối. Fail/spawn lỗi → GIỮ marker + toast lỗi
|
||||
// thật + retry tối đa 4 lần — trước đây catch xoá marker vĩnh viễn + im lặng
|
||||
// → GUI không bao giờ tự mở lại sau tắt/mở app (bug P2.1 reload). Chạy lại khi
|
||||
// marker/chain xuất hiện sau (project restore chưa xong lúc mở panel).
|
||||
const _autoIsOpenRef = React.useRef(isOpen);
|
||||
_autoIsOpenRef.current = isOpen;
|
||||
const _autoOzRef = React.useRef(ozState);
|
||||
_autoOzRef.current = ozState;
|
||||
const _autoOpenRef = React.useRef({ timer: null, attempt: 0, inFlight: false });
|
||||
const _autoTryOpen = React.useCallback(() => {
|
||||
const st = _autoOzRef.current || {};
|
||||
const _p = st.gui_open_path;
|
||||
if (!_p) return;
|
||||
if (!_autoIsOpenRef.current || masterGuiOpenRef.current || _autoOpenRef.current.inFlight) return;
|
||||
const s = (st.vstFxChain || []).find(x => x && x.path === _p);
|
||||
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
|
||||
_autoOpenRef.current.inFlight = true;
|
||||
setActiveMasterVstPath(_p);
|
||||
const _name = st.gui_open_name || s.name || _p;
|
||||
window.SonicAPI.openFxGui({ path: _p, name: _name, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[_p] || s.preset_b64 || '' }).then(r => {
|
||||
_autoOpenRef.current.inFlight = false;
|
||||
if (r && r.embed_url && !masterGuiOpenRef.current) { _autoOpenRef.current.attempt = 0; setMasterGuiOpen({ path: _p, url: r.embed_url, name: _name }); return; }
|
||||
if (r && r.already_running && r.embed_url && !masterGuiOpenRef.current) { _autoOpenRef.current.attempt = 0; setMasterGuiOpen({ path: _p, url: r.embed_url, name: _name }); return; }
|
||||
if (r && r.already_running) { _autoOpenRef.current.attempt = 0; setActiveMasterVstPath(null); window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); return; }
|
||||
throw new Error((r && r.detail) || 'Bridge không mở được GUI embed');
|
||||
}).catch(err => {
|
||||
_autoOpenRef.current.inFlight = false;
|
||||
console.warn('[MasterAutoOpen] openFxGui fail:', _p, (err && err.message) || err);
|
||||
setActiveMasterVstPath(null);
|
||||
if (_autoIsOpenRef.current && _autoOpenRef.current.attempt < 4 && !masterGuiOpenRef.current) {
|
||||
_autoOpenRef.current.attempt += 1;
|
||||
if (_autoOpenRef.current.timer) clearTimeout(_autoOpenRef.current.timer);
|
||||
_autoOpenRef.current.timer = setTimeout(() => { _autoOpenRef.current.timer = null; _autoTryOpen(); }, 3000);
|
||||
} else {
|
||||
_autoOpenRef.current.attempt = 0;
|
||||
window.showToast && window.showToast('Không tự mở được GUI VST: ' + ((err && err.message) || 'lỗi') + ' — bấm tên plugin trong chuỗi để mở tay.', 'error');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
return () => { if (_autoOpenRef.current.timer) { clearTimeout(_autoOpenRef.current.timer); _autoOpenRef.current.timer = null; } };
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) { _autoOpenRef.current.attempt = 0; return; }
|
||||
if (_autoOpenRef.current.timer) { clearTimeout(_autoOpenRef.current.timer); _autoOpenRef.current.timer = null; }
|
||||
_autoTryOpen();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, ozState.gui_open_path, ozState.gui_open_name, ozState.vstFxChain]);
|
||||
const setMasterVstChain = (next) => setOzState(prev => {
|
||||
const chain = prev.chain || [];
|
||||
const out = [];
|
||||
@@ -14351,13 +14458,6 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
const previewAudioRef = React.useRef(null);
|
||||
const [addMasterVstSel, setAddMasterVstSel] = React.useState('');
|
||||
const [masterVstPickerOpen, setMasterVstPickerOpen] = React.useState(false);
|
||||
// FX RT telemetry (WebView2 không mở được devtools → hiển thị thẳng trên UI).
|
||||
const [fxRtStats, setFxRtStats] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const iv = setInterval(() => { if (fxRt && fxRt.stats) setFxRtStats({ ...fxRt.stats }); }, 2000);
|
||||
return () => clearInterval(iv);
|
||||
}, [isOpen]);
|
||||
|
||||
// Wave Observer Refs & States
|
||||
const woCanvasRef = React.useRef(null);
|
||||
@@ -14878,7 +14978,8 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
};
|
||||
const removeChainModule = (modId) => {
|
||||
const removedMod = (ozState.chain || []).find(m => m.id === modId);
|
||||
if (removedMod && removedMod.type === 'vst3' && masterGuiOpen && masterGuiOpen.path === removedMod.path) closeMasterGui();
|
||||
const wasGuiOpenFor = removedMod && removedMod.type === 'vst3' && masterGuiOpen && masterGuiOpen.path === removedMod.path;
|
||||
if (wasGuiOpenFor) closeMasterGui();
|
||||
setOzState(prev => {
|
||||
const removed = prev.chain.find(m => m.id === modId);
|
||||
const chain = prev.chain.filter(m => m.id !== modId);
|
||||
@@ -14890,7 +14991,10 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
const vi = vstFxChain.findIndex(s => s.path === removed.path);
|
||||
if (vi >= 0) vstFxChain = [...vstFxChain.slice(0, vi), ...vstFxChain.slice(vi + 1)];
|
||||
}
|
||||
return { ...prev, chain, ...flags, vstFxChain, activeModule: chain.length ? (chain.some(m => m.type === activeModule) ? activeModule : chain[chain.length - 1].type) : null };
|
||||
const n = { ...prev, chain, ...flags, vstFxChain, activeModule: chain.length ? (chain.some(m => m.type === activeModule) ? activeModule : chain[chain.length - 1].type) : null };
|
||||
// P2.1 — marker mồ côi khi xoá VST3 mà GUI không mở (panel đang đóng/ẩn)
|
||||
if (removed && removed.type === 'vst3' && !wasGuiOpenFor && prev.gui_open_path === removed.path) { delete n.gui_open_path; delete n.gui_open_name; }
|
||||
return n;
|
||||
});
|
||||
};
|
||||
const reorderChain = (fromIdx, toIdx) => {
|
||||
@@ -15001,7 +15105,7 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70" onClick={onClose}>
|
||||
<div id="masteringModalBody" className="flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()} style={{fontFamily: "'Inter', sans-serif"}}>
|
||||
|
||||
{/* TOP TRANSPORT & SESSION BAR */}
|
||||
@@ -15092,17 +15196,6 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* FX RT TELEMETRY — WebView2 không mở devtools, hiển thị thẳng trên UI */}
|
||||
<div id="fxrt-stats-ui" className="h-6 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-3 text-[10px] oz-font-mono shrink-0">
|
||||
<span className="text-slate-500 font-bold uppercase tracking-widest">FXRT</span>
|
||||
{fxRtStats ? (
|
||||
<span className={fxRtStats.underruns > 0 || fxRtStats.silences > 0 ? 'text-red-400' : 'text-emerald-400'}>
|
||||
u={fxRtStats.underruns} h={fxRtStats.holds} f={fxRtStats.fades} s={fxRtStats.silences} q={fxRtStats.queueLen} cap={fxRtStats.capDrops} fadeIn={fxRtStats.fadeIns}
|
||||
</span>
|
||||
) : <span className="text-slate-600">off</span>}
|
||||
{fxRtStats && (fxRtStats.underruns > 0 || fxRtStats.silences > 0) && <span className="text-red-400">⚠ main-thread stall? Giảm tải UI / tăng FILL</span>}
|
||||
</div>
|
||||
|
||||
{/* MAIN WORKSPACE */}
|
||||
<main className="flex-1 flex overflow-hidden min-h-0">
|
||||
|
||||
@@ -20187,7 +20280,27 @@ const App = () => {
|
||||
// Đồng bộ realtime VST master với PWR/Bypass: WebAudio graph đã đổi theo
|
||||
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
|
||||
try {
|
||||
if (fxRtChain().length) { fxRtStart(); }
|
||||
if (fxRtChain().length) {
|
||||
fxRtStart();
|
||||
// V45.46: start từ settings-change lúc KHÔNG có playback (mount mở
|
||||
// project có master chain / chỉnh PWR khi đang dừng) → engine từng chạy
|
||||
// VÔ HẠN khi idle (fx_stats spam play=0 → CPU cao). Arm warm-idle
|
||||
// teardown như stopAllPlayback(fade): play path (fxRtStart trong
|
||||
// startTrackPlayback) và section play (startSubTabPlayback) clear timer
|
||||
// ngay → chỉ teardown khi thực sự idle lâu. MIDI-live/rec giữ warm.
|
||||
const _recNow = (activeAudioRecordersRef && activeAudioRecordersRef.current && Object.keys(activeAudioRecordersRef.current).length > 0) || (recordingStateRef && recordingStateRef.current === 'RECORDING');
|
||||
let _liveMidi = false;
|
||||
try { for (const _k in midiVuActivityRef.current) { if (midiVuActivityRef.current[_k] > 0.001) { _liveMidi = true; break; } } } catch (e) {}
|
||||
const _demand = (isPlayingRef && isPlayingRef.current) || _recNow || _liveMidi ||
|
||||
(subTabsRef && subTabsRef.current && subTabsRef.current.some(s => s && s.isPlaying));
|
||||
if (!_demand) {
|
||||
clearTimeout(fxRtWarmIdleTimer);
|
||||
fxRtWarmIdleTimer = setTimeout(() => {
|
||||
fxRtWarmIdleTimer = null;
|
||||
try { fxRtStop(); } catch (e) { console.warn('[FX RT] idle stop error:', e); }
|
||||
}, FX_RT_WARM_IDLE_MS);
|
||||
}
|
||||
}
|
||||
else if (fxRt.active || fxRt.starting) { fxRtStop(); }
|
||||
} catch (e) { console.warn('[FX RT] resync fail:', e); }
|
||||
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
|
||||
@@ -20408,11 +20521,23 @@ const App = () => {
|
||||
if (lastId.startsWith('local_')) {
|
||||
var localData = localStorage.getItem('sonic_local_project_data');
|
||||
if (localData) parsed = JSON.parse(localData);
|
||||
} else if (lastId.startsWith('disk:')) {
|
||||
// Ctrl-S desktop: mở lại file .sonicforge.json trong thư mục tài liệu
|
||||
var diskFname = lastId.slice(5);
|
||||
var diskProj = await window.SonicAPI.getDiskProject(diskFname);
|
||||
if (diskProj && diskProj.data_json) parsed = JSON.parse(diskProj.data_json);
|
||||
} else {
|
||||
var proj = await window.SonicAPI.getCloudProject(lastId);
|
||||
if (proj) parsed = JSON.parse(proj.data_json);
|
||||
}
|
||||
} catch (e) { console.warn('restore load error:', e); }
|
||||
} catch (e) {
|
||||
console.warn('restore load error:', e);
|
||||
if (lastId && lastId.startsWith('disk:')) {
|
||||
// File trên đĩa không còn (bị xoá) → bỏ id cũ, không lặp lại mỗi lần mở
|
||||
localStorage.removeItem('sonic_project_id');
|
||||
localStorage.removeItem('sonic_project_name');
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!parsed) return false;
|
||||
@@ -29705,8 +29830,25 @@ const App = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLocalProject = (name) => {
|
||||
const handleSaveLocalProject = async (name) => {
|
||||
const finalName = name || projectName || 'Dự án mới';
|
||||
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
|
||||
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
|
||||
// Desktop: "Lưu Local" = lưu vào thư mục mặc định của máy
|
||||
// (Documents/SonicForgeDAW/Projects) — sống sót khi cài lại. Id
|
||||
// disk:<file> giúp lần khởi động sau tự mở lại dự án này.
|
||||
const schemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
|
||||
const res = await window.SonicAPI.saveProjectToDisk(finalName, JSON.stringify(schemaObj));
|
||||
const diskId = res && res.filename ? ('disk:' + res.filename) : null;
|
||||
if (diskId) {
|
||||
setCurrentProjectId(diskId);
|
||||
localStorage.setItem('sonic_project_id', diskId);
|
||||
}
|
||||
setProjectName(finalName);
|
||||
localStorage.setItem('sonic_project_name', finalName);
|
||||
showToast(`Đã lưu dự án "${finalName}" vào thư mục tài liệu.`, "success");
|
||||
return;
|
||||
}
|
||||
const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now();
|
||||
const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
|
||||
const dataStr = JSON.stringify(projectSchemaObj);
|
||||
@@ -29778,7 +29920,7 @@ const App = () => {
|
||||
setSaveProjectModalOpen(true);
|
||||
return;
|
||||
}
|
||||
if (currentProjectId.startsWith('local_')) {
|
||||
if (currentProjectId && (currentProjectId.startsWith('local_') || currentProjectId.startsWith('disk:'))) {
|
||||
handleSaveLocalProject(projectName);
|
||||
} else {
|
||||
if (!currentUser) { showToast('Cần đăng nhập để lưu Cloud. currentUser=' + (currentUser ? 'OK' : 'NULL') + ' token=' + (localStorage.getItem('sonic_token') ? 'exists' : 'missing'), 'warning'); return; }
|
||||
@@ -29798,8 +29940,16 @@ const App = () => {
|
||||
const dataJson = JSON.stringify(schemaObj);
|
||||
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
|
||||
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
|
||||
// Desktop → thư mục của hệ điều hành
|
||||
// Desktop → thư mục của hệ điều hành (Documents/SonicForgeDAW/Projects)
|
||||
const res = await window.SonicAPI.saveProjectToDisk(finalName, dataJson);
|
||||
// Đăng ký id disk:<file> → lần khởi động sau tự mở lại dự án này
|
||||
const diskId = res && res.filename ? ('disk:' + res.filename) : null;
|
||||
if (diskId) {
|
||||
setCurrentProjectId(diskId);
|
||||
setProjectName(finalName);
|
||||
localStorage.setItem('sonic_project_id', diskId);
|
||||
localStorage.setItem('sonic_project_name', finalName);
|
||||
}
|
||||
showToast('Đã lưu dự án: ' + ((res && res.path) || finalName), 'success');
|
||||
} else if (window.SonicAPI) {
|
||||
// Docker / headless → Cloud (nếu đã đăng nhập), ngược lại local
|
||||
@@ -32330,6 +32480,7 @@ STRICT CONSTRAINTS:
|
||||
// Master & Track VU meter animation loop
|
||||
const masterVUAnimRef = useRef(null);
|
||||
useEffect(() => {
|
||||
let lastIdleDraw = 0;
|
||||
function tick() {
|
||||
try {
|
||||
// Khi STOP (không play, không recording): vẽ VU RỖNG — không đọc analyser/
|
||||
@@ -32346,14 +32497,22 @@ STRICT CONSTRAINTS:
|
||||
}
|
||||
} catch (e) { }
|
||||
if (!isPlaying && !isRecActive && !liveMidiVu) {
|
||||
setMasterVU(0);
|
||||
setMasterMeterPeak(0);
|
||||
Object.keys(trackVuRefs.current).forEach(k => {
|
||||
const c = trackVuRefs.current[k];
|
||||
if (!c) return;
|
||||
if (k.endsWith('_mixer')) drawMixerVuMeter(c, 0);
|
||||
else drawVuMeter(c, -60);
|
||||
});
|
||||
// V45.46 throttle: vẽ VU rỗng ~10fps (throttle 100ms) — trước đây 60fps
|
||||
// mỗi frame setState + vẽ canvas → GPU process CPU cao khi STOP. Vòng rAF
|
||||
// vẫn chạy (skip-frame đóng vai poll: record/liveMidi không re-run effect
|
||||
// vì deps chỉ [isPlaying]) nhưng không repaint các frame thừa.
|
||||
const _nowIdle = performance.now();
|
||||
if (_nowIdle - lastIdleDraw >= 100) {
|
||||
lastIdleDraw = _nowIdle;
|
||||
setMasterVU(0);
|
||||
setMasterMeterPeak(0);
|
||||
Object.keys(trackVuRefs.current).forEach(k => {
|
||||
const c = trackVuRefs.current[k];
|
||||
if (!c) return;
|
||||
if (k.endsWith('_mixer')) drawMixerVuMeter(c, 0);
|
||||
else drawVuMeter(c, -60);
|
||||
});
|
||||
}
|
||||
masterVUAnimRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
@@ -36012,6 +36171,19 @@ STRICT CONSTRAINTS:
|
||||
onOpenLocal: () => {
|
||||
setOpenProjectModalOpen(false);
|
||||
handleImportSFS();
|
||||
},
|
||||
onOpenDisk: (filename, name) => {
|
||||
setOpenProjectModalOpen(false);
|
||||
setAppWarningModal({
|
||||
title: "Mở dự án từ máy",
|
||||
message: 'Bạn có muốn mở dự án "' + (name || filename) + '" lưu trên máy? Các thay đổi chưa lưu sẽ bị mất.',
|
||||
isAlert: false,
|
||||
onConfirm: async () => {
|
||||
localStorage.setItem('sonic_project_id', 'disk:' + filename);
|
||||
localStorage.setItem('sonic_project_name', name || filename);
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
||||
isOpen: aiConfigModalOpen,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -130,6 +130,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
||||
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
|
||||
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
|
||||
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||
listDiskProjects: () => apiRequest('/api/v1/projects/disk', { method: 'GET' }),
|
||||
getDiskProject: (filename) => apiRequest('/api/v1/projects/disk/' + encodeURIComponent(filename), { method: 'GET' }),
|
||||
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
||||
uploadSoundFont: async (file) => {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<script src="/static/vendor/react-dom.production.min.js"></script>
|
||||
<script src="/static/js/services/fluidsynthLoader.js?v=202608290945201545"></script>
|
||||
<script src="/static/js/services/runtime.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/api.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/api.js?v=202609031108"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202608290945"></script>
|
||||
@@ -51,7 +51,7 @@
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202608290945"></script>
|
||||
<script src="/static/js/services/chordTheory.js?v=202608290945"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202609030646" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202609032200" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202608290945">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
// native_bridge/src/FxGuiServer.cpp
|
||||
// Embedded VST GUI server (Phase 0 of PLAN_MASTERBUS_VST_GUI_EMBED.md):
|
||||
// `daw_vst_bridge --fx-gui <job.json>` loads ONE VST3 effect plugin, attaches
|
||||
// its native editor to an off-screen host window, and serves the window as
|
||||
// JPEG frames over a tiny HTTP server so the web frontend can embed it in a
|
||||
// panel (frame-capture + input relay, VNC-style). Windows only.
|
||||
// VST GUI server (Phase 0 of PLAN_MASTERBUS_VST_GUI_EMBED.md; P2 float mode of
|
||||
// the gop-process plan): `daw_vst_bridge --fx-gui <job.json>` loads ONE VST3
|
||||
// effect plugin and attaches its native editor to a host window.
|
||||
// - Embedded mode (default): window off-screen; JPEG frames + input relay
|
||||
// over HTTP so the webview can show it in a panel (VNC-style).
|
||||
// - Float mode (SF_FXGUI_FLOAT=1): the window is a REAL on-screen window the
|
||||
// user moves/sizes freely; /frame and /input are not used. WM_CLOSE only
|
||||
// hides it (plugin instance stays loaded — reopen via /show is instant);
|
||||
// /close and /exit tear the process down for real.
|
||||
// Windows only.
|
||||
//
|
||||
// Routes:
|
||||
// GET /ping -> {"ok":true}
|
||||
// GET /frame -> image/jpeg (BitBlt, fallback PrintWindow)
|
||||
// GET /frame -> image/jpeg (BitBlt, fallback PrintWindow) — embed
|
||||
// POST /input -> {type:"mousedown|mousemove|mouseup|wheel|keydown|keyup",
|
||||
// x,y,button,deltaY,keyCode} -> PostMessage to HWND
|
||||
// x,y,button,deltaY,keyCode} — embed
|
||||
// POST /show -> show (+focus, float) / park at panel rect (embed)
|
||||
// POST /hide -> hide the window (bridge keeps running)
|
||||
// POST /close -> detach + exit 0
|
||||
// POST /exit -> alias of /close
|
||||
// GET /preset -> {"preset_b64":"..."} current plugin state
|
||||
//
|
||||
// The HTTP server runs on a worker thread; the calling (STA) thread runs the
|
||||
@@ -53,6 +61,13 @@ extern void fxGuiSetShm(const std::string& name);
|
||||
// Set by RenderFxJob once the VST editor attached to the host window; /frame
|
||||
// refuses (503) until then so the webview never shows the empty host window.
|
||||
extern std::atomic<bool> g_fxGuiEditorReady;
|
||||
// Set by float /show when the editor is still loading/attaching; consumed by
|
||||
// fxGuiLoadAttach after attach so a pre-ready /show is not lost (Bug 2).
|
||||
extern std::atomic<bool> g_fxGuiShowPending;
|
||||
// Float mode flag (RenderFxJob.cpp global, set from SF_FXGUI_FLOAT).
|
||||
extern bool g_fxGuiFloatMode;
|
||||
// Ask the pump thread to destroy the window (full exit in float mode).
|
||||
extern void fxGuiRequestExit(HWND hwnd);
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -403,6 +418,9 @@ void realClick(int sx, int sy, bool down, int button) {
|
||||
|
||||
// --- input relay -------------------------------------------------------------
|
||||
void relayInput(HWND hwnd, const json_object_s* o) {
|
||||
// Float mode: the user interacts with the real window directly — the
|
||||
// relay/dance machinery is embed-only.
|
||||
if (g_fxGuiFloatMode) return;
|
||||
// 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).
|
||||
@@ -502,6 +520,42 @@ void relayInput(HWND hwnd, const json_object_s* o) {
|
||||
}
|
||||
}
|
||||
|
||||
// One-time first-paint kick (shared by /show and /frame). iZotope-class
|
||||
// editors (Nectar/Ozone) paint ONLY when their window is actually visible
|
||||
// AND activated: parked HWND_BOTTOM under the DAW they never paint -> black
|
||||
// forever, and SetForegroundWindow from this background process is blocked
|
||||
// by the Windows foreground lock. So: raise the window ABOVE the DAW at the
|
||||
// panel rect, paint it there, then demote back under the DAW — the painted
|
||||
// surface survives the demote (capture stays non-black at HWND_BOTTOM).
|
||||
static void kickFirstPaint(HWND hwnd, const char* via) {
|
||||
if (g_paintKicked.load()) return;
|
||||
g_paintKicked = true;
|
||||
const int cw = g_embedRect.right - g_embedRect.left;
|
||||
const int ch = g_embedRect.bottom - g_embedRect.top;
|
||||
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.
|
||||
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=" << via << " @" << cx
|
||||
<< "," << cy << std::endl;
|
||||
}
|
||||
|
||||
// --- HTTP server loop (worker thread) ---------------------------------------
|
||||
void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
WSADATA wsa;
|
||||
@@ -557,8 +611,8 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
// redirects the editor's GPU surface and capture sees real
|
||||
// pixels — the one-shot rect /show may have run pre-attach
|
||||
// (deduped), so reconcile here on every poll until it fits.
|
||||
if (g_embedOnscreen && !g_anchored && !g_dance &&
|
||||
g_embedRect.right > g_embedRect.left &&
|
||||
if (!g_fxGuiFloatMode && g_embedOnscreen && !g_anchored &&
|
||||
!g_dance && g_embedRect.right > g_embedRect.left &&
|
||||
g_embedRect.bottom > g_embedRect.top) {
|
||||
const int cw = g_embedRect.right - g_embedRect.left;
|
||||
const int ch = g_embedRect.bottom - g_embedRect.top;
|
||||
@@ -569,6 +623,13 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
SetWindowPos(hwnd, HWND_BOTTOM, g_embedRect.left,
|
||||
g_embedRect.top, cw, ch,
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
// First-paint kick via /frame poll: the one-shot /show
|
||||
// may have landed while the editor was still loading
|
||||
// (ready=false) and is deduped, so no later /show fires;
|
||||
// iZotope-class editors paint only when visible+activated
|
||||
// -> black panel until a user click. Kick here once the
|
||||
// editor is ready and the window is parked at the rect.
|
||||
kickFirstPaint(hwnd, "frame");
|
||||
}
|
||||
std::vector<unsigned char> jpg;
|
||||
float avg = -1.0f;
|
||||
@@ -633,6 +694,7 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
g_fxGuiShowPending.store(false); // panel closed while attaching: don't auto-show later
|
||||
// Reopen re-anchors from scratch (/show + rect) — clear the
|
||||
// real-input state or the reopened panel would never re-place
|
||||
// the window at the panel rect (stuck anchored/offscreen).
|
||||
@@ -642,6 +704,52 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
g_paintKicked = false; // next /show re-kicks the first paint
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
} else if (req.path == "/show") {
|
||||
if (g_fxGuiFloatMode) {
|
||||
// P2 float mode: reopen the hidden window (WM_CLOSE only hides
|
||||
// in float mode — the plugin instance stays loaded, so a show
|
||||
// is instant). Optional {rect:{x,y,w,h}} positions/sizes the
|
||||
// window explicitly; without one, show at the previous spot and
|
||||
// try to focus (SetForegroundWindow is best-effort — the
|
||||
// Windows foreground lock may swallow it; the user's first
|
||||
// click reactivates the window anyway). Guarded by
|
||||
// g_fxGuiEditorReady: while the pump thread is still loading +
|
||||
// attaching, a SetWindowPos from this worker would block.
|
||||
if (g_fxGuiEditorReady.load()) {
|
||||
json_parse_result_s spres = {};
|
||||
json_value_s* sroot = json_parse_ex(req.body.data(), req.body.size(),
|
||||
json_parse_flags_default, nullptr, nullptr, &spres);
|
||||
bool rectOk = false;
|
||||
if (sroot && sroot->type == json_type_object) {
|
||||
const json_object_s* so = static_cast<const json_object_s*>(sroot->payload);
|
||||
const json_value_s* srv = memberValue(so, "rect");
|
||||
if (srv && srv->type == json_type_object) {
|
||||
const json_object_s* sro = static_cast<const json_object_s*>(srv->payload);
|
||||
const LONG srw = (LONG)memberInt(sro, "w", 0);
|
||||
const LONG srh = (LONG)memberInt(sro, "h", 0);
|
||||
if (srw > 0 && srh > 0) {
|
||||
SetWindowPos(hwnd, nullptr,
|
||||
(int)memberInt(sro, "x", 0),
|
||||
(int)memberInt(sro, "y", 0),
|
||||
(int)srw, (int)srh,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
rectOk = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sroot) std::free(sroot);
|
||||
if (!rectOk) {
|
||||
ShowWindow(hwnd, SW_SHOW);
|
||||
bringToTop(hwnd);
|
||||
}
|
||||
} else {
|
||||
// Editor still loading/attaching (slow VST): cannot show
|
||||
// yet. Remember the request — fxGuiLoadAttach shows the
|
||||
// window right after attach, else the GUI stays hidden
|
||||
// forever on a reopen during attach (Bug 2).
|
||||
g_fxGuiShowPending.store(true);
|
||||
}
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
} else {
|
||||
// Optional body {rect:{x,y,w,h}} (screen px) — anchored placement
|
||||
// over the embed panel; without a rect just show + top (never
|
||||
// topmost — the GUI must not stay above other apps).
|
||||
@@ -697,40 +805,9 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
if (cur.right - cur.left != cw || cur.bottom - cur.top != ch)
|
||||
fxGuiRequestResize(hwnd, cw, ch);
|
||||
// First-paint kick (one-time per /show cycle, only after the
|
||||
// editor attached). iZotope-class editors (Nectar/Ozone) paint
|
||||
// ONLY when their window is actually visible AND activated:
|
||||
// parked HWND_BOTTOM under the DAW they never paint -> black
|
||||
// forever, and SetForegroundWindow from this background process
|
||||
// is blocked by the Windows foreground lock. So: raise the
|
||||
// window ABOVE the DAW at the panel rect, paint it there, then
|
||||
// demote back under the DAW — the painted surface survives the
|
||||
// demote (capture stays non-black at HWND_BOTTOM).
|
||||
if (!g_paintKicked) {
|
||||
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.
|
||||
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;
|
||||
}
|
||||
// First-paint kick — one-time, runs from the shared helper;
|
||||
// /frame also kicks (the deduped /show may land pre-ready).
|
||||
kickFirstPaint(hwnd, "click");
|
||||
} else if (!IsWindowVisible(hwnd)) {
|
||||
// No usable rect yet (pre-layout / still attaching): keep the
|
||||
// window somewhere harmless until the frontend supplies it.
|
||||
@@ -740,6 +817,7 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
// 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);
|
||||
} // end embed-mode /show body (else of g_fxGuiFloatMode)
|
||||
} else if (req.path == "/preset") {
|
||||
std::string b64;
|
||||
fxGuiRequestCapture(hwnd, b64, 500); // 1500 blocked the HTTP worker, stalling /input (clicks)
|
||||
@@ -758,10 +836,13 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
|
||||
if (sroot) std::free(sroot);
|
||||
sendResponse(c, 400, "application/json", "{\"error\":\"bad json\"}", 20);
|
||||
}
|
||||
} else if (req.path == "/close") {
|
||||
} else if (req.path == "/close" || req.path == "/exit") {
|
||||
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
|
||||
stop.store(true);
|
||||
PostMessageA(hwnd, WM_CLOSE, 0, 0); // pump exits -> process returns
|
||||
// Float mode: WM_CLOSE only hides — WM_FXGUI_EXIT destroys the
|
||||
// window so the pump exits and the process returns. Embedded mode
|
||||
// is unchanged (there WM_CLOSE already destroyed).
|
||||
fxGuiRequestExit(hwnd); // pump exits -> process returns
|
||||
} else {
|
||||
sendResponse(c, 404, "application/json", "{\"error\":\"not found\"}", 22);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
// refuses to serve /frame while false: before attach the host window is empty
|
||||
// and capturing it would stream a blank frame -> white flash in the webview.
|
||||
std::atomic<bool> g_fxGuiEditorReady{false};
|
||||
// /show that arrived while the editor was still loading/attaching (slow VST,
|
||||
// e.g. iZotope 20-70s cold attach) must not be silently dropped: FxGuiServer's
|
||||
// float /show sets this when it cannot show yet; fxGuiLoadAttach consumes it
|
||||
// right after a successful attach and shows the window. Without it, closing
|
||||
// the Mastering panel while the GUI is attaching and reopening it later leaves
|
||||
// the GUI permanently hidden (Bug 2).
|
||||
std::atomic<bool> g_fxGuiShowPending{false};
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -614,6 +621,31 @@ public:
|
||||
}
|
||||
state_ = s;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
// Unload the current instance so a retry can load a fresh one. On the
|
||||
// retry path the editor never attached (view is null) and closeEditor()
|
||||
// only frees the plugFrame leaked by the crashed attach. state_ is
|
||||
// cleared BEFORE the plugin teardown calls: a crash in one of them
|
||||
// (SEH-caught by fxGuiUnloadSafe) leaves this object usable again — the
|
||||
// old Vst3FxState leaks (accepted: the retry loop gives up anyway).
|
||||
void unload() {
|
||||
#ifndef HAVE_VST3SDK
|
||||
return;
|
||||
#else
|
||||
auto* s = static_cast<Vst3FxState*>(state_);
|
||||
if (!s) return;
|
||||
closeEditor();
|
||||
state_ = nullptr;
|
||||
if (s->component) {
|
||||
FUnknownPtr<IAudioProcessor> processor(s->component);
|
||||
if (processor) processor->setProcessing(false);
|
||||
s->component->setActive(false);
|
||||
s->component->terminate();
|
||||
}
|
||||
if (s->controller && !s->controllerIsComponent) s->controller->terminate();
|
||||
s->processData.unprepare();
|
||||
delete s;
|
||||
#endif
|
||||
}
|
||||
// Audio in → audio out, one block. Must be SEH-wrapped by the caller.
|
||||
@@ -1500,6 +1532,13 @@ void RealtimeFxChain::Impl::workerLoop() {
|
||||
static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h);
|
||||
static void fxGuiCaptureSafe(void* fx, std::string& out);
|
||||
|
||||
// Float mode (P2 gop-process plan): SF_FXGUI_FLOAT=1 makes --fx-gui a REAL
|
||||
// on-screen window instead of the embedded frame-capture server. WM_CLOSE only
|
||||
// hides it (plugin instance stays loaded — reopen is instant); /exit, /close
|
||||
// or a dead parent tear the process down for real. Set in run_fx_gui_server
|
||||
// from the env, before the window is created.
|
||||
bool g_fxGuiFloatMode = false;
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kFxGuiWndClass = "SonicForge_FX_GUI_Class";
|
||||
@@ -1521,13 +1560,42 @@ static bool fxGuiParentAlive(uint32_t pid) {
|
||||
CloseHandle(h);
|
||||
return true;
|
||||
}
|
||||
// Attach attempts per process (VẤN ĐỀ #2 auto-retry): Ozone 11's editor
|
||||
// crashes (0xC0000005) on a COLD first attach (createInstance ~74s) but a
|
||||
// warm reload (~20-30s) usually attaches fine. Retrying in-process keeps the
|
||||
// SAME hwnd, HTTP port and process so the frontend embed never sees the
|
||||
// bridge die. Env SF_FXGUI_ATTACH_RETRIES overrides the count (1 = no retry,
|
||||
// the pre-V45.46 behavior; cap 6).
|
||||
static int fxGuiAttachRetries() {
|
||||
const char* e = std::getenv("SF_FXGUI_ATTACH_RETRIES");
|
||||
int n = e ? std::atoi(e) : 3;
|
||||
if (n < 1) n = 1;
|
||||
if (n > 6) n = 6;
|
||||
return n;
|
||||
}
|
||||
// Leftover plugin child windows (GL surfaces etc.) from a crashed attach must
|
||||
// go before the next attempt — the fresh instance creates its own children.
|
||||
// SEH-wrapped: a bad child wndproc after a crash must not kill the retry.
|
||||
static BOOL CALLBACK fxGuiDestroyChildProc(HWND child, LPARAM) {
|
||||
__try { if (IsWindow(child)) DestroyWindow(child); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
return TRUE; // keep enumerating
|
||||
}
|
||||
static void fxGuiDestroyChildWindows(HWND hwnd) {
|
||||
if (IsWindow(hwnd)) EnumChildWindows(hwnd, fxGuiDestroyChildProc, 0);
|
||||
}
|
||||
// Float-mode exit message: destroy the window -> pump loop ends -> process
|
||||
// exits for real (WM_CLOSE in float mode only hides).
|
||||
#define WM_FXGUI_EXIT (WM_APP + 0x53)
|
||||
|
||||
static void fxGuiStartWatchdog(HWND hwnd, uint32_t parentPid) {
|
||||
if (parentPid == 0) return;
|
||||
std::thread([hwnd, parentPid]() {
|
||||
while (IsWindow(hwnd)) {
|
||||
Sleep(2000);
|
||||
if (!fxGuiParentAlive(parentPid)) {
|
||||
PostMessageA(hwnd, WM_CLOSE, 0, 0); // pump exits -> process returns
|
||||
PostMessageA(hwnd, g_fxGuiFloatMode ? WM_FXGUI_EXIT : WM_CLOSE,
|
||||
0, 0); // pump exits -> process returns
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1552,11 +1620,39 @@ LRESULT CALLBACK FxGuiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
FxGuiCtx* ctx = (FxGuiCtx*)GetWindowLongPtrA(hwnd, GWLP_USERDATA);
|
||||
switch (msg) {
|
||||
case WM_CLOSE:
|
||||
// Float mode: hide, keep the window + plugin instance alive so a
|
||||
// reopen (/show) is instant. Only embedded mode (or WM_FXGUI_EXIT)
|
||||
// destroys the window for real.
|
||||
if (g_fxGuiFloatMode) {
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
return 0;
|
||||
}
|
||||
DestroyWindow(hwnd);
|
||||
return 0;
|
||||
case WM_FXGUI_EXIT:
|
||||
DestroyWindow(hwnd);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
if (ctx) ctx->closing = true;
|
||||
return 0;
|
||||
case WM_EXITSIZEMOVE:
|
||||
// Float mode: user finished dragging/moving the frame. The plugin
|
||||
// child window does NOT follow host resizes by itself, so the
|
||||
// newly exposed/stale client area keeps old pixels (ảnh ma, Bug 1).
|
||||
// Resize the editor view to the new client rect (plugin clamps via
|
||||
// checkSizeConstraint; resizeEditor re-sizes the host to match) and
|
||||
// force a full repaint of window + children. Runs on the pump
|
||||
// thread (owner of the editor), so the direct call is safe.
|
||||
if (g_fxGuiFloatMode && !(ctx && ctx->closing)) {
|
||||
RECT rc;
|
||||
if (GetClientRect(hwnd, &rc) && rc.right > 0 && rc.bottom > 0) {
|
||||
fxGuiResizeSafe((void*)GetPropA(hwnd, "FXGUI_FX"), hwnd,
|
||||
(int)rc.right, (int)rc.bottom);
|
||||
}
|
||||
RedrawWindow(hwnd, nullptr, nullptr,
|
||||
RDW_INVALIDATE | RDW_ALLCHILDREN);
|
||||
}
|
||||
return 0;
|
||||
case WM_FXGUI_RESIZE:
|
||||
fxGuiResizeSafe((void*)GetPropA(hwnd, "FXGUI_FX"), hwnd, (int)wp, (int)lp);
|
||||
return 0;
|
||||
@@ -1596,6 +1692,18 @@ static void fxGuiDetachSafe(void* fx) {
|
||||
(void)fx;
|
||||
#endif
|
||||
}
|
||||
// Unload a GUI instance under SEH — the plugin's terminate may crash (same
|
||||
// rule as fxGuiAttachSafe: raw-pointer frame, C2712-safe). False = exception
|
||||
// mid-teardown → instance state unknown → stop retrying.
|
||||
static bool fxGuiUnloadSafe(Vst3Fx* fx) {
|
||||
#ifdef HAVE_VST3SDK
|
||||
__try { if (fx) fx->unload(); return true; }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
|
||||
#else
|
||||
(void)fx;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h) {
|
||||
#ifdef HAVE_VST3SDK
|
||||
__try { static_cast<Vst3Fx*>(fx)->resizeEditor(hwnd, w, h); }
|
||||
@@ -1634,6 +1742,13 @@ void fxGuiRequestCapture(void* hwndV, std::string& outB64, int timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Float mode close: post WM_FXGUI_EXIT so the pump thread destroys the window
|
||||
// (full process exit — WM_CLOSE in float mode only hides). Used by the
|
||||
// FxGuiServer /close and /exit routes; embedded mode is unaffected.
|
||||
void fxGuiRequestExit(HWND hwnd) {
|
||||
PostMessageA(hwnd, WM_FXGUI_EXIT, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
// --- SHM audio feeder (GUI meters) -----------------------------------------
|
||||
// The GUI process PEEKS the SAME input ring the realtime session uses (never
|
||||
@@ -1828,13 +1943,19 @@ static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::strin
|
||||
g_fxGuiEditorReady.store(false);
|
||||
if (!fxGuiAttachSafe(fx, hwnd)) {
|
||||
std::cerr << "[FxGui] editor attach failed for " << path << std::endl;
|
||||
DestroyWindow(hwnd);
|
||||
// Do NOT destroy hwnd here — the retry loop reuses the same window;
|
||||
// after the last failed attempt the caller posts WM_CLOSE (teardown).
|
||||
return 2;
|
||||
}
|
||||
// Attach OK: allow /frame. First captures may still be blank (plugin paints
|
||||
// on its first WM_PAINT) — FxGuiServer's luma check rejects those and
|
||||
// serves the last good frame instead.
|
||||
g_fxGuiEditorReady.store(true);
|
||||
if (g_fxGuiShowPending.exchange(false)) {
|
||||
// A /show was waiting while this editor attached (Bug 2): show now
|
||||
// that the plugin view exists. Runs on the pump thread (owner).
|
||||
ShowWindow(hwnd, SW_SHOW);
|
||||
}
|
||||
std::cout << "[FxGui] editor open: " << (name.empty() ? path : name) << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -1890,6 +2011,15 @@ int run_fx_gui_server(const std::string& jobPath) {
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
struct ComGuard { ~ComGuard() { CoUninitialize(); } } comGuard;
|
||||
|
||||
// P2 float mode: SF_FXGUI_FLOAT=1 (engine sets it for --fx-gui) turns the
|
||||
// embed server into a REAL on-screen window. Must be read before
|
||||
// fxGuiCreateWindow + watchdog (window style + WM_CLOSE handling differ).
|
||||
const char* floatEnv = std::getenv("SF_FXGUI_FLOAT");
|
||||
g_fxGuiFloatMode = floatEnv != nullptr && floatEnv[0] != '0';
|
||||
if (g_fxGuiFloatMode)
|
||||
std::cout << "[FxGui] float mode (real on-screen window; WM_CLOSE=hide)"
|
||||
<< std::endl;
|
||||
|
||||
Vst3Fx fx;
|
||||
HWND hwnd = nullptr;
|
||||
// Fast path: parse job + create the host window, then start the HTTP
|
||||
@@ -1899,7 +2029,7 @@ int run_fx_gui_server(const std::string& jobPath) {
|
||||
std::string path, name, shm, presetB64;
|
||||
int rc = fxGuiParseJob(jobPath, path, name, shm, presetB64);
|
||||
if (rc) return rc;
|
||||
rc = fxGuiCreateWindow(name, /*offscreen=*/true, 0, &hwnd);
|
||||
rc = fxGuiCreateWindow(name, /*offscreen=*/!g_fxGuiFloatMode, 0, &hwnd);
|
||||
if (rc) return rc;
|
||||
fxGuiStartWatchdog(hwnd, fxGuiParentPid());
|
||||
SetPropA(hwnd, "FXGUI_FX", &fx); // for WM_FXGUI_RESIZE on the pump thread
|
||||
@@ -1920,25 +2050,52 @@ int run_fx_gui_server(const std::string& jobPath) {
|
||||
<< " SF_FXGUI_DEBUG=" << (std::getenv("SF_FXGUI_DEBUG") ? 1 : 0)
|
||||
<< std::endl;
|
||||
const int srv = fxGuiServerLoop(hwnd, [&]() {
|
||||
// Some plugin GL editors (e.g. iZotope Ozone 11 on Intel UHD 630) crash
|
||||
// when their editor window is parked offscreen during attach. Env toggle
|
||||
// SF_FXGUI_ATTACH_ONSCREEN=1: show the window on-screen first, then park
|
||||
// it back once the editor attached. Visible for one frame max (feeder is
|
||||
// started only after attach succeeds).
|
||||
if (attachOnscreen)
|
||||
SetWindowPos(hwnd, nullptr, 100, 100, 0, 0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd, presetB64);
|
||||
if (loadRc == 0) {
|
||||
fxGuiStartFeederThread(&fx, hwnd); // after load+attach only
|
||||
// EMBED_ONSCREEN: leave the window where attach put it (on a
|
||||
// monitor); the /show + /frame reconcile park it at the panel
|
||||
// rect (HWND_BOTTOM) once the frontend supplies the rect.
|
||||
if (attachOnscreen && !embedOnscreen)
|
||||
SetWindowPos(hwnd, nullptr, -20000, -20000, 0, 0,
|
||||
// Auto-retry attach (V45.46, VẤN ĐỀ #2): iZotope Ozone 11's editor
|
||||
// crashes (0xC0000005 in iZOzone11Core.dll) on a COLD first attach.
|
||||
// Each retry unloads the instance and loads a fresh one (warm load
|
||||
// ~20-30s vs ~74s cold) while keeping the SAME hwnd, HTTP port and
|
||||
// process — the frontend embed poll never sees the bridge die and
|
||||
// its spinner just keeps waiting. SF_FXGUI_ATTACH_RETRIES overrides
|
||||
// the count (1 = old no-retry behavior; cap 6). "attach attempt N/M"
|
||||
// in the engine log lets the user verify retries happened.
|
||||
const int maxAttempts = fxGuiAttachRetries();
|
||||
int attempt = 0;
|
||||
for (; attempt < maxAttempts; ++attempt) {
|
||||
// Some plugin GL editors (e.g. iZotope Ozone 11 on Intel UHD 630)
|
||||
// crash when parked offscreen during attach. Show on-screen first,
|
||||
// park back once the editor attached. Visible for one frame max
|
||||
// (feeder is started only after attach succeeds).
|
||||
if (attachOnscreen)
|
||||
SetWindowPos(hwnd, nullptr, 100, 100, 0, 0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
std::cout << "[FxGui] attach attempt " << (attempt + 1) << "/"
|
||||
<< maxAttempts << std::endl;
|
||||
if (attempt > 0) {
|
||||
// Let audio/session settle after the crash, drop the crashed
|
||||
// instance's leftover child windows, then unload it.
|
||||
Sleep(1500);
|
||||
fxGuiDestroyChildWindows(hwnd);
|
||||
if (!fxGuiUnloadSafe(&fx)) break; // instance state unknown — give up
|
||||
}
|
||||
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd, presetB64);
|
||||
if (loadRc == 0) {
|
||||
fxGuiStartFeederThread(&fx, hwnd); // after load+attach only
|
||||
// EMBED_ONSCREEN: leave the window where attach put it (on a
|
||||
// monitor); the /show + /frame reconcile park it at the panel
|
||||
// rect (HWND_BOTTOM) once the frontend supplies the rect.
|
||||
// Float mode: leave the window where attach put it (visible,
|
||||
// user-positionable) — nothing to park off-screen.
|
||||
if (!g_fxGuiFloatMode && attachOnscreen && !embedOnscreen)
|
||||
SetWindowPos(hwnd, nullptr, -20000, -20000, 0, 0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
break;
|
||||
}
|
||||
std::cerr << "[FxGui] attach attempt " << (attempt + 1) << "/"
|
||||
<< maxAttempts << " FAILED (rc=" << loadRc << ")" << std::endl;
|
||||
}
|
||||
if (loadRc != 0) PostMessageA(hwnd, WM_CLOSE, 0, 0); // exit; port already printed
|
||||
if (loadRc != 0)
|
||||
PostMessageA(hwnd, g_fxGuiFloatMode ? WM_FXGUI_EXIT : WM_CLOSE,
|
||||
0, 0); // exit; port already printed
|
||||
});
|
||||
RemovePropA(hwnd, "FXGUI_FX");
|
||||
fxGuiDetachSafe(&fx);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user