Fix 3 bug standalone: save project lỗi schema, export MIDI/WAV không hỏi nơi lưu
- Bug 1: serializeTracksList bỏ section item không có sectionId (do insertSectionAtPlayhead tạo) — tránh source_data {} làm schema oneOf fail
- Bug 2: export MIDI qua save-dialog + write file, toast kèm đường dẫn
- Bug 3: export mix WAV/MP3/OGG qua save-dialog + write file, toast kèm đường dẫn
- Marker tauri_bridge_ready: xóa khi window destroy + guard SF_PARENT_PID — tránh standalone treo 120s
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import os, sys, json, subprocess, time as _time
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Native "Save As" dialog (fix bug: export MIDI/WAV không hỏi nơi lưu) ──
|
||||
# WebView2 standalone chặn anchor-download → không thấy save dialog. Giải pháp:
|
||||
# frontend gọi /save-dialog → engine mở NATIVE SaveFileDialog (Windows Explorer)
|
||||
# → trả đường dẫn user chọn → frontend gửi bytes qua /write → ghi file thật.
|
||||
SAVE_DIALOG_TIMEOUT = 120 # user có thể để dialog mở lâu
|
||||
|
||||
|
||||
def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]:
|
||||
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE SaveFileDialog qua
|
||||
file IPC: engine ghi save_file.request (chứa tên mặc định) → Rust mở dialog
|
||||
→ ghi save_file.response. Trả None nếu bridge không tồn tại (standalone)."""
|
||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
|
||||
if not os.path.isdir(ipc):
|
||||
return None
|
||||
# Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co
|
||||
# the con sot tu lan Tauri chay truoc → chay standalone se cho response
|
||||
# khong bao gio den (treo 120s). Check nay loai bo truong hop do.
|
||||
if not os.environ.get("SF_PARENT_PID"):
|
||||
return None
|
||||
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
|
||||
return None
|
||||
req = os.path.join(ipc, "save_file.request")
|
||||
resp = os.path.join(ipc, "save_file.response")
|
||||
try:
|
||||
for f in (req, resp):
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
with open(req, "w", encoding="utf-8") as fh:
|
||||
fh.write(default_name or "untitled")
|
||||
deadline = _time.time() + SAVE_DIALOG_TIMEOUT
|
||||
while _time.time() < deadline:
|
||||
if os.path.exists(resp):
|
||||
try:
|
||||
with open(resp, "r", encoding="utf-8") as fh:
|
||||
val = fh.read().strip()
|
||||
finally:
|
||||
os.remove(resp)
|
||||
return val or None
|
||||
_time.sleep(0.1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_file_native_engine(default_name: str, filter_name: str, filter_ext: str) -> Optional[str]:
|
||||
"""Fallback khi không có Tauri bridge: PowerShell SaveFileDialog (Windows),
|
||||
osascript (macOS), zenity/kdialog (Linux)."""
|
||||
if os.name == "nt":
|
||||
# FileName mặc định + Filter — single-quoted trong PS, filename không
|
||||
# chứa nháy đơn (SonicForge tự sinh tên).
|
||||
ps = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms; "
|
||||
f"$f = New-Object System.Windows.Forms.SaveFileDialog; "
|
||||
f"$f.FileName = '{default_name}'; "
|
||||
f"$f.Filter = '{filter_name} (*.{filter_ext})|*.{filter_ext}|Tất cả các tệp (*.*)|*.*'; "
|
||||
"$f.Title = 'Chọn nơi lưu tệp xuất'; "
|
||||
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.FileName }"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-STA", "-Command", ps],
|
||||
capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT,
|
||||
)
|
||||
return r.stdout.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["osascript", "-e",
|
||||
'set f to choose file name with prompt "Chọn nơi lưu tệp xuất" default name "' + (default_name or "untitled") + '"'],
|
||||
capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT,
|
||||
)
|
||||
return r.stdout.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
for cmd in (["zenity", "--file-selection", "--save", "--filename=" + (default_name or "untitled")],
|
||||
["kdialog", "--getsavefilename", os.path.join(os.path.expanduser("~"), default_name or "untitled")]):
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT)
|
||||
if r.returncode == 0:
|
||||
p = r.stdout.strip()
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class SaveDialogRequest(BaseModel):
|
||||
default_name: str = "untitled"
|
||||
filter_name: str = "File"
|
||||
filter_ext: str = "bin"
|
||||
|
||||
|
||||
@router.post("/save-dialog")
|
||||
def save_file_dialog(req: SaveDialogRequest):
|
||||
"""Mở NATIVE Save As dialog. Không cần auth (desktop local, chỉ mở dialog).
|
||||
Trả {"path": "<đường dẫn user chọn>"} hoặc {"path": None} (hủy/không có
|
||||
dialog). def (sync) → FastAPI chạy threadpool — không block loop."""
|
||||
path = _save_file_via_tauri_bridge(req.default_name)
|
||||
if path is None:
|
||||
path = _save_file_native_engine(req.default_name, req.filter_name, req.filter_ext)
|
||||
return {"path": path}
|
||||
|
||||
|
||||
@router.post("/write")
|
||||
async def write_export_file(path: str = Form(...), file: UploadFile = File(...)):
|
||||
"""Ghi bytes đã export vào đường dẫn tuyệt đối user chọn qua save dialog.
|
||||
Trả {"path": ..., "bytes": N} — frontend toast kèm đường dẫn thật."""
|
||||
try:
|
||||
data = await file.read()
|
||||
dirname = os.path.dirname(path)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return {"path": path, "bytes": len(data)}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Không thể ghi file: {str(e)}")
|
||||
@@ -277,6 +277,11 @@ def _pick_dir_via_tauri_bridge() -> Optional[str]:
|
||||
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
|
||||
if not os.path.isdir(ipc):
|
||||
return None
|
||||
# Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co
|
||||
# the con sot tu lan Tauri chay truoc → chay standalone se cho response
|
||||
# khong bao gio den (treo 120s). Check nay loai bo truong hop do.
|
||||
if not os.environ.get("SF_PARENT_PID"):
|
||||
return None
|
||||
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
|
||||
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
|
||||
return None
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.api.v1.user_config import router as user_config_router
|
||||
from app.api.v1.ai_proxy import router as ai_proxy_router
|
||||
from app.api.v1.ai_presets import router as ai_presets_router
|
||||
from app.api.v1.plugins import router as plugins_router
|
||||
from app.api.v1.files import router as files_router
|
||||
from app.api.v1.media import router as media_router
|
||||
from app.api.v1.system import router as system_router
|
||||
from app.api.v1.presets import router as presets_router
|
||||
@@ -109,6 +110,7 @@ app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config
|
||||
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(files_router, prefix="/api/v1/files", tags=["files"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
|
||||
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
|
||||
|
||||
+91
-18
@@ -11029,6 +11029,12 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
}
|
||||
if (t.sections && t.sections.length > 0) {
|
||||
t.sections.forEach(s => {
|
||||
// BỎ section item thiếu sectionId (insertSectionAtPlayhead tạo section
|
||||
// chưa gắn vào tab → s.sectionId undefined). Nếu serialize thật thì
|
||||
// referenced_section_id bị JSON.stringify bỏ → source_data = {} →
|
||||
// schema oneOf fail → lưu project báo lỗi xác thực (bug user gặp).
|
||||
// Item vô nghĩa (chưa có nội dung tab) — bỏ là đúng, không mất data.
|
||||
if (!s.sectionId) return;
|
||||
items.push({
|
||||
id: s.id,
|
||||
name: s.name || "Section Item",
|
||||
@@ -11037,9 +11043,6 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
duration_bars: s.duration / secondsPerBar,
|
||||
clip_start_offset_bars: 0.0,
|
||||
source_data: {
|
||||
// KHÔNG fallback s.id: section item thiếu sectionId (insert thiếu
|
||||
// field) → fallback = chính id item → TỰ TRỎ → block lồng + mất
|
||||
// nội dung. Chỉ dùng sectionId; undefined → deserialize block rỗng.
|
||||
referenced_section_id: s.sectionId
|
||||
}
|
||||
});
|
||||
@@ -26593,6 +26596,36 @@ const App = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// ── Save-As qua NATIVE dialog (fix bug: export không hỏi nơi lưu) ──
|
||||
// WebView2 chặn anchor-download → engine mở SaveFileDialog qua
|
||||
// /api/v1/files/save-dialog (PowerShell hoặc Tauri bridge) → ghi bytes qua
|
||||
// /api/v1/files/write. Trả path đã ghi; null nếu user hủy / không có dialog.
|
||||
async function saveExportViaDialog(defaultName, blob) {
|
||||
try {
|
||||
const dlg = await fetch(`${API_BASE_URL}/api/v1/files/save-dialog`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_name: defaultName,
|
||||
filter_name: defaultName.toLowerCase().endsWith('.mid') ? 'MIDI' : 'Audio',
|
||||
filter_ext: defaultName.split('.').pop() || 'bin'
|
||||
})
|
||||
});
|
||||
if (!dlg.ok) return null;
|
||||
const d = await dlg.json();
|
||||
if (!d.path) return null;
|
||||
const fd = new FormData();
|
||||
fd.append('path', d.path);
|
||||
fd.append('file', new File([blob], defaultName, { type: blob.type || 'application/octet-stream' }));
|
||||
const wr = await fetch(`${API_BASE_URL}/api/v1/files/write`, { method: 'POST', body: fd });
|
||||
if (!wr.ok) return null;
|
||||
const w = await wr.json();
|
||||
return w.path;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── MIDI Export: tạo file MIDI từ tất cả tracks ──
|
||||
// ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ──
|
||||
// Blob download bị WebView2 chặn sau khi chuyển standalone → POST
|
||||
@@ -26610,7 +26643,18 @@ const App = () => {
|
||||
throw new Error(e.detail || ('HTTP ' + res.status));
|
||||
}
|
||||
return res.json();
|
||||
}).then(data => {
|
||||
}).then(async data => {
|
||||
// NATIVE Save-As dialog (WebView2 chặn anchor download) → ghi file thật.
|
||||
try {
|
||||
const blobResp = await fetch(`${API_BASE_URL}${data.download_url}`);
|
||||
if (blobResp.ok) {
|
||||
const blob = await blobResp.blob();
|
||||
const savedPath = await saveExportViaDialog(filename, blob);
|
||||
if (savedPath) {
|
||||
return { ...data, saved_path: savedPath };
|
||||
}
|
||||
}
|
||||
} catch (e) { /* fallback anchor dưới */ }
|
||||
const a = document.createElement('a');
|
||||
a.href = `${API_BASE_URL}${data.download_url}`;
|
||||
a.download = filename;
|
||||
@@ -26627,6 +26671,11 @@ const App = () => {
|
||||
// /api/v1/audio/download/{file_id} (Content-Disposition attachment).
|
||||
// Nếu upload thất bại (server offline) → fallback blob download cũ.
|
||||
async function downloadBlobViaServer(blob, filename) {
|
||||
// NATIVE Save-As dialog trước (WebView2 chặn anchor download) → ghi file thật.
|
||||
try {
|
||||
const savedPath = await saveExportViaDialog(filename, blob);
|
||||
if (savedPath) return { saved_path: savedPath };
|
||||
} catch (e) { /* fallback upload+anchor dưới */ }
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new File([blob], filename, { type: blob.type || 'audio/wav' }));
|
||||
@@ -26677,8 +26726,12 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid');
|
||||
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
|
||||
const r = await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid');
|
||||
if (r && r.saved_path) {
|
||||
showToast(`Đã xuất file MIDI (${midiTracks.length} tracks) → ${r.saved_path}`, "success");
|
||||
} else {
|
||||
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error');
|
||||
}
|
||||
@@ -27008,11 +27061,19 @@ const App = () => {
|
||||
const fileId = result.output_file_id || result.output_path?.split('/').pop();
|
||||
if (fileId) {
|
||||
const downloadUrl = `${API_AUDIO}/download/${fileId}`;
|
||||
showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = fileId;
|
||||
a.click();
|
||||
showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", async () => {
|
||||
try {
|
||||
const br = await fetch(downloadUrl);
|
||||
if (!br.ok) throw new Error('HTTP ' + br.status);
|
||||
const b = await br.blob();
|
||||
const sp = await saveExportViaDialog((projectName || 'Project') + '.wav', b);
|
||||
if (sp) showToast(`Đã lưu → ${sp}`, "success");
|
||||
} catch (e) {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = fileId;
|
||||
a.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -27495,11 +27556,19 @@ const App = () => {
|
||||
if (result.success) {
|
||||
const outId = result.output_file_id;
|
||||
const downloadUrl = `${API_AUDIO}/download/${outId}`;
|
||||
showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;
|
||||
a.click();
|
||||
showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", async () => {
|
||||
try {
|
||||
const br = await fetch(downloadUrl);
|
||||
if (!br.ok) throw new Error('HTTP ' + br.status);
|
||||
const b = await br.blob();
|
||||
const sp = await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`, b);
|
||||
if (sp) showToast(`Đã lưu → ${sp}`, "success");
|
||||
} catch (e) {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;
|
||||
a.click();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.error || 'Server encoding failed');
|
||||
@@ -27513,8 +27582,12 @@ const App = () => {
|
||||
if (exportSettings.format !== 'wav') {
|
||||
showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning");
|
||||
}
|
||||
await downloadBlobViaServer(blob, wavName).catch(() => {});
|
||||
showToast("Xuất bản âm thanh hoàn tất!", "success");
|
||||
const dres = await downloadBlobViaServer(blob, wavName).catch(() => null);
|
||||
if (dres && dres.saved_path) {
|
||||
showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`, "success");
|
||||
} else {
|
||||
showToast("Xuất bản âm thanh hoàn tất!", "success");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Restore the live master bus + live mastering state after offline render.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -648,6 +648,40 @@ pub fn run() {
|
||||
waited_ms += 100;
|
||||
}
|
||||
}
|
||||
// ── Native Save-As dialog bridge (export MIDI/WAV) ──
|
||||
// Giong pick_dir: engine ghi ipc/save_file.request (chua ten
|
||||
// mac dinh) → thread mo NATIVE SaveFileDialog → ghi ket qua
|
||||
// vao ipc/save_file.response. Frontend lay path → POST bytes
|
||||
// /api/v1/files/write → file thuc su duoc ghi vao noi chon.
|
||||
let sreq = ipc_dir_for_thread.join("save_file.request");
|
||||
if sreq.exists() {
|
||||
let default_name = std::fs::read_to_string(&sreq).unwrap_or_default();
|
||||
let _ = std::fs::remove_file(&sreq);
|
||||
let sresp = ipc_dir_for_thread.join("save_file.response");
|
||||
let _ = std::fs::remove_file(&sresp);
|
||||
let handle = app_handle.clone();
|
||||
let sresp_for_main = sresp.clone();
|
||||
let handle_for_closure = handle.clone();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let saved: Option<FilePath> = handle_for_closure
|
||||
.dialog()
|
||||
.file()
|
||||
.set_file_name(default_name.trim())
|
||||
.blocking_save_file();
|
||||
let val = saved
|
||||
.map(|p| match p {
|
||||
FilePath::Path(pb) => pb.to_string_lossy().to_string(),
|
||||
FilePath::Url(u) => u.to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = std::fs::write(&sresp_for_main, val);
|
||||
});
|
||||
let mut s_waited_ms = 0u32;
|
||||
while !sresp.exists() && s_waited_ms < 300_000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
s_waited_ms += 100;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
}
|
||||
});
|
||||
@@ -687,6 +721,16 @@ pub fn run() {
|
||||
// phu/an giu event loop song, Tauri khong tu exit; exit(0)
|
||||
// ket thuc dut diem (pump da dung, khong con respawn bridge).
|
||||
if window.label() == "main" {
|
||||
// Xoa marker bridge khi app tat han — neu de lai, lan sau
|
||||
// chay engine STANDALONE se nham tuong co bridge va cho
|
||||
// response khong bao gio den (treo 120s truoc fallback).
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
|
||||
let _ = std::fs::remove_file(
|
||||
std::path::Path::new(&appdata)
|
||||
.join("SonicForgeDAW")
|
||||
.join("ipc")
|
||||
.join("tauri_bridge_ready"),
|
||||
);
|
||||
// Orphan sweep: bat CA dau engine con song theo image name —
|
||||
// engine spawn thu cong (khong co SF_PARENT_PID), engine cua
|
||||
// lan chay truoc bi crash, hoac engine song sot khi app chet
|
||||
|
||||
Reference in New Issue
Block a user