From 9bf2b0792ec003cded24ad0bba07bd7488036972 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 7 Jun 2026 00:47:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86=20BangDream=20?= =?UTF-8?q?=E8=AF=95=E7=82=BC=E9=95=BF=E5=9B=BE=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 7 ++- scripts/bangdream-render-smoke.ps1 | 30 ++++++++-- .../tsugu/command-renderers/event-stage.ts | 57 +++++++++++-------- .../tsugu/render-blocks/event-stage-spec.ts | 30 +++++++++- .../bangDream/tsugu/event-stage-spec.spec.ts | 7 +++ 5 files changed, 97 insertions(+), 34 deletions(-) diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 6c96dd1..6efb411 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -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 和时间/档线规则 diff --git a/scripts/bangdream-render-smoke.ps1 b/scripts/bangdream-render-smoke.ps1 index 62ba6c5..7eca2d4 100644 --- a/scripts/bangdream-render-smoke.ps1 +++ b/scripts/bangdream-render-smoke.ps1 @@ -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 diff --git a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts index a72bbb4..e24913a 100644 --- a/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts +++ b/src/qqbot/plugins/bangDream/tsugu/command-renderers/event-stage.ts @@ -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 = []; + 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; } diff --git a/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts b/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts index cccc3ab..8fbdec9 100644 --- a/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts +++ b/src/qqbot/plugins/bangDream/tsugu/render-blocks/event-stage-spec.ts @@ -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) { diff --git a/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts index 5ee2d3d..ca3243c 100644 --- a/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts +++ b/test/qqbot/plugins/bangDream/tsugu/event-stage-spec.spec.ts @@ -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( [