fix: 拆分 BangDream 试炼长图输出

This commit is contained in:
sunlei 2026-06-07 00:47:23 +08:00
parent 4220efa6b2
commit 9bf2b0792e
5 changed files with 97 additions and 34 deletions

View File

@ -363,9 +363,10 @@ export interface TsuguHook {
- 已新增 `render-blocks/gacha-simulate-spec.ts`,收口抽卡模拟网格宽度、单抽/汇总混排尺寸、重复卡叠层、数量右对齐和卡池横幅布局;`gacha-simulate.ts` 改为消费 spec抽卡概率和卡池选择逻辑保持不变。
- 已新增 `gacha-simulate-spec.spec.ts`,覆盖 10 抽网格、汇总模式、重复卡叠层、计数字样和横幅尺寸;本地生成 `gacha-simulate-spec-10-259.jpg``gacha-simulate-spec-50-259-after-cache-fix.jpg`,验证抽卡模拟两种布局模式图片输出正常。
- 抽卡模拟 50 抽 smoke 暴露资源下载短时失败会把 URL 写入无过期错误缓存,导致后续重试直接输出 `asset error`;已改为只有 HTTP 404 进入错误缓存,超时/网络抖动不污染 URL并新增 `file-cache-client.spec.ts` 覆盖瞬时失败后可再次下载、404 缓存缺失资源两种路径。
- 已新增 `render-blocks/event-stage-spec.ts`,收口试炼列表最大列高、歌曲单元格/封面/ID/难度条尺寸和试炼类型顶部文字规格;`event-stage.ts` 与 `list-event-stage.ts` 改为消费 spec活动/歌曲选择和输出结构保持不变。
- 已新增 `event-stage-spec.spec.ts`,覆盖试炼歌曲行尺寸、类型顶部文字规格和按列高拆分算法;本地生成 `event-stage-spec-310.jpg`、`event-stage-spec-310-meta.jpg`,验证 `/查试炼 310``/查试炼 310 -m` 图片输出正常。
- smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录。
- 已新增 `render-blocks/event-stage-spec.ts`,收口试炼列表最大列高、歌曲单元格/封面/ID/难度条尺寸、试炼类型顶部文字规格和换列判断;`event-stage.ts` 与 `list-event-stage.ts` 改为消费 spec活动/歌曲选择和输出结构保持不变。
- 线上 smoke 暴露 `/查试炼 310 -m` 将所有列横向拼成一张巨大 canvas 会触发 Pod `OOMKilled exit=137`;已改为边绘制边按列输出多张 CQ 图片,不再创建最终横向巨图,普通版和 meta 版均拆成 5 张输出。
- 已新增 `event-stage-spec.spec.ts`,覆盖试炼歌曲行尺寸、类型顶部文字规格、换列判断和按列高拆分算法;本地生成 `event-stage-split-310*.jpg`、`event-stage-split-310-meta*.jpg`,验证 `/查试炼 310``/查试炼 310 -m` 图片输出正常。
- smoke/Jenkins/远程调试卡点继续按已固化规则处理同一卡点第二次尝试前必须改变可验证变量Jenkins 查询 URL 含方括号时用 `curl -g` 或改查 Jenkins home线上图片 smoke 必须查询 `commandId`、拉回图片、展示图片、查日志并清理本轮临时目录PowerShell 双引号 here-string 中不要写 JS `${...}` 模板字面量,避免被 PowerShell 提前插值smoke 没有真实图片落盘时必须失败。
### Phase 6策略 policy 和时间/档线规则

View File

@ -25,6 +25,9 @@ $StdoutLog = Join-Path $LogDir "$LogName.out.log"
$StderrLog = Join-Path $LogDir "$LogName.err.log"
Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ResolvedOutFile -Force -ErrorAction SilentlyContinue
$OutExtension = [System.IO.Path]::GetExtension($ResolvedOutFile)
Get-ChildItem -LiteralPath $OutDir -Filter "$LogName-*${OutExtension}" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
$Payload = @{
input = @{
@ -53,12 +56,27 @@ const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/rende
const service = new TsuguApplicationService(renderer, new ToolsService());
await service.onApplicationBootstrap();
const result = await service.execute(payload.operationKey, payload.input);
const match = result.replyText.match(/base64:\/\/([A-Za-z0-9+/=]+)/);
if (!match) throw new Error("No image CQ payload");
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) throw new Error("No image CQ payload");
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
fs.writeFileSync(payload.outFile, Buffer.from(match[1], "base64"));
const parsed = path.parse(payload.outFile);
const files = matches.map((match, index) => {
const outFile =
index === 0
? payload.outFile
: path.join(
parsed.dir,
parsed.name + "-" + (index + 1) + (parsed.ext || ".jpg"),
);
fs.writeFileSync(outFile, Buffer.from(match[1], "base64"));
return {
bytes: fs.statSync(outFile).size,
out: outFile,
};
});
process.stdout.write(JSON.stringify({
bytes: fs.statSync(payload.outFile).size,
bytes: files[0].bytes,
files,
imageCount: result.imageCount,
out: payload.outFile,
}, null, 2) + "\n");
@ -118,4 +136,8 @@ if ($CompletedByOutput) {
Write-Output "BangDream smoke output completed; lingering process $($Process.Id) was cleaned up."
exit 0
}
if (-not (Test-SmokeCompleted)) {
Write-Output "BangDream smoke did not produce an image file."
exit 1
}
exit $Process.ExitCode

View File

@ -12,11 +12,9 @@ import {
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-event-stage';
import { outputEasyImages } from '@/qqbot/plugins/bangDream/tsugu/canvas/output';
import { drawDataBlock } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/data-block';
import {
stackImage,
stackImageHorizontal,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { splitEventStageImagesByColumnHeight } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec';
import { stackImage } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/image-stack';
import { shouldStartNewEventStageColumn } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec';
import { Canvas } from 'skia-canvas';
/**
* QQBot
@ -52,13 +50,14 @@ export async function drawEventStage(
return [`错误: 活动stage数据不足`];
}
const all = [];
all.push(drawTitle('查试炼', `国服 ID:${eventId} 活动试炼`));
const titleImage = drawTitle('查试炼', `国服 ID:${eventId} 活动试炼`);
//获得活动stage列表
const stageList = eventStage.getStageList();
const eventStagePromises = [];
const outputImages: Array<Buffer | string> = [];
let currentColumn: Canvas[] = [];
let currentHeight = 0;
let pageIndex = 0;
//绘制活动stage每个stage一个图片
/**
@ -73,22 +72,32 @@ export async function drawEventStage(
]);
}
for (let i = 0; i < stageList.length; i++) {
const stage = stageList[i];
eventStagePromises.push(drawStageSong(stage));
async function flushColumn() {
if (currentColumn.length === 0) return;
const columnBlock = drawDataBlock({ list: currentColumn });
const pageImages =
pageIndex === 0 ? [titleImage, columnBlock] : [columnBlock];
outputImages.push(...(await outputEasyImages(pageImages, { compress })));
currentColumn = [];
currentHeight = 0;
pageIndex += 1;
}
const eventStageResults = await Promise.all(eventStagePromises);
for (const stage of stageList) {
const stageImage = await drawStageSong(stage);
if (
shouldStartNewEventStageColumn(
currentHeight,
stageImage.height,
currentColumn.length,
)
) {
await flushColumn();
}
currentColumn.push(stageImage);
currentHeight += stageImage.height;
}
//将活动stage图片纵向并横向合并
const eventStageImageListHorizontal = splitEventStageImagesByColumnHeight(
eventStageResults,
).map((list) => drawDataBlock({ list }));
const eventStageListImage = stackImageHorizontal(
eventStageImageListHorizontal,
);
all.push(eventStageListImage);
return await outputEasyImages(all, { compress });
await flushColumn();
return outputImages;
}

View File

@ -66,6 +66,23 @@ export function getEventStageSongRowSize(): { height: number; width: number } {
};
}
/**
* stage
*
* @param currentHeight -
* @param nextImageHeight - stage
* @param currentColumnLength -
* @param maxHeight - 使
*/
export function shouldStartNewEventStageColumn(
currentHeight: number,
nextImageHeight: number,
currentColumnLength: number,
maxHeight = BANGDREAM_EVENT_STAGE_SPEC.list.maxColumnHeight,
): boolean {
return currentColumnLength > 0 && currentHeight + nextImageHeight > maxHeight;
}
/**
* stage
*
@ -83,13 +100,20 @@ export function splitEventStageImagesByColumnHeight<
let currentHeight = 0;
for (const image of images) {
currentHeight += image.height;
if (currentHeight > maxHeight && currentColumn.length > 0) {
if (
shouldStartNewEventStageColumn(
currentHeight,
image.height,
currentColumn.length,
maxHeight,
)
) {
columns.push(currentColumn);
currentColumn = [];
currentHeight = image.height;
currentHeight = 0;
}
currentColumn.push(image);
currentHeight += image.height;
}
if (currentColumn.length > 0) {

View File

@ -4,6 +4,7 @@ import {
getEventStageSongCellWidth,
getEventStageSongJacketHeight,
getEventStageSongRowSize,
shouldStartNewEventStageColumn,
splitEventStageImagesByColumnHeight,
} from '@/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec';
@ -38,6 +39,12 @@ describe('BangDream event stage spec', () => {
]);
});
it('detects whether the next stage image should start a new column', () => {
expect(shouldStartNewEventStageColumn(5000, 1000, 2)).toBe(false);
expect(shouldStartNewEventStageColumn(5000, 1001, 2)).toBe(true);
expect(shouldStartNewEventStageColumn(0, 7000, 0)).toBe(false);
});
it('keeps oversized first image in its own column', () => {
const columns = splitEventStageImagesByColumnHeight(
[