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:
commit
a237a7c604
38 files changed
+13958
No files matched your search
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user