cc8b286f6c
1) Folder picker dung WINDOW EXPLORER (khong prompt nhap tay):
- src-tauri/src/lib.rs: IPC bridge — thread watcher ipc/pick_dir.request
-> run_on_main_thread -> dialog().file().blocking_pick_folder()
(tauri-plugin-dialog = IFileDialog/Explorer) -> ghi pick_dir.response;
ghi marker tauri_bridge_ready luc setup.
- app/api/v1/plugins.py: POST /pick-dir (def sync -> threadpool, khong
auth) — uu tien Tauri bridge, fallback PowerShell FolderBrowserDialog
(Win) / osascript (macOS) / zenity-kdialog (Linux).
- app.jsx pickPluginFolder: 1) pickPluginDir (native) -> 2) __TAURI__
invoke -> 3) in-app browser -> 4) prompt (cuoi cung).
2) Nut Synth liet ke VSTi da scan (truoc day rong):
- Root: list_available() chi quet settings.VST_DIR (mac dinh
/opt/daw_engine/vst3) trong khi Plugin Manager scan plugin_dirs user.
- plugins.py list_plugins: gop _scan_vst_in_dirs(plugin_dirs) (file
.vst3/.dll/.so + folder X.vst3 Windows).
- vst_engine.py: PluginManager.extra_vst_dirs + _scan_plugins quet them
(ca folder .vst3) + get_plugin_manager doc plugin_dirs.json -> load_vst
tim thay plugin user scan khi render.
3) VU meter leak: section-tab play -> sang MAIN SESSION -> track MAIN van
animate theo am section.
- Root: VU tick fallback _sub_ (sub-node section co analyser) chay cho
ca canvas MAIN; startSubTabPlayback ghi node o key track MAIN.
- Fix: fallback _sub_ chi ap dung cho canvas SECTION (isSessVu); 2 trigger
piano-roll them prefix _sess_ theo st.parent_tab_id.
Verify: 86 tests pass; engine frozen STARTUP 1.35s, 1 engine, 0 spawn '-c';
pick-dir IPC mock tra dung path; scan VST user dirs OK.
449 lines
19 KiB
Python
449 lines
19 KiB
Python
import os, sys, uuid, json, tempfile, subprocess, time as _time
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Any
|
|
from app.config import settings
|
|
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
|
|
from app.core.render_engine import PythonRenderEngine
|
|
from app.core.soundfont_inspector import SoundFontInspector
|
|
from app.core.soundfont_converter import SoundFontConverter
|
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
|
|
|
router = APIRouter()
|
|
|
|
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
|
|
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
|
|
|
|
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
|
|
SYSTEM_VST_DIR = settings.VST_DIR
|
|
|
|
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
|
|
# Plugins Manager). File global (không per-user): desktop app 1 user.
|
|
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
|
|
|
|
def _load_plugin_dirs() -> dict:
|
|
if os.path.exists(PLUGIN_DIRS_FILE):
|
|
try:
|
|
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _save_plugin_dirs(dirs: dict):
|
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
|
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(dirs, f, indent=2)
|
|
|
|
def _effective_dirs() -> dict:
|
|
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
|
|
|
|
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
|
|
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
|
|
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
|
|
"""
|
|
user = _load_plugin_dirs()
|
|
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
|
|
vst_dir = settings.VST_DIR
|
|
soundfont_dir = settings.SOUNDFONT_DIR
|
|
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
|
|
if not plugin_dirs:
|
|
if user.get("vst_dir"):
|
|
plugin_dirs.append(user["vst_dir"])
|
|
if user.get("soundfont_dir"):
|
|
plugin_dirs.append(user["soundfont_dir"])
|
|
if not plugin_dirs:
|
|
plugin_dirs = [vst_dir, soundfont_dir]
|
|
return {
|
|
"plugin_dirs": plugin_dirs,
|
|
"vst_dir": vst_dir,
|
|
"soundfont_dir": soundfont_dir,
|
|
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
|
|
}
|
|
|
|
class DirsRequest(BaseModel):
|
|
vst_dir: Optional[str] = None
|
|
soundfont_dir: Optional[str] = None
|
|
plugin_dirs: Optional[list] = None
|
|
|
|
@router.get("/dirs")
|
|
async def get_plugin_dirs():
|
|
return {"success": True, **(_effective_dirs())}
|
|
|
|
@router.post("/dirs")
|
|
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
|
|
enforce_password_changed(current_user)
|
|
user = _load_plugin_dirs()
|
|
if req.plugin_dirs is not None:
|
|
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
|
|
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
|
|
user.pop("vst_dir", None)
|
|
user.pop("soundfont_dir", None)
|
|
else:
|
|
if req.vst_dir is not None:
|
|
user["vst_dir"] = req.vst_dir.strip()
|
|
if req.soundfont_dir is not None:
|
|
user["soundfont_dir"] = req.soundfont_dir.strip()
|
|
_save_plugin_dirs(user)
|
|
return {"success": True, **(_effective_dirs())}
|
|
|
|
@router.post("/scan")
|
|
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
|
|
current_user: dict = Depends(get_current_user)):
|
|
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
|
|
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
|
|
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
|
|
enforce_password_changed(current_user)
|
|
dirs = _effective_dirs()
|
|
plugin_dirs = dirs["plugin_dirs"]
|
|
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
|
|
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
|
|
if background_tasks:
|
|
background_tasks.add_task(scanner.scan_once)
|
|
else:
|
|
scanner.scan_once()
|
|
catalog = scanner.get_catalog()
|
|
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
|
|
vst_found = []
|
|
sf_found = []
|
|
for d in plugin_dirs:
|
|
if not os.path.isdir(d):
|
|
continue
|
|
for root, _dirs, files in os.walk(d):
|
|
for f in files:
|
|
low = f.lower()
|
|
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
|
|
vst_found.append({"name": os.path.splitext(f)[0],
|
|
"path": os.path.join(root, f),
|
|
"dir": d,
|
|
"type": "VST3" if low.endswith(".vst3") else "VST2"})
|
|
elif low.endswith(".sf2") or low.endswith(".sf3"):
|
|
sf_found.append({"name": os.path.splitext(f)[0],
|
|
"path": os.path.join(root, f),
|
|
"dir": d})
|
|
return {
|
|
"success": True,
|
|
"plugin_dirs": plugin_dirs,
|
|
"vst_found": vst_found,
|
|
"vst_count": len(vst_found),
|
|
"soundfonts": sf_found,
|
|
"soundfont_count": len(sf_found),
|
|
}
|
|
|
|
_inspector = None
|
|
_scanner = None
|
|
|
|
def get_inspector():
|
|
global _inspector
|
|
if _inspector is None:
|
|
d = _effective_dirs()
|
|
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
|
|
upload_sf_dir=UPLOAD_SF_DIR)
|
|
return _inspector
|
|
|
|
def get_scanner():
|
|
global _scanner
|
|
if _scanner is None:
|
|
d = _effective_dirs()
|
|
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
|
|
_scanner.scan_once()
|
|
return _scanner
|
|
|
|
|
|
@router.get("/available")
|
|
async def list_plugins(current_user: dict = Depends(get_current_user)):
|
|
d = _effective_dirs()
|
|
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
|
|
avail = pm.list_available()
|
|
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
|
|
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
|
|
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
|
|
extra = _scan_vst_in_dirs(d["plugin_dirs"])
|
|
by_id = {v["id"]: v for v in avail["vst_instruments"]}
|
|
for v in extra:
|
|
by_id.setdefault(v["id"], v)
|
|
avail["vst_instruments"] = list(by_id.values())
|
|
return avail
|
|
|
|
|
|
def _scan_vst_in_dirs(dirs: list) -> list:
|
|
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
|
|
file .vst3/.dll/.so (VST3 folder Windows = .vst3.dll bên trong)."""
|
|
found = {}
|
|
for d in dirs:
|
|
if not d or not os.path.isdir(d):
|
|
continue
|
|
for root, _dirs, files in os.walk(d):
|
|
for f in files:
|
|
low = f.lower()
|
|
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
|
|
name = os.path.splitext(f)[0]
|
|
if name not in found:
|
|
found[name] = {
|
|
"id": name, "name": name,
|
|
"type": "VST3" if low.endswith(".vst3") else "VST2",
|
|
"path": os.path.join(root, f),
|
|
}
|
|
return list(found.values())
|
|
|
|
|
|
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
|
|
# nhập tay) ─────────────────────────────────────────────────────────────
|
|
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
|
|
|
|
|
|
def _pick_dir_via_tauri_bridge() -> Optional[str]:
|
|
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
|
|
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
|
|
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy 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
|
|
# 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
|
|
req = os.path.join(ipc, "pick_dir.request")
|
|
resp = os.path.join(ipc, "pick_dir.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("1")
|
|
deadline = _time.time() + PICK_DIR_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 _pick_dir_native_engine() -> Optional[str]:
|
|
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
|
|
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
|
|
if os.name == "nt":
|
|
ps = (
|
|
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
|
|
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
|
|
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
|
|
)
|
|
try:
|
|
r = subprocess.run(
|
|
["powershell", "-NoProfile", "-STA", "-Command", ps],
|
|
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
|
|
)
|
|
return r.stdout.strip() or None
|
|
except Exception:
|
|
return None
|
|
if sys.platform == "darwin":
|
|
try:
|
|
r = subprocess.run(
|
|
["osascript", "-e",
|
|
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
|
|
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
|
|
)
|
|
return r.stdout.strip() or None
|
|
except Exception:
|
|
return None
|
|
for cmd in (["zenity", "--file-selection", "--directory"],
|
|
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
|
|
if r.returncode == 0:
|
|
p = r.stdout.strip()
|
|
if p:
|
|
return p
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
@router.post("/pick-dir")
|
|
def pick_plugin_directory():
|
|
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
|
|
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
|
|
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
|
|
trong lúc user chọn thư mục (có thể mất phút)."""
|
|
path = _pick_dir_via_tauri_bridge()
|
|
if path is None:
|
|
path = _pick_dir_native_engine()
|
|
return {"path": path}
|
|
|
|
|
|
@router.get("/default-soundfonts")
|
|
async def list_default_soundfonts():
|
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
|
result = []
|
|
if os.path.isdir(static_sf_dir):
|
|
for f in os.listdir(static_sf_dir):
|
|
if f.endswith(".sf2") or f.endswith(".sf3"):
|
|
result.append({
|
|
"id": os.path.splitext(f)[0],
|
|
"name": f,
|
|
"file": f,
|
|
"url": f"/soundfonts/{f}"
|
|
})
|
|
return result
|
|
|
|
|
|
@router.get("/soundfonts/catalog")
|
|
async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
|
|
scanner = get_scanner()
|
|
full_catalog = scanner.get_catalog()
|
|
inspector = get_inspector()
|
|
condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
|
|
return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}
|
|
|
|
|
|
@router.get("/soundfont-instruments/{sf_id}")
|
|
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
|
|
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
|
|
presets = pm.list_soundfont_instruments(sf_id)
|
|
return {"presets": presets, "count": len(presets)}
|
|
|
|
|
|
@router.post("/upload-soundfont")
|
|
async def upload_soundfont(
|
|
file: UploadFile = File(...),
|
|
background_tasks: BackgroundTasks = None,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
enforce_password_changed(current_user)
|
|
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
|
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
|
|
|
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
|
# 500MB; reading the whole body into RAM would OOM the server).
|
|
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
|
contents = bytearray()
|
|
while True:
|
|
chunk = await file.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
contents.extend(chunk)
|
|
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
|
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
|
|
|
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
|
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
|
|
|
file_ext = os.path.splitext(file.filename)[1]
|
|
file_uuid = str(uuid.uuid4())
|
|
# Store original name in a sidecar file
|
|
base_name = os.path.splitext(file.filename)[0].replace('/', '_').replace('\\', '_')
|
|
file_id = file_uuid + file_ext
|
|
dest_path = os.path.join(UPLOAD_SF_DIR, file_id)
|
|
with open(dest_path, "wb") as f:
|
|
f.write(contents)
|
|
|
|
# Save metadata with original name
|
|
meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
|
|
with open(meta_path, "w", encoding="utf-8") as f:
|
|
import json
|
|
json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)
|
|
|
|
scanner = get_scanner()
|
|
if background_tasks:
|
|
background_tasks.add_task(scanner.scan_once)
|
|
else:
|
|
scanner.scan_once()
|
|
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
|
|
|
|
|
|
@router.delete("/soundfont/{sf_id}")
|
|
async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_user)):
|
|
base_id = sf_id.replace("sf_", "")
|
|
deleted = False
|
|
for d in [UPLOAD_SF_DIR, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts"), SYSTEM_SF_DIR]:
|
|
if not os.path.isdir(d):
|
|
continue
|
|
for f in os.listdir(d):
|
|
if os.path.splitext(f)[0] == base_id:
|
|
# Skip system dir — only allow deleting uploads
|
|
if d == SYSTEM_SF_DIR:
|
|
raise HTTPException(status_code=403, detail="System soundfonts cannot be deleted via this endpoint")
|
|
path = os.path.join(d, f)
|
|
os.remove(path)
|
|
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
|
|
if os.path.isfile(meta_path):
|
|
os.remove(meta_path)
|
|
deleted = True
|
|
break
|
|
if deleted:
|
|
break
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="SoundFont not found")
|
|
get_scanner().scan_once()
|
|
return {"deleted": True, "sf_id": sf_id}
|
|
|
|
|
|
@router.get("/soundfonts/download/{sf_id}")
|
|
async def download_soundfont_asset(sf_id: str):
|
|
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
|
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
|
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
|
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
|
if not os.path.isdir(base_dir):
|
|
continue
|
|
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
|
# samples, so any SF3 would play silence in the browser.
|
|
for fname in os.listdir(base_dir):
|
|
fbase, fext = os.path.splitext(fname)
|
|
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
|
|
full = os.path.join(base_dir, fname)
|
|
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
|
|
# Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
|
|
for fname in os.listdir(base_dir):
|
|
fbase, fext = os.path.splitext(fname)
|
|
if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
|
|
full = os.path.join(base_dir, fname)
|
|
try:
|
|
from app.core.soundfont_converter import SoundFontConverter
|
|
sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
|
|
if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
|
|
return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
|
|
result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
|
|
if result != full and os.path.exists(result):
|
|
return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
|
|
except Exception as e:
|
|
print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
|
|
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
|
|
raise HTTPException(status_code=404, detail="SoundFont asset not found")
|
|
|
|
|
|
class RenderRequest(BaseModel):
|
|
project_json: dict
|
|
output_filename: Optional[str] = "render_output.wav"
|
|
|
|
|
|
@router.post("/render")
|
|
async def render_project(
|
|
req: RenderRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
enforce_password_changed(current_user)
|
|
engine = PythonRenderEngine()
|
|
# Prevent path traversal: strip any directory components and force .wav.
|
|
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
|
if not safe_name.lower().endswith(".wav"):
|
|
safe_name += ".wav"
|
|
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
|
try:
|
|
result_path = engine.render_project(req.project_json, output_path)
|
|
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|