MIDI upload flow: save to workspace, action picker (재생/편집/악보)

- music-renderer: saveMidiFile export (JSON, no LilyPond), getRenderFile prefers original.midi, fix self-copy bug
- server-v2: POST /api/music/midi now saves to workspace/music/<name>/ and returns JSON immediately; new SSE endpoint /api/music/midi-score for on-demand LilyPond
- index.html: buildMidiPickerWidget with color-coded buttons (green/purple/amber), MIDI download moved inside player tab, score panel default closed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 17:29:49 +09:00
co-authored by Claude Sonnet 4.6
parent 1bfadd6060
commit 00d508cc77
3 changed files with 396 additions and 71 deletions
+125 -55
View File
@@ -2324,8 +2324,19 @@
cursor: pointer;
text-decoration: none;
font-family: inherit;
display: inline-flex;
align-items: center;
gap: 5px;
}
.music-dl-btn:hover { background: var(--hover); }
.music-dl-btn.btn-green { background:#0d2b18; border-color:#2ea043; color:#3fb950; }
.music-dl-btn.btn-green:hover { background:#163d22; }
.music-dl-btn.btn-purple { background:#1e1040; border-color:#7c3aed; color:#a78bfa; }
.music-dl-btn.btn-purple:hover { background:#281552; }
.music-dl-btn.btn-amber { background:#2d1d04; border-color:#d97706; color:#fbbf24; }
.music-dl-btn.btn-amber:hover { background:#3d2806; }
.music-dl-btn.btn-blue { background:#071d3a; border-color:#2563eb; color:#60a5fa; }
.music-dl-btn.btn-blue:hover { background:#0a2848; }
.music-page-nav {
display: flex;
align-items: center;
@@ -9483,8 +9494,8 @@ function buildMusicActions(data, downloadName) {
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">🎛 외부 신디</button>';
html += '<button class="music-dl-btn" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">✏ 편집</button>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
}
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
@@ -9563,75 +9574,134 @@ async function renderMusicBlock(widgetId, source) {
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-render-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">' +
'🎵 <strong>' + escHtml(file.name) + '</strong> 악보 변환 중...' +
'<br><span id="' + msgId + '-prog" style="font-size:11px;opacity:0.7">준비 중...</span>' +
'</div>'
);
// ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
function buildMidiPickerWidget(data, filename) {
var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
var midiUrl = data.midi_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var savedLine = data.saved_to
? '<div style="font-size:11px;color:var(--muted);margin-bottom:8px">💾 ' + escHtml(data.saved_to) + '</div>'
: '';
// MIDI player panel (hidden by default) — includes MIDI download
var playerPanel =
'<div id="' + rid + '" class="midi-router-panel" style="display:none;margin-top:8px">' +
'<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>' +
'<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>' +
'<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>' +
'<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(filename) + '" style="padding:2px 8px;font-size:11px">⬇ MIDI</a>' +
'<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' +
'</div>';
// Score area — always hidden by default; content pre-filled if already rendered
var scoreInner = (data.has_svg && data.svg_url)
? buildSvgPagesHtml(data) + buildMusicActions(data, filename)
: '';
var scoreArea = '<div id="' + wid + '-score" style="margin-top:8px;display:none">' + scoreInner + '</div>';
var scoreBtnLabel = '🎼 악보';
var html =
'<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(filename) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body"><div class="music-output">' +
savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' +
playerPanel +
scoreArea +
'</div></div>' +
'</div>';
return { wid, html };
}
async function midiPickerScore(wid, midiId, filename) {
var scoreDiv = document.getElementById(wid + '-score');
var btn = document.getElementById(wid + '-score-btn');
if (!scoreDiv) return;
// If score already rendered, toggle visibility
if (scoreDiv.style.display !== 'none' && scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = 'none';
if (btn) btn.textContent = '📄 악보';
return;
}
if (scoreDiv.innerHTML.trim()) {
scoreDiv.style.display = '';
if (btn) btn.textContent = '📄 닫기';
return;
}
// Show progress
scoreDiv.style.display = '';
scoreDiv.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:8px 0">📄 악보 변환 중... <span id="' + wid + '-sprog"></span></div>';
if (btn) btn.disabled = true;
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
var resp = await fetch('/api/music/midi-score', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ id: midiId }),
});
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buf = '';
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split('\n\n');
buf = parts.pop() || '';
var parts = buf.split('\n\n'); buf = parts.pop() || '';
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (!part.startsWith('data: ')) continue;
var ev;
try { ev = JSON.parse(part.slice(6)); } catch (e) { continue; }
if (!parts[i].startsWith('data: ')) continue;
var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
if (ev.type === 'progress') {
var progEl = document.getElementById(msgId + '-prog');
if (progEl) progEl.textContent = ev.message;
var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
} else if (ev.type === 'done') {
var wid = msgId + '-widget';
var html = '<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(file.name) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<div class="music-output">';
if (ev.svg_url) html += buildSvgPagesHtml(ev);
html += buildMusicActions(ev, file.name);
html += '</div></div></div>';
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = html;
// Persist to chatHistory so it survives page reload
chatHistory.push({ role: 'widget', content: html });
persistActiveChat();
scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
} else if (ev.type === 'error') {
var pl2 = document.getElementById(msgId);
if (pl2) pl2.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(ev.message) + '</span>';
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
}
} catch (e) {
var pl3 = document.getElementById(msgId);
if (pl3) pl3.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(e.message) + '</span>';
scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-upload-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">🎵 <strong>' + escHtml(file.name) + '</strong> 업로드 중...</div>'
);
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || '서버 오류 ' + resp.status);
var result = buildMidiPickerWidget(data, file.name);
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = result.html;
chatHistory.push({ role: 'widget', content: result.html });
persistActiveChat();
} catch (e) {
var pl = document.getElementById(msgId);
if (pl) pl.outerHTML = '<span style="color:var(--err);font-size:12px">업로드 실패: ' + escHtml(e.message) + '</span>';
}
}