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:
+125
-55
@@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user