diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 33e23ff4..7204640f 100755 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -912,6 +912,40 @@ async function generateAndDownloadMmpz(baseName) { } +export async function buildRenderPackageBlob(baseName = "projeto") { + initializeAudioContext(); + + const baseXmlString = generateXmlFromState(); // patterns + const xmlDoc = new DOMParser().parseFromString(baseXmlString, "application/xml"); + + const zip = new JSZip(); + await applySampleTracksToXmlAndZip(xmlDoc, zip); // ✅ coloca sampletco + samples/*.wav + + const finalXml = new XMLSerializer().serializeToString(xmlDoc); + zip.file(`${baseName}.mmp`, finalXml); + + const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" }); + + // pode ser .mmpz (LMMS costuma aceitar) ou .zip (você já usa) + return { blob, fileName: `${baseName}.mmpz` }; +} + +export async function buildRenderPackageBase64(baseName = "projeto") { + const { blob, fileName } = await buildRenderPackageBlob(baseName); + const base64 = await blobToBase64(blob); + return { mmpzBase64: base64, mmpzName: fileName }; +} + + +function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result).split(",")[1]); // remove data:... + r.onerror = reject; + r.readAsDataURL(blob); + }); +} + function downloadBlob(blob, fileName) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -1085,6 +1119,10 @@ async function _decodeAudioBufferCached(url) { return decoded; } +function _isProbablyWavUrl(url) { + return /\.wav(\?|#|$)/i.test(String(url || "")); +} + // -------------------- XML: SampleTracks + zip -------------------- async function applySampleTracksToXmlAndZip(xmlDoc, zip) { @@ -1179,17 +1217,17 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { // ✅ OTIMIZAÇÃO 2: sem offset + sem bake mix // -> usa arquivo original no zip (sem decode/encode) // ----------------------------------------- - const needsOffset = Math.abs(offsetSec) > 1e-6; - const needsBake = Math.abs((vol ?? 1) - 1) > 1e-6 || Math.abs((pan ?? 0) - 0) > 1e-6; + const isWav = _isProbablyWavUrl(srcUrl); - if (!needsOffset && !needsBake) { + if (!needsOffset && !needsBake && isWav) { const raw = await _fetchArrayBufferCached(srcUrl); + const name = `${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` + `__raw__${_sanitizeFileName(clip.id || String(cidx))}.wav`; + const zipPath = `samples/${name}`; - // evita regravar o mesmo raw várias vezes if (!zip.file(zipPath)) zip.file(zipPath, raw); const tco = xmlDoc.createElement("sampletco"); @@ -1201,6 +1239,8 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { continue; } + // se NÃO for wav, cai no “bake” (decode+encode wav) abaixo ✅ + // ----------------------------------------- // ✅ OTIMIZAÇÃO 3: cache de slice idêntico // ----------------------------------------- diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index bcdb2fe8..59f965f8 100755 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -5,7 +5,7 @@ import { restartAudioEditorIfPlaying, } from "./audio/audio_audio.js"; import { initializeAudioContext } from "./audio.js"; -import { handleFileLoad, generateMmpFile, generateXmlFromStateExported } from "./file.js"; +import { handleFileLoad, generateMmpFile, generateXmlFromStateExported, buildRenderPackageBase64 } from "./file.js"; import { renderAll, loadAndRenderSampleBrowser, @@ -156,6 +156,9 @@ document.addEventListener("DOMContentLoaded", () => { format: chosenFormat, // ✅ sem shorthand name: appState.global?.currentBeatBasslineName || appState.global?.projectName || "projeto", }; + const pkg = await buildRenderPackageBase64(body.name); + body.mmpzBase64 = pkg.mmpzBase64; + body.mmpzName = pkg.mmpzName; // ✅ Modo Local: manda XML direto (senão dá missing_xml_or_room) if (!roomName) { diff --git a/assets/js/creations/server/server.js b/assets/js/creations/server/server.js index 3f23e659..275c98a3 100755 --- a/assets/js/creations/server/server.js +++ b/assets/js/creations/server/server.js @@ -633,7 +633,7 @@ io.on("connection", (socket) => { // Conversão de projeto em áudio no mmpCreator -app.use(express.json({ limit: "25mb" })); +app.use(express.json({ limit: "250mb" })); app.use((req, res, next) => { const origin = req.headers.origin; @@ -681,9 +681,47 @@ function sanitizeFileName(name) { .slice(0, 120) || "projeto"; } -function run(cmd, args, { timeoutMs } = {}) { +function isSafeZipEntry(name) { + const s = String(name || "").replace(/\\/g, "/"); + if (!s) return false; + if (s.startsWith("/") || /^[a-zA-Z]:\//.test(s)) return false; // absoluto + if (s.includes("..")) return false; // zip slip básico + return true; +} + +async function safeUnzip(zipPath, destDir) { + // lista entradas + const list = await run("unzip", ["-Z1", zipPath], { timeoutMs: 60_000 }); + const entries = String(list.out || "").split("\n").map(x => x.trim()).filter(Boolean); + + if (!entries.length) throw new Error("zip sem entradas (ou unzip falhou ao listar)"); + + for (const e of entries) { + if (!isSafeZipEntry(e)) throw new Error(`zip contém caminho inseguro: ${e}`); + } + + // extrai + await run("unzip", ["-q", zipPath, "-d", destDir], { timeoutMs: 120_000 }); +} + +async function findFirstMmp(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + const found = await findFirstMmp(p); + if (found) return found; + } else if (e.isFile() && e.name.toLowerCase().endsWith(".mmp")) { + return p; + } + } + return null; +} + + +function run(cmd, args, { timeoutMs, cwd } = {}) { return new Promise((resolve, reject) => { - const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd }); let out = ""; let err = ""; @@ -692,7 +730,7 @@ function run(cmd, args, { timeoutMs } = {}) { const t = setTimeout(() => { try { p.kill("SIGKILL"); } catch {} - reject(new Error(`LMMS timeout (${timeoutMs}ms)\n${err || out}`)); + reject(new Error(`${cmd} timeout (${timeoutMs}ms)\n${err || out}`)); }, timeoutMs || LMMS_TIMEOUT_MS); p.on("error", (e) => { @@ -703,7 +741,7 @@ function run(cmd, args, { timeoutMs } = {}) { p.on("close", (code) => { clearTimeout(t); if (code === 0) return resolve({ out, err }); - reject(new Error(`LMMS exit code=${code}\n${err || out}`)); + reject(new Error(`${cmd} exit code=${code}\n${err || out}`)); }); }); } @@ -712,7 +750,7 @@ function run(cmd, args, { timeoutMs } = {}) { const renderLocks = new Map(); app.post("/render", async (req, res) => { - const { roomName, xml, format, name } = req.body || {}; + const { roomName, xml, format, name, mmpzBase64, mmpzName } = req.body || {}; const ext = String(format || "wav").toLowerCase(); const allowed = new Set(["wav", "ogg", "flac", "mp3"]); @@ -720,36 +758,53 @@ app.post("/render", async (req, res) => { return res.status(400).json({ ok: false, error: "invalid_format", allowed: [...allowed] }); } - // prioridade: sala -> pega do estado autoritativo - let projectXml = null; - - if (xml && String(xml).trim().length > 0) { - projectXml = xml; - } else if (roomName) { - if (renderLocks.get(roomName)) { - return res.status(429).json({ ok: false, error: "render_in_progress" }); - } - projectXml = ensureRoom(roomName)?.projectXml || null; - } - - // fallback: se mandou xml direto - if (!projectXml) projectXml = xml; - - if (!projectXml || String(projectXml).trim().length === 0) { - return res.status(400).json({ ok: false, error: "missing_xml_or_room" }); + // trava sala (se aplicável) + if (roomName && renderLocks.get(roomName)) { + return res.status(429).json({ ok: false, error: "render_in_progress" }); } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lmms-render-")); - const inputPath = path.join(tmpDir, "project.mmp"); const outputPath = path.join(tmpDir, `render.${ext}`); if (roomName) renderLocks.set(roomName, true); try { - fs.writeFileSync(inputPath, projectXml, "utf8"); + let inputMmpPath = null; + let cwdForLmms = tmpDir; - const { cmd, args } = buildLmmsCommand(inputPath, outputPath, ext); - await run(cmd, args, { timeoutMs: LMMS_TIMEOUT_MS }); + // ✅ caminho novo: veio pacote (mmpz/zip) com samples/ + if (mmpzBase64 && String(mmpzBase64).trim().length > 0) { + const safeName = sanitizeFileName(mmpzName || "project.mmpz"); + const zipPath = path.join(tmpDir, safeName); + fs.writeFileSync(zipPath, Buffer.from(mmpzBase64, "base64")); + + await safeUnzip(zipPath, tmpDir); + + inputMmpPath = await findFirstMmp(tmpDir); + if (!inputMmpPath) throw new Error("nenhum .mmp encontrado após extrair o pacote"); + + cwdForLmms = path.dirname(inputMmpPath); // 🔥 isso faz o LMMS resolver samples/... + } else { + // fallback antigo: xml puro (patterns ok, samples não) + let projectXml = null; + + if (xml && String(xml).trim().length > 0) { + projectXml = xml; + } else if (roomName) { + projectXml = ensureRoom(roomName)?.projectXml || null; + } + + if (!projectXml || String(projectXml).trim().length === 0) { + return res.status(400).json({ ok: false, error: "missing_xml_or_room" }); + } + + inputMmpPath = path.join(tmpDir, "project.mmp"); + fs.writeFileSync(inputMmpPath, projectXml, "utf8"); + cwdForLmms = tmpDir; + } + + const { cmd, args } = buildLmmsCommand(inputMmpPath, outputPath, ext); + await run(cmd, args, { timeoutMs: LMMS_TIMEOUT_MS, cwd: cwdForLmms }); const fileBase = sanitizeFileName(name || roomName || "projeto"); const downloadName = `${fileBase}.${ext}`;