Add one-line score visualizer with 3D orb and MP4 export

- Verovio single-system engraving, MuseScore audio/MIDI from the same source
- React + Remotion player with live camera, colour and orb controls
- Express pipeline, render queue and local H.264/AAC export
- MIT license plus third-party notices for the reference skill
This commit is contained in:
blumia committed 2026-11-05 11:38:25 +08:00
commit a237a7c604
38 files changed
+13958

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
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<VisualSettings> | 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<string, Express.Multer.File[]> | 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<VisualSettings>;
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');
});