fix: 降低BangDream歌曲详情渲染峰值

This commit is contained in:
sunlei 2026-06-16 07:31:19 +08:00
parent 0fe3f99c36
commit 1deb2dc8b6
3 changed files with 270 additions and 19 deletions

View File

@ -78,6 +78,7 @@ export class Song {
//meta数据
hasMeta = false;
private readonly songJacketImageCache = new Map<string, Promise<Image>>();
meta: {
[difficultyId: number]: {
@ -206,6 +207,18 @@ export class Song {
*/
async getSongJacketImage(
displayedServerList: Server[] = [Server.jp, Server.cn],
): Promise<Image> {
const cacheKey = displayedServerList.join(',');
let jacketImage = this.songJacketImageCache.get(cacheKey);
if (!jacketImage) {
jacketImage = this.loadSongJacketImage(displayedServerList);
this.songJacketImageCache.set(cacheKey, jacketImage);
}
return await jacketImage;
}
private async loadSongJacketImage(
displayedServerList: Server[],
): Promise<Image> {
const jacketImageBuffer = await songResourceRepository.getJacketImageBuffer(
this,
@ -357,6 +370,16 @@ export interface SongInRank {
meta: number;
rank: number;
}
export interface SongMetaRankSummary {
entries: Array<{
difficulty: number;
meta: number;
rank: number;
}>;
maxMeta: number;
}
/**
* BangDream MetaRanking
*
@ -404,3 +427,87 @@ export function getMetaRanking(
}
return songRankList;
}
/**
* BangDream Meta排名摘要
*
* @param targetSong -
* @param withFever - withFever参数
* @param mainServer -
* @returns Meta
*/
export function getSongMetaRankSummary(
targetSong: Song,
withFever: boolean,
mainServer: Server,
): SongMetaRankSummary {
const songIdList = bangdreamCatalogRepository.getNumericIds('meta');
const rowMetas: number[] = [];
const targetEntries: Array<{
difficulty: number;
meta: number;
order: number;
}> = [];
let maxMeta = 0;
for (let i = 0; i < songIdList.length; i++) {
const songId = songIdList[i];
const song = songId === targetSong.songId ? targetSong : new Song(songId);
if (!isSongMetaRankCandidate(song, mainServer)) {
continue;
}
for (const j in song.difficulty) {
const difficulty = parseInt(j);
const meta = song.calcMeta(withFever, difficulty);
const order = rowMetas.length;
rowMetas.push(meta);
if (meta > maxMeta) {
maxMeta = meta;
}
if (song.songId === targetSong.songId) {
targetEntries.push({
difficulty,
meta,
order,
});
}
}
}
targetEntries.sort((a, b) => {
const metaDiff = b.meta - a.meta;
return metaDiff === 0 ? a.order - b.order : metaDiff;
});
return {
entries: targetEntries.map((entry) => ({
difficulty: entry.difficulty,
meta: entry.meta,
rank: countStableMetaRank(rowMetas, entry.meta, entry.order),
})),
maxMeta,
};
}
function isSongMetaRankCandidate(song: Song, mainServer: Server): boolean {
return (
song.publishedAt[mainServer] != null &&
Object.keys(song.notes).length > 0 &&
song.hasMeta
);
}
function countStableMetaRank(
rowMetas: number[],
targetMeta: number,
targetOrder: number,
) {
let rank = 0;
for (let i = 0; i < rowMetas.length; i++) {
const meta = rowMetas[i];
if (meta > targetMeta || (meta === targetMeta && i < targetOrder)) {
rank++;
}
}
return rank;
}

View File

@ -12,7 +12,8 @@ import {
import {
Song,
getMetaRanking,
SongInRank,
getSongMetaRankSummary,
type SongMetaRankSummary,
} from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model';
import { drawDottedLine } from '@/modules/qqbot/plugins/bangdream/src/theme/canvas-dotted-line';
import {
@ -240,19 +241,9 @@ export async function drawSongMetaListDataBlock(
topLeftText?: string,
displayedServerList: Server[] = globalDefaultServer,
) {
const metaRanking = {};
const metaRanking: Partial<Record<Server, SongMetaRankSummary>> = {};
for (const server of displayedServerList) {
metaRanking[server] = {};
metaRanking[server].data = getMetaRanking(withFever, server);
metaRanking[server].maxMeta = metaRanking[server].data[0].meta;
}
const songMetaRanking = {};
for (const server of displayedServerList) {
songMetaRanking[server] = {};
const tempMetaRanking = metaRanking[server].data;
songMetaRanking[server].data = tempMetaRanking.filter(
(value: SongInRank) => value.songId == song.songId,
);
metaRanking[server] = getSongMetaRankSummary(song, withFever, server);
}
const list: Array<Image | Canvas> = [];
@ -260,14 +251,17 @@ export async function drawSongMetaListDataBlock(
const difficultyId = parseInt(difficulty);
let text = '';
for (const server of displayedServerList) {
const tempSongMetaRanking = songMetaRanking[server].data;
for (let j = 0; j < tempSongMetaRanking.length; j++) {
if (tempSongMetaRanking[j].difficulty == difficultyId) {
const summary = metaRanking[server];
if (!summary) {
continue;
}
for (let j = 0; j < summary.entries.length; j++) {
if (summary.entries[j].difficulty == difficultyId) {
const percent = getRelativeMetaPercent(
tempSongMetaRanking[j].meta,
metaRanking[server].maxMeta,
summary.entries[j].meta,
summary.maxMeta,
);
text += `${serverNameFullList[server]}: ${percent}% #${tempSongMetaRanking[j].rank + 1} `;
text += `${serverNameFullList[server]}: ${percent}% #${summary.entries[j].rank + 1} `;
}
}
}

View File

@ -0,0 +1,150 @@
describe('BangDream song meta rank summary', () => {
it('matches full meta ranking entries for a single song', async () => {
jest.resetModules();
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache',
() => ({
__esModule: true,
default: createCatalogFixture(),
}),
);
const { Server } = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'
);
const songModule = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'
);
const { Song, getMetaRanking } = songModule;
const getSongMetaRankSummary = (
songModule as typeof songModule & {
getSongMetaRankSummary?: (
song: InstanceType<typeof Song>,
withFever: boolean,
server: typeof Server.cn,
) => {
entries: Array<{ difficulty: number; meta: number; rank: number }>;
maxMeta: number;
};
}
).getSongMetaRankSummary;
const song = new Song(243);
const fullRanking = getMetaRanking(true, Server.cn);
expect(typeof getSongMetaRankSummary).toBe('function');
const summary = getSongMetaRankSummary(song, true, Server.cn);
expect(summary.maxMeta).toBe(fullRanking[0].meta);
expect(summary.entries).toEqual(
fullRanking
.filter((entry) => entry.songId === 243)
.map(({ difficulty, meta, rank }) => ({ difficulty, meta, rank })),
);
});
it('caches jacket image decoding within a song instance', async () => {
const getJacketImageBuffer = jest
.fn<Promise<Buffer>, [unknown]>()
.mockResolvedValue(Buffer.from('fake-png'));
const loadImage = jest.fn(async () => ({
height: 1,
width: 1,
}));
jest.resetModules();
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache',
() => ({
__esModule: true,
default: createCatalogFixture(),
}),
);
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song-resource.repository',
() => ({
songResourceRepository: {
getChart: jest.fn(),
getDetail: jest.fn(),
getJacketImageBuffer,
getJacketImagePath: jest.fn(),
getSongRip: jest.fn(),
resolveJacketImageUrl: jest.fn(),
},
}),
);
jest.doMock('skia-canvas', () => ({
loadImage,
}));
const { Song } = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'
);
const song = new Song(243);
const first = await song.getSongJacketImage();
const second = await song.getSongJacketImage();
expect(first).toBe(second);
expect(getJacketImageBuffer).toHaveBeenCalledTimes(1);
expect(loadImage).toHaveBeenCalledTimes(1);
});
});
function createCatalogFixture() {
return {
meta: {
100: createMeta({ 1: 90, 2: 60 }),
243: createMeta({ 1: 50, 2: 70 }),
300: createMeta({ 1: 100, 2: 100 }),
400: createMeta({ 1: 70, 2: 70 }),
},
songs: {
100: createSong({ id: 100, publishedCn: true }),
243: createSong({ id: 243, publishedCn: true }),
300: createSong({ id: 300, publishedCn: false }),
400: createSong({ id: 400, publishedCn: true }),
},
};
}
function createSong({
id,
publishedCn,
}: {
id: number;
publishedCn: boolean;
}) {
return {
bandId: 1,
bpm: {
1: [{ bpm: 120, end: 1, start: 0 }],
2: [{ bpm: 140, end: 1, start: 0 }],
},
closedAt: [],
difficulty: {
1: { playLevel: 20 },
2: { playLevel: 25 },
},
jacketImage: [`song-${id}`],
length: 120,
musicTitle: [`Song ${id}`, null, null, `歌曲 ${id}`, null],
nickname: null,
notes: {
1: 500,
2: 700,
},
publishedAt: [null, null, null, publishedCn ? 1 : null, null],
tag: 'normal',
};
}
function createMeta(values: Record<number, number>) {
return Object.fromEntries(
Object.entries(values).map(([difficulty, value]) => [
difficulty,
{
7: [0, value, 0, value],
},
]),
);
}