301 lines
9.4 KiB
TypeScript
301 lines
9.4 KiB
TypeScript
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');
|
||
|
|
});
|