diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py
index 675a754..fc7ab47 100644
--- a/app/api/v1/plugins.py
+++ b/app/api/v1/plugins.py
@@ -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]
diff --git a/app/api/v1/projects.py b/app/api/v1/projects.py
index 28c33c4..c9c0f3a 100644
--- a/app/api/v1/projects.py
+++ b/app/api/v1/projects.py
@@ -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)
diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index e84f3be..7c5f0ea 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -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 }) =>
Đang tải plugin VST — GUI embed sẽ hiện khi plugin sẵn sàng...
)}
-
{ setFrameLoaded(true); frameLoadedRef.current = true; }} />
+
setFrameLoaded(true)} />
);
};
+
+
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen, openGui, activeIdx = -1, onSelect = null) => (
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'}`}>