import path from 'node:path'; import fs from 'node:fs'; import express from 'express'; import cors from 'cors'; import multer from 'multer'; import {z} from 'zod'; import { DEFAULT_SETTINGS, MAX_RENDER_SECONDS, MAX_UPLOAD_BYTES, type HealthResponse, type VisualSettings, } from '../shared/types.js'; import { createJob, getJob, JOBS_DIR, jobDirectory, listJobs, loadJobsFromDisk, publicJob, updateJob, } from './jobs.js'; import {buildScrollKeys} from '../shared/scroll.js'; import {runPipeline} from './pipeline.js'; import {enqueueRender} from './queue.js'; import {findMuseScore, museScoreVersion} from './musescore.js'; import {IS_PRODUCTION, PORT, PUBLIC_BASE_URL} from './config.js'; loadJobsFromDisk(); const app = express(); app.use(cors()); app.use(express.json({limit: '2mb'})); const upload = multer({ storage: multer.memoryStorage(), limits: {fileSize: MAX_UPLOAD_BYTES, files: 2}, }); const colorSchema = z.string().regex(/^#[0-9a-fA-F]{3,8}$/, '颜色必须是 #RRGGBB'); const settingsSchema = z .object({ width: z.number().int().min(320).max(3840), height: z.number().int().min(320).max(3840), fps: z.number().int().min(15).max(60), introSeconds: z.number().min(0).max(5), outroSeconds: z.number().min(0).max(15), scoreHeightRatio: z.number().min(0.18).max(0.82), scrollLead: z.number().min(0.1).max(0.7), paperColor: colorSchema, paperAlpha: z.number().min(0).max(1), scoreColor: colorSchema, background: colorSchema, staffColors: z.array(colorSchema).min(2).max(8), ballScale: z.number().min(0.4).max(3), ballLift: z.number().min(0).max(3), glow: z.number().min(0).max(3), showTitle: z.boolean(), showProgress: z.boolean(), titleFont: z.string().min(1).max(300), audioOffsetMs: z.number().min(-5000).max(5000), tiltX: z.number().min(-85).max(85), tiltY: z.number().min(-60).max(60), tiltZ: z.number().min(-60).max(60), perspective: z.number().min(400).max(6000), tiltZoom: z.number().min(0.5).max(3), }) .partial(); function normalizeSettings(input: Partial | undefined): VisualSettings { return {...DEFAULT_SETTINGS, ...(input ?? {})}; } /** Scroll keys depend on the viewport and strip height, so recompute them when those change. */ function scrollForSettings( data: {events: import('../shared/types.js').NoteEvent[]; meta: import('../shared/types.js').ScoreMeta}, settings: VisualSettings, ) { const displayHeight = settings.height * settings.scoreHeightRatio; const displayScale = displayHeight / Math.max(1, data.meta.svgHeight); const viewWidthScore = settings.width / displayScale; const maxLeft = Math.max(0, data.meta.svgWidth - viewWidthScore); return buildScrollKeys( data.events, settings.scrollLead, viewWidthScore, maxLeft, data.meta.musicDuration, ); } app.get('/api/health', (_req, res) => { const musescorePath = findMuseScore(); const body: HealthResponse = { ok: true, musescore: { available: Boolean(musescorePath), path: musescorePath ?? undefined, version: museScoreVersion(), }, chrome: { available: true, note: 'Remotion 首次渲染会使用或下载 Chrome Headless Shell。', }, ffmpeg: { available: true, note: 'Remotion 渲染使用内置 FFmpeg。', }, version: process.env.npm_package_version ?? '0.1.0', }; res.json(body); }); app.get('/api/jobs', (_req, res) => { res.json({jobs: listJobs().slice(0, 30).map((job) => publicJob(job))}); }); app.post('/api/jobs', upload.fields([{name: 'score', maxCount: 1}, {name: 'audio', maxCount: 1}]), (req, res) => { const files = req.files as Record | undefined; const score = files?.score?.[0]; if (!score) { res.status(400).json({error: '请选择 MusicXML / MXL / MIDI 谱面文件。'}); return; } if (score.size > MAX_UPLOAD_BYTES) { res.status(413).json({error: '文件超过 30MB 限制。'}); return; } let settings: VisualSettings = DEFAULT_SETTINGS; const rawSettings = typeof req.body?.settings === 'string' ? req.body.settings : null; if (rawSettings) { try { const parsed = settingsSchema.parse(JSON.parse(rawSettings)); settings = normalizeSettings(parsed); } catch (error) { res.status(400).json({error: `设置无效:${error instanceof Error ? error.message : String(error)}`}); return; } } const metadata = { title: typeof req.body?.title === 'string' ? req.body.title : undefined, subtitle: typeof req.body?.subtitle === 'string' ? req.body.subtitle : undefined, composer: typeof req.body?.composer === 'string' ? req.body.composer : undefined, }; const audio = files?.audio?.[0]; const job = createJob({ settings, inputName: score.originalname, inputFormat: path.extname(score.originalname).toLowerCase().replace('.', ''), }); void runPipeline({ jobId: job.id, file: {buffer: score.buffer, filename: score.originalname}, audio: audio ? {buffer: audio.buffer, filename: audio.originalname} : undefined, settings, metadata, }); res.status(202).json({id: job.id}); }); app.get('/api/jobs/:id', (req, res) => { const job = getJob(req.params.id); if (!job) { res.status(404).json({error: '任务不存在。'}); return; } res.json(publicJob(job)); }); app.patch('/api/jobs/:id/settings', (req, res) => { const job = getJob(req.params.id); if (!job) { res.status(404).json({error: '任务不存在。'}); return; } if (!job.data) { res.status(409).json({error: '任务还没有生成预览数据。'}); return; } if (job.status === 'rendering') { res.status(409).json({error: '正在渲染,暂时不能修改参数。'}); return; } let parsedSettings: Partial; try { parsedSettings = settingsSchema.parse(req.body?.settings ?? {}); } catch (error) { res.status(400).json({error: `设置无效:${error instanceof Error ? error.message : String(error)}`}); return; } const settings = normalizeSettings({ ...job.data.settings, ...job.settings, ...parsedSettings, }); const meta = {...job.data.meta}; const metadata = req.body?.metadata as | {title?: unknown; subtitle?: unknown; composer?: unknown} | undefined; if (typeof metadata?.title === 'string' && metadata.title.trim()) meta.title = metadata.title.trim(); if (typeof metadata?.subtitle === 'string' && metadata.subtitle.trim()) { meta.subtitle = metadata.subtitle.trim(); } if (typeof metadata?.composer === 'string' && metadata.composer.trim()) { meta.composer = metadata.composer.trim(); } const data = { ...job.data, meta, settings, scroll: scrollForSettings(job.data, settings), }; fs.writeFileSync(path.join(jobDirectory(job.id), 'score.json'), JSON.stringify(data, null, 2), 'utf8'); const updated = updateJob(job.id, { settings, data, ...(job.status === 'done' ? { status: 'ready' as const, stage: 'done' as const, progress: 100, message: '参数已更新,可以重新导出。', videoFile: undefined, } : {}), }); res.json(updated ? publicJob(updated) : {error: '任务不存在。'}); }); app.post('/api/jobs/:id/render', (req, res) => { const job = getJob(req.params.id); if (!job) { res.status(404).json({error: '任务不存在。'}); return; } if (!job.data) { res.status(409).json({error: '任务还没有生成预览数据。'}); return; } if (job.data.meta.audioDuration > MAX_RENDER_SECONDS || job.data.meta.musicDuration > MAX_RENDER_SECONDS) { res.status(400).json({ error: `这段音乐超过 ${Math.round(MAX_RENDER_SECONDS / 60)} 分钟,请先截取片段再渲染。`, }); return; } if (job.status === 'rendering') { res.json({ok: true, alreadyRunning: true}); return; } updateJob(job.id, {message: '已加入渲染队列…'}); enqueueRender(job.id); res.json({ok: true}); }); app.get('/api/jobs/:id/video', (req, res) => { const job = getJob(req.params.id); if (!job || !job.videoFile) { res.status(404).json({error: '视频还没有生成。'}); return; } const file = path.join(JOBS_DIR, job.id, job.videoFile); if (!fs.existsSync(file)) { res.status(404).json({error: '视频文件不存在。'}); return; } const safeName = (job.data?.meta.title || 'music-visualizer').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80); res.download(file, `${safeName}.mp4`); }); app.use('/jobs', express.static(JOBS_DIR, {maxAge: '1h'})); app.use('/demo', express.static(path.resolve('public/demo'), {maxAge: '1h'})); if (IS_PRODUCTION) { const clientDir = path.resolve('dist/client'); app.use(express.static(clientDir)); app.get('*', (_req, res) => { res.sendFile(path.join(clientDir, 'index.html')); }); } app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { if (error instanceof multer.MulterError) { res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({error: error.message}); return; } console.error(error); res.status(500).json({error: error instanceof Error ? error.message : '服务器错误。'}); }); app.listen(PORT, () => { console.log(`Music Visualizer API: http://127.0.0.1:${PORT}`); console.log(`Public base URL for render: ${PUBLIC_BASE_URL}`); console.log(`MuseScore: ${findMuseScore() ?? 'not found'}`); if (!IS_PRODUCTION) console.log('Client dev server: http://127.0.0.1:5173'); });