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
+28 -2
View File
@@ -377,8 +377,8 @@ export function renderMidiAsync(
const originalMidi = path.join(workDir, 'original.midi'); const originalMidi = path.join(workDir, 'original.midi');
const midiCopy = path.join(workDir, 'output.midi'); const midiCopy = path.join(workDir, 'output.midi');
try { try {
fs.copyFileSync(midiFilePath, originalMidi); if (path.resolve(originalMidi) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, originalMidi);
fs.copyFileSync(midiFilePath, midiCopy); if (path.resolve(midiCopy) !== path.resolve(midiFilePath)) fs.copyFileSync(midiFilePath, midiCopy);
} catch (e: any) { onError(e); return; } } catch (e: any) { onError(e); return; }
// Step 1: midi2ly (sync, fast — typically <2s) // Step 1: midi2ly (sync, fast — typically <2s)
@@ -448,3 +448,29 @@ export function renderMidiAsync(
} }
}); });
} }
/** Save uploaded MIDI to render dir (no LilyPond). Returns metadata for the action picker. */
export function saveMidiFile(fileData: Buffer): {
id: string;
midiUrl: string;
hasSvg: boolean;
pageCount: number;
pdfUrl: string | null;
} {
ensureRenderDir();
const id = crypto.createHash('sha1').update(fileData).digest('hex').slice(0, 12);
const workDir = path.join(RENDER_DIR, 'midi-' + id);
fs.mkdirSync(workDir, { recursive: true });
const origPath = path.join(workDir, 'original.midi');
if (!fs.existsSync(origPath)) fs.writeFileSync(origPath, fileData);
const pages = collectSvgPages(workDir);
const pdfPath = path.join(workDir, 'output.pdf');
return {
id: 'midi-' + id,
midiUrl: `/api/music/file/midi-${id}/midi`,
hasSvg: pages.length > 0,
pageCount: pages.length,
pdfUrl: fs.existsSync(pdfPath) ? `/api/music/file/midi-${id}/pdf` : null,
};
}
+243 -14
View File
@@ -4856,7 +4856,7 @@ async function handleChat(
role: 'system', role: 'system',
content: `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools. content: `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know. ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContext(500)}${workflowCtx ? '\n\n' + workflowCtx : ''}`, IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 500)}${workflowCtx ? '\n\n' + workflowCtx : ''}`,
}, },
]; ];
@@ -8676,8 +8676,14 @@ app.post('/api/upload/image', (req: express.Request, res: express.Response) => {
const header = part.substring(0, headerEnd); const header = part.substring(0, headerEnd);
if (!header.includes('name="image"')) continue; if (!header.includes('name="image"')) continue;
const fnMatch = header.match(/filename="([^"]+)"/); const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
if (fnMatch) filename = fnMatch[1]; ?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim(); if (ctMatch) filetype = ctMatch[1].trim();
@@ -8755,8 +8761,15 @@ app.post('/api/upload/file', (req: express.Request, res: express.Response) => {
const header = part.substring(0, headerEnd); const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue; if (!header.includes('name="file"')) continue;
const fnMatch = header.match(/filename="([^"]+)"/); const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
if (fnMatch) filename = fnMatch[1]; ?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
// Body decoded as 'binary' (Latin-1) — recover original UTF-8 bytes
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim(); if (ctMatch) filetype = ctMatch[1].trim();
@@ -8852,7 +8865,7 @@ app.post('/api/voice/tts', async (req: express.Request, res: express.Response) =
py.stdin.write(text.slice(0, 2000), 'utf8'); py.stdin.write(text.slice(0, 2000), 'utf8');
py.stdin.end(); py.stdin.end();
let stderr = ''; let stderr = '';
py.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); py.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); });
py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`))); py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
py.on('error', reject); py.on('error', reject);
}); });
@@ -9063,7 +9076,7 @@ app.post('/api/chat', async (req, res) => {
lastMainSessionId = String(sessionId || 'default'); lastMainSessionId = String(sessionId || 'default');
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('X-Accel-Buffering', 'no');
@@ -9223,9 +9236,23 @@ app.post('/api/clear-history', async (req, res) => {
}); });
app.get('/api/skills', async (_req, res) => { app.get('/api/skills', async (req, res) => {
recoverSkillsIfEmpty(); recoverSkillsIfEmpty();
const session = (req as any).user as { username: string; role: string } | undefined;
const GLOBAL_ONLY = new Set(['multi-agent-orchestrator']);
// ?username= : admin can query any user, regular user can only query themselves.
let targetUser = (req.query.username as string) || null;
if (targetUser && session?.role !== 'admin' && session?.username !== targetUser) {
targetUser = session?.username ?? null;
}
let userState: Record<string, boolean> | null = null;
if (targetUser) {
userState = skillsManager.getUserState(getUserWorkspace(targetUser));
}
let orchestrationEligibility: { eligible: boolean; reason?: string } = { eligible: true }; let orchestrationEligibility: { eligible: boolean; reason?: string } = { eligible: true };
try { try {
orchestrationEligibility = await checkOrchestrationEligibility(); orchestrationEligibility = await checkOrchestrationEligibility();
@@ -9233,21 +9260,27 @@ app.get('/api/skills', async (_req, res) => {
const skills = skillsManager.getAll().map(s => { const skills = skillsManager.getAll().map(s => {
const isOrchestrator = s.id === 'multi-agent-orchestrator'; const isOrchestrator = s.id === 'multi-agent-orchestrator';
const isGlobalOnly = GLOBAL_ONLY.has(s.id);
let enabled = s.enabled;
if (userState !== null && !isGlobalOnly && s.id in userState) {
enabled = userState[s.id];
}
return { return {
id: s.id, id: s.id,
name: s.name, name: s.name,
description: s.description, description: s.description,
emoji: s.emoji, emoji: s.emoji,
version: s.version, version: s.version,
enabled: s.enabled, enabled,
createdAt: s.createdAt, createdAt: s.createdAt,
globalOnly: isGlobalOnly,
eligible: isOrchestrator ? orchestrationEligibility.eligible : true, eligible: isOrchestrator ? orchestrationEligibility.eligible : true,
eligibleReason: isOrchestrator eligibleReason: isOrchestrator
? (orchestrationEligibility.eligible ? undefined : orchestrationEligibility.reason) ? (orchestrationEligibility.eligible ? undefined : orchestrationEligibility.reason)
: undefined, : undefined,
}; };
}); });
res.json({ success: true, skills }); res.json({ success: true, skills, targetUser: targetUser || null });
}); });
// ——— Skill Templates API ———————————————————————————————— // ——— Skill Templates API ————————————————————————————————
@@ -9321,7 +9354,25 @@ app.post('/api/skills/:id/toggle', async (req, res) => {
const current = skillsManager.get(skillId); const current = skillsManager.get(skillId);
if (!current) { res.status(404).json({ success: false, error: 'Skill not found' }); return; } if (!current) { res.status(404).json({ success: false, error: 'Skill not found' }); return; }
// Guard enabling orchestration skill until config is eligible. const session = (req as any).user as { username: string; role: string } | undefined;
const GLOBAL_ONLY = new Set(['multi-agent-orchestrator']);
const targetUser = (req.body?.username as string) || null;
// Per-user toggle: admin can target any user; regular user can only target themselves.
if (targetUser && !GLOBAL_ONLY.has(skillId)) {
if (session?.role !== 'admin' && session?.username !== targetUser) {
res.status(403).json({ success: false, error: 'Forbidden' }); return;
}
const userDir = getUserWorkspace(targetUser);
const curState = skillsManager.getUserState(userDir);
const curEnabled = (curState && skillId in curState) ? curState[skillId] : current.enabled;
const skill = skillsManager.setUserSkillEnabled(userDir, skillId, !curEnabled);
if (!skill) { res.status(404).json({ success: false, error: 'Skill not found' }); return; }
res.json({ success: true, skill: { id: skill.id, name: skill.name, enabled: skill.enabled }, targetUser });
return;
}
// Global toggle (existing behavior).
if (skillId === 'multi-agent-orchestrator' && !current.enabled) { if (skillId === 'multi-agent-orchestrator' && !current.enabled) {
const eligibility = await checkOrchestrationEligibility(); const eligibility = await checkOrchestrationEligibility();
if (!eligibility.eligible) { if (!eligibility.eligible) {
@@ -9793,7 +9844,7 @@ app.post('/api/bg-tasks/:id/message', async (req: any, res: any) => {
// SSE stream for live task updates // SSE stream for live task updates
app.get('/api/bg-tasks/:id/stream', (req, res) => { app.get('/api/bg-tasks/:id/stream', (req, res) => {
const taskId = req.params.id; const taskId = req.params.id;
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('X-Accel-Buffering', 'no');
@@ -11365,6 +11416,7 @@ import { getProvider, resetProvider, buildProviderForLLM } from '../providers/fa
import { buildWebhookRouter, resolveHookConfig } from './webhook-handler'; import { buildWebhookRouter, resolveHookConfig } from './webhook-handler';
import { getMCPManager } from './mcp-manager'; import { getMCPManager } from './mcp-manager';
import { startOAuthFlow, isConnected, clearTokens, loadTokens, exchangeManualCodeFromPending } from '../auth/openai-oauth'; import { startOAuthFlow, isConnected, clearTokens, loadTokens, exchangeManualCodeFromPending } from '../auth/openai-oauth';
import { renderMusic, renderMidi, getRenderFile, getRenderPageFile, renderMidiAsync, saveMidiFile } from './music-renderer';
function sanitizeLLMConfig(llm: any): any { function sanitizeLLMConfig(llm: any): any {
if (!llm || typeof llm !== 'object') return llm; if (!llm || typeof llm !== 'object') return llm;
@@ -11431,7 +11483,18 @@ app.post('/api/settings/provider', (req, res) => {
// POST /api/models/test — test connectivity for the active (or a given) provider // POST /api/models/test — test connectivity for the active (or a given) provider
app.post('/api/models/test', async (req, res) => { app.post('/api/models/test', async (req, res) => {
try { try {
const llmOverride = req.body?.llm ? sanitizeLLMConfig(req.body.llm) : null; let llmOverride = req.body?.llm ? sanitizeLLMConfig(req.body.llm) : null;
// Replace redacted keys (••••••••) with the stored values so the UI can test
// without the user re-entering credentials after they've been saved.
if (llmOverride?.providers) {
const stored = ((getConfig().getConfig() as any)?.llm?.providers) || {};
for (const provId of Object.keys(llmOverride.providers)) {
const p = llmOverride.providers[provId];
if (p?.api_key === '••••••••' && stored[provId]?.api_key) {
p.api_key = stored[provId].api_key;
}
}
}
const provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider(); const provider = llmOverride ? buildProviderForLLM(llmOverride) : getProvider();
const ok = await provider.testConnection(); const ok = await provider.testConnection();
const models = ok ? await provider.listModels() : []; const models = ok ? await provider.listModels() : [];
@@ -11441,6 +11504,172 @@ app.post('/api/models/test', async (req, res) => {
} }
}); });
// ─── Music rendering ───────────────────────────────────────────────────────────
// POST /api/music/render — render ABC/LilyPond source → SVG + PDF + MIDI
app.post('/api/music/render', (req, res) => {
try {
const source = String(req.body?.source || '').trim();
if (!source) { res.status(400).json({ error: 'Missing source' }); return; }
const result = renderMusic(source);
res.json({
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: result.midiPath ? `/api/music/file/${result.id}/midi` : null,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi — upload MIDI file → save to workspace + render dir, return action picker data
app.post('/api/music/midi', (req, res) => {
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('binary');
const parts = raw.split('--' + boundary);
let fileData: Buffer | null = null;
let originalFilename = 'upload.midi';
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"') && !header.includes('name="midi"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/i);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
originalFilename = raw8;
}
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ error: 'No MIDI file found' }); return; }
if (fileData.length > 10 * 1024 * 1024) { res.status(400).json({ error: 'File too large (max 10MB)' }); return; }
// Save to render dir (original.midi) — no LilyPond yet
const info = saveMidiFile(fileData);
// Save to workspace/music/<songName>/
let savedTo: string | null = null;
const uploadUser = (req as any).user;
if (uploadUser?.workspace) {
const songName = originalFilename
.replace(/\.midi?$/i, '')
.replace(/[/\\:*?"<>|]/g, '_')
.trim() || 'untitled';
const musicDir = path.join(uploadUser.workspace, 'music', songName);
fs.mkdirSync(musicDir, { recursive: true });
const safeName = originalFilename.replace(/[/\\:*?"<>|]/g, '_');
const destPath = path.join(musicDir, safeName);
if (!fs.existsSync(destPath)) fs.writeFileSync(destPath, fileData);
savedTo = destPath;
}
res.json({
id: info.id,
filename: originalFilename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
});
// POST /api/music/midi-score — on-demand sheet music render for uploaded MIDI
app.post('/api/music/midi-score', (req, res) => {
const { id } = req.body as { id: string };
if (!id || !id.startsWith('midi-')) { res.status(400).json({ error: 'Invalid id' }); return; }
const { getRenderFile: _rf, ..._ } = { getRenderFile };
const origPath = getRenderFile(id, 'midi');
if (!origPath) { res.status(404).json({ error: 'MIDI not found — 파일을 다시 업로드하세요' }); return; }
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20_000);
const send = (data: object) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}; };
const finish = () => { clearInterval(hb); try { res.end(); } catch {} };
renderMidiAsync(
origPath,
(msg) => send({ type: 'progress', message: msg }),
(result) => {
send({
type: 'done',
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: `/api/music/file/${result.id}/midi`,
});
finish();
},
(err) => { send({ type: 'error', message: err.message }); finish(); },
);
});
// GET /api/music/file/:id/svg — serve page 1 SVG
app.get('/api/music/file/:id/svg', (req, res) => {
const filePath = getRenderFile(req.params.id, 'svg');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/svg/:page — serve specific page (1-indexed)
app.get('/api/music/file/:id/svg/:page', (req, res) => {
const page = parseInt(req.params.page, 10);
const filePath = isNaN(page) ? null : getRenderPageFile(req.params.id, page);
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/pdf — serve rendered PDF
app.get('/api/music/file/:id/pdf', (req, res) => {
const filePath = getRenderFile(req.params.id, 'pdf');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="score.pdf"');
res.sendFile(filePath);
});
// GET /api/music/file/:id/midi — serve rendered MIDI
app.get('/api/music/file/:id/midi', (req, res) => {
const filePath = getRenderFile(req.params.id, 'midi');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'audio/midi');
res.sendFile(filePath);
});
// GET /api/auth/openai/status — is the user connected via OAuth? // GET /api/auth/openai/status — is the user connected via OAuth?
app.get('/api/auth/openai/status', (_req, res) => { app.get('/api/auth/openai/status', (_req, res) => {
const configDir = CONFIG_DIR_PATH; const configDir = CONFIG_DIR_PATH;
@@ -11812,7 +12041,7 @@ wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
ws.on('message', async (d) => { ws.on('message', async (d) => {
let msg: any; let msg: any;
try { msg = JSON.parse(d.toString()); } catch { return; } try { msg = JSON.parse(d.toString('utf8')); } catch { return; }
if (msg.type === 'pty_spawn') { if (msg.type === 'pty_spawn') {
const shellId = msg.shellId; const shellId = msg.shellId;
+125 -55
View File
@@ -2324,8 +2324,19 @@
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
font-family: inherit; font-family: inherit;
display: inline-flex;
align-items: center;
gap: 5px;
} }
.music-dl-btn:hover { background: var(--hover); } .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 { .music-page-nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -9483,8 +9494,8 @@ function buildMusicActions(data, downloadName) {
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>' + '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>' + '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>'; + '</div>';
html += '<button class="music-dl-btn" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">🎛 외부 신디</button>'; html += '<button class="music-dl-btn btn-green" 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-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>'; 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>'; 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) { // ─── MIDI 업로드 → 액션 피커 ──────────────────────────────────────────────────
var msgId = 'midi-render-' + Date.now();
appendChatBubble('system', function buildMidiPickerWidget(data, filename) {
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">' + var wid = 'midi-' + Math.random().toString(36).slice(2, 10);
'🎵 <strong>' + escHtml(file.name) + '</strong> 악보 변환 중...' + var midiUrl = data.midi_url;
'<br><span id="' + msgId + '-prog" style="font-size:11px;opacity:0.7">준비 중...</span>' + var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
'</div>'
); 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 { try {
var form = new FormData(); var resp = await fetch('/api/music/midi-score', {
form.append('file', file); method: 'POST',
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form }); headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
body: JSON.stringify({ id: midiId }),
});
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status); if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader(), decoder = new TextDecoder(), buf = '';
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buf = '';
while (true) { while (true) {
var chunk = await reader.read(); var chunk = await reader.read();
if (chunk.done) break; if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true }); 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++) { for (var i = 0; i < parts.length; i++) {
var part = parts[i]; if (!parts[i].startsWith('data: ')) continue;
if (!part.startsWith('data: ')) continue; var ev; try { ev = JSON.parse(parts[i].slice(6)); } catch { continue; }
var ev;
try { ev = JSON.parse(part.slice(6)); } catch (e) { continue; }
if (ev.type === 'progress') { if (ev.type === 'progress') {
var progEl = document.getElementById(msgId + '-prog'); var sp = document.getElementById(wid + '-sprog'); if (sp) sp.textContent = ev.message;
if (progEl) progEl.textContent = ev.message;
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
var wid = msgId + '-widget'; scoreDiv.innerHTML = buildSvgPagesHtml(ev) + buildMusicActions(ev, filename);
var html = '<div class="music-widget" id="' + wid + '">' + if (btn) { btn.disabled = false; btn.textContent = '📄 닫기'; }
'<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();
} else if (ev.type === 'error') { } else if (ev.type === 'error') {
var pl2 = document.getElementById(msgId); scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">악보 변환 실패: ' + escHtml(ev.message) + '</span>';
if (pl2) pl2.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(ev.message) + '</span>'; if (btn) { btn.disabled = false; btn.textContent = '📄 악보'; }
} }
} }
} }
} catch (e) { } catch (e) {
var pl3 = document.getElementById(msgId); scoreDiv.innerHTML = '<span style="color:var(--err);font-size:12px">오류: ' + escHtml(e.message) + '</span>';
if (pl3) pl3.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + 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>';
} }
} }