renderizando projetos com sample tracks. basslines ok
Deploy / Deploy (push) Successful in 2m17s
Details
Deploy / Deploy (push) Successful in 2m17s
Details
This commit is contained in:
parent
c90852441c
commit
86900c9a12
|
|
@ -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) {
|
function downloadBlob(blob, fileName) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|
@ -1085,6 +1119,10 @@ async function _decodeAudioBufferCached(url) {
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _isProbablyWavUrl(url) {
|
||||||
|
return /\.wav(\?|#|$)/i.test(String(url || ""));
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- XML: SampleTracks + zip --------------------
|
// -------------------- XML: SampleTracks + zip --------------------
|
||||||
|
|
||||||
async function applySampleTracksToXmlAndZip(xmlDoc, zip) {
|
async function applySampleTracksToXmlAndZip(xmlDoc, zip) {
|
||||||
|
|
@ -1179,17 +1217,17 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) {
|
||||||
// ✅ OTIMIZAÇÃO 2: sem offset + sem bake mix
|
// ✅ OTIMIZAÇÃO 2: sem offset + sem bake mix
|
||||||
// -> usa arquivo original no zip (sem decode/encode)
|
// -> usa arquivo original no zip (sem decode/encode)
|
||||||
// -----------------------------------------
|
// -----------------------------------------
|
||||||
const needsOffset = Math.abs(offsetSec) > 1e-6;
|
const isWav = _isProbablyWavUrl(srcUrl);
|
||||||
const needsBake = Math.abs((vol ?? 1) - 1) > 1e-6 || Math.abs((pan ?? 0) - 0) > 1e-6;
|
|
||||||
|
|
||||||
if (!needsOffset && !needsBake) {
|
if (!needsOffset && !needsBake && isWav) {
|
||||||
const raw = await _fetchArrayBufferCached(srcUrl);
|
const raw = await _fetchArrayBufferCached(srcUrl);
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
`${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` +
|
`${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` +
|
||||||
`__raw__${_sanitizeFileName(clip.id || String(cidx))}.wav`;
|
`__raw__${_sanitizeFileName(clip.id || String(cidx))}.wav`;
|
||||||
|
|
||||||
const zipPath = `samples/${name}`;
|
const zipPath = `samples/${name}`;
|
||||||
|
|
||||||
// evita regravar o mesmo raw várias vezes
|
|
||||||
if (!zip.file(zipPath)) zip.file(zipPath, raw);
|
if (!zip.file(zipPath)) zip.file(zipPath, raw);
|
||||||
|
|
||||||
const tco = xmlDoc.createElement("sampletco");
|
const tco = xmlDoc.createElement("sampletco");
|
||||||
|
|
@ -1201,6 +1239,8 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// se NÃO for wav, cai no “bake” (decode+encode wav) abaixo ✅
|
||||||
|
|
||||||
// -----------------------------------------
|
// -----------------------------------------
|
||||||
// ✅ OTIMIZAÇÃO 3: cache de slice idêntico
|
// ✅ OTIMIZAÇÃO 3: cache de slice idêntico
|
||||||
// -----------------------------------------
|
// -----------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
restartAudioEditorIfPlaying,
|
restartAudioEditorIfPlaying,
|
||||||
} from "./audio/audio_audio.js";
|
} from "./audio/audio_audio.js";
|
||||||
import { initializeAudioContext } from "./audio.js";
|
import { initializeAudioContext } from "./audio.js";
|
||||||
import { handleFileLoad, generateMmpFile, generateXmlFromStateExported } from "./file.js";
|
import { handleFileLoad, generateMmpFile, generateXmlFromStateExported, buildRenderPackageBase64 } from "./file.js";
|
||||||
import {
|
import {
|
||||||
renderAll,
|
renderAll,
|
||||||
loadAndRenderSampleBrowser,
|
loadAndRenderSampleBrowser,
|
||||||
|
|
@ -156,6 +156,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
format: chosenFormat, // ✅ sem shorthand
|
format: chosenFormat, // ✅ sem shorthand
|
||||||
name: appState.global?.currentBeatBasslineName || appState.global?.projectName || "projeto",
|
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)
|
// ✅ Modo Local: manda XML direto (senão dá missing_xml_or_room)
|
||||||
if (!roomName) {
|
if (!roomName) {
|
||||||
|
|
|
||||||
|
|
@ -633,7 +633,7 @@ io.on("connection", (socket) => {
|
||||||
|
|
||||||
// Conversão de projeto em áudio no mmpCreator
|
// 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) => {
|
app.use((req, res, next) => {
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
|
|
@ -681,9 +681,47 @@ function sanitizeFileName(name) {
|
||||||
.slice(0, 120) || "projeto";
|
.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) => {
|
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 out = "";
|
||||||
let err = "";
|
let err = "";
|
||||||
|
|
@ -692,7 +730,7 @@ function run(cmd, args, { timeoutMs } = {}) {
|
||||||
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
try { p.kill("SIGKILL"); } catch {}
|
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);
|
}, timeoutMs || LMMS_TIMEOUT_MS);
|
||||||
|
|
||||||
p.on("error", (e) => {
|
p.on("error", (e) => {
|
||||||
|
|
@ -703,7 +741,7 @@ function run(cmd, args, { timeoutMs } = {}) {
|
||||||
p.on("close", (code) => {
|
p.on("close", (code) => {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
if (code === 0) return resolve({ out, err });
|
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();
|
const renderLocks = new Map();
|
||||||
|
|
||||||
app.post("/render", async (req, res) => {
|
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 ext = String(format || "wav").toLowerCase();
|
||||||
const allowed = new Set(["wav", "ogg", "flac", "mp3"]);
|
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] });
|
return res.status(400).json({ ok: false, error: "invalid_format", allowed: [...allowed] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// prioridade: sala -> pega do estado autoritativo
|
// trava sala (se aplicável)
|
||||||
let projectXml = null;
|
if (roomName && renderLocks.get(roomName)) {
|
||||||
|
return res.status(429).json({ ok: false, error: "render_in_progress" });
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lmms-render-"));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lmms-render-"));
|
||||||
const inputPath = path.join(tmpDir, "project.mmp");
|
|
||||||
const outputPath = path.join(tmpDir, `render.${ext}`);
|
const outputPath = path.join(tmpDir, `render.${ext}`);
|
||||||
|
|
||||||
if (roomName) renderLocks.set(roomName, true);
|
if (roomName) renderLocks.set(roomName, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(inputPath, projectXml, "utf8");
|
let inputMmpPath = null;
|
||||||
|
let cwdForLmms = tmpDir;
|
||||||
|
|
||||||
const { cmd, args } = buildLmmsCommand(inputPath, outputPath, ext);
|
// ✅ caminho novo: veio pacote (mmpz/zip) com samples/
|
||||||
await run(cmd, args, { timeoutMs: LMMS_TIMEOUT_MS });
|
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 fileBase = sanitizeFileName(name || roomName || "projeto");
|
||||||
const downloadName = `${fileBase}.${ext}`;
|
const downloadName = `${fileBase}.${ext}`;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue