/** * 超长图片的「切片 + markdown 拼接」发送。 * * 背景:评论区卡片会把「主评论 + 楼中楼」全部渲染进一张图,实测能长到 **2880×40000** * (1.15 亿像素、7MB)—— QQ 直接拒收(报 `[40093011] 上传文件大小超过限制`), * 表现就是「评论卡发不出来」。 * * 解法:把长图按固定高度切成若干片,每片单独上传,再用 **一条 markdown 消息** 拼起来: * * ![#2880px #6000px](url1) * ![#2880px #6000px](url2) * * QQ 对同一 markdown 里连续的图片是**紧贴渲染**的,所以视觉上仍然是一整张卡片(严丝合缝), * 而每片都在 QQ 的预览尺寸内,不会再被拒。 */ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { logger, resolveFfmpegBin, segment, type Message } from 'node-karin' import { isForwardCollecting } from '../../../compat/forward-collect' import { canUseMarkdownImage } from '../../../compat/imageMarkdown' import { commandInvocation, tryGetRuntime } from '../../../compat/runtime' import { getImageMetadata } from '@/module/utils/Render' import { classifySendFailure, describeSendFailure, failureFromReplyResult, type SendFailure } from '../../../compat/sendError' /** * 每片的**目标**高度。 * * 一开始用的 6000:QQ 会把这么高的图整体缩小显示,结果前几片字小到看不清; * 而最后一片因为短,反而被按宽度铺满,看起来比别的「宽」。 * 现在改成 2000 —— 每片都在 QQ 的舒适区内,字够大;同时下面的算法保证**每片等长**。 */ const SLICE_HEIGHT = 2000 /** 切片前先把宽度压到这个值(卡片本来 2880 宽,QQ 显示用不到,缩一半解码量降到 1/4) */ const SLICE_WIDTH = 1440 /** * 当前事件所在平台的适配器名(兼容层里真实 Bot 挂在 \`bot.bot\` 上)。 */ export const platformOf = (e: any): string => String(e?.bot?.bot?.platform ?? e?.platform ?? e?.bot?.platform ?? '') /** * **OneBot 系(NapCat / Lagrange / go-cqhttp / Chronocat…)**。 * * 它们的 QQ 图片限制和官方 bot 一样(所以要切片),但**不渲染 markdown 消息** —— * 实测切片后用 \`segment.markdown('![#1440px #2000px](url)…')\` 发出去, * 群里只看到一串 URL 文字、图片根本出不来(用户反馈:「onebot 平台给我评论区图片发不出来」)。 * 所以这条链路要改发**普通图片段**。 */ /** * 名单本体在 `compat/imageMarkdown`(`isOneBotLike`)—— * 这里只是转出来,方便同目录的模块继续按老名字引用,**别在这再抄一份正则**。 */ export { isOneBotLike } from '../../../compat/imageMarkdown' /** * **Milky**(QQ NT 的另一套实现端)。 * * 它**不是** OneBot(没有 `set_msg_emoji_like`),但实现了 **Satori 标准**的 * `createReaction(channelId, messageId, emojiId)` / `deleteReaction(...)` * (适配器 `koishi-plugin-adapter-milky` 的 `MilkyBot`),所以「贴表情当按钮」这条路 * 在 Milky 上是**通的** —— 只是接口形状和 OneBot 完全不一样: * * | | 贴表情 | 表情 id 写法 | 点击事件 | * |----------|---------------------------------------------------------|---------------|-----------------------------| * | OneBot | `set_msg_emoji_like(messageId, emojiId, isAdd)` | `301` | `group_msg_emoji_like`(常被适配器丢掉) | * | Milky | `createReaction(channelId, messageId, 'face\|301')` | `face\|301` | `reaction-added`(标准名,稳) | * * 表情 id 那列尤其要当心:Milky 自己上报的事件里也是 `face|301` 这种写法, * **贴和认必须用同一个转换**,不然贴上去的 id 和回来的 id 对不上号。 */ export const isMilkyLike = (platform: string): boolean => /^milky$/i.test(String(platform ?? '').trim()) /** * **QQ 那一族**(平台名)= 官方 QQ / QQ 频道 + OneBot 系 + Milky。 * * 只用来判「**图片上传受 QQ 那套限制约束吗**」和「**表情 id 用 QQ 系统表情那套吗**」。 * * ⚠️ `milky` 必须算进来:Milky 是 QQ NT 的实现端,和 NapCat 一样有 * **单图体积上限**(实测 9MB 附近就 `HTTP Upload failed with code 921`), * 以前没算进来 → 那张 `#kkk版本` 的大海报被当「其它平台」原样发出去, * 上传失败、命令直接报错。表情 id 同理:漏了它就落到占位符那一档 * (`OTHER_*_PLACEHOLDER`,协议端根本不认,见 `EmojiReaction.getEmojiId`)。 * * 注意它**不是**「要不要开表情面板」的判据 —— 那个认 OneBot 系 **+ Milky** * (见 {@link isReactionPanelCapable}),Milky 走的是 Satori 标准的 `createReaction`。 */ const QQ_FAMILY = /^(qq|qqguild|onebot|napcat|lagrange|go-?cqhttp|chronocat|mirai|milky)/i export const isQqFamily = (platform: string): boolean => QQ_FAMILY.test(platform) /** * 官方 QQ 适配器(`qq` / `qqguild`):**文字** markdown 能用(见 QqPanel.supportsMarkdown)。 * 注意这是「文字 md」的判据,**图片** md 要走 compat/imageMarkdown 的 `canUseMarkdownImage`。 */ export const isOfficialQq = (platform: string): boolean => /^qq/i.test(platform) /** OneBot 一条消息里最多塞几片(每片 1440x2000 的 jpeg ≈ 200KB,base64 后 ≈ 270KB) */ const SLICES_PER_MESSAGE = 5 /** 按条数把元素分组(OneBot 一次发太多图容易被客户端/适配器截断) */ const chunkElements = (items: T[], size: number): T[][] => { const groups: T[][] = [] for (let index = 0; index < items.length; index += size) groups.push(items.slice(index, index + size)) return groups } /** 把 data URI 或本地路径读成 Buffer */ const readImage = (source: string): Buffer | null => { try { if (source.startsWith('data:')) { const comma = source.indexOf(',') return Buffer.from(source.slice(comma + 1), 'base64') } if (fs.existsSync(source)) return fs.readFileSync(source) return null } catch { return null } } /** 用 ffmpeg 裁一段出来(项目本来就依赖 ffmpeg,不额外引图像库) */ const cropWithFfmpeg = async ( input: string, output: string, width: number, height: number, y: number, /** 自定义 filter(给缩放步骤用);不传就是按宽度/高度/偏移裁剪 */ customFilter?: string ): Promise => { const filter = customFilter ?? ('crop=' + width + ':' + height + ':0:' + y) const args = ['-y', '-i', input, '-vf', filter, '-frames:v', '1', output] /** * **不要用宿主的 ffmpeg 服务自带的 builder** —— 实测 `ffmpeg.builder()...run()` 会**卡住不返回** * (既没有 ffmpeg 进程、也没有 CPU 占用、更没有任何日志,表现就是「怎么没反应了」)。 * 命令行方式已经验证可用,直接用它。 * * 但**可执行文件要走兼容层那一套候选**(Koishi 服务 → ffmpegPath → 环境变量 → PATH, * 每份都校验存在与可执行):以前这里直接读 `process.env.FFMPEG_PATH`, * 拿到一份相对路径/没 +x 的 ffmpeg 时同样会 EACCES(和 m4s 那条链路一个坑)。 */ try { const { spawn } = await import('node:child_process') const bin = resolveFfmpegBin() await new Promise((resolve, reject) => { const proc = spawn(bin, args, { stdio: 'ignore' }) proc.on('error', reject) proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error('ffmpeg exit ' + code)))) }) return true } catch (error: any) { logger.warn('[图片切片] ffmpeg 裁剪失败: ' + String(error?.message ?? error)) return false } } /** 上传到宿主 assets,拿到 QQ 能访问的 https 地址 */ const uploadSlice = async (buffer: Buffer, name: string, attempts = 3): Promise => { for (let attempt = 1; attempt <= attempts; attempt++) { const url = await uploadSliceOnce(buffer, name) if (url) return url if (attempt < attempts) { logger.mark('[图片切片] 上传第 ' + attempt + ' 次失败,' + attempt * 1000 + 'ms 后重试') await new Promise((resolve) => setTimeout(resolve, attempt * 1000)) } } return null } /** 单次上传(重试逻辑在外面) */ const uploadSliceOnce = async (buffer: Buffer, name: string): Promise => { try { const assets: any = (tryGetRuntime() as any)?.ctx?.assets if (!assets?.upload) return null const uploaded: any = await assets.upload('data:image/jpeg;base64,' + buffer.toString('base64'), name) const url = typeof uploaded === 'string' ? uploaded : uploaded?.url return url && /^https?:\/\//i.test(String(url)) ? String(url) : null } catch (error: any) { logger.warn('[图片切片] 上传失败: ' + String(error?.message ?? error)) return null } } /** * 发送可能超长的图片:短图直接发,长图切片后用一条 markdown 拼接。 * * @param e 消息事件 * @param source data URI 或本地图片路径 * @returns 是否已发送 */ /** * @param e 消息事件 * @param input 图片(data URI / 本地路径 / 图片段 / 数组) * @param extra **跟着图片一起发**的额外元素(比如卡片下面的「提取封面图」按钮)。 * 它们会被拼进**同一条消息**(图片后面),调用方不用再单独发一条。 */ export const sendSlicedImage = async (e: Message, input: any, extra: any[] = []): Promise => { /** 拼 extras:没有附加元素时保持原来的单元素形态(不改变既有发送行为) */ const withExtra = (content: any): any => extra.length ? [content, ...extra] : content /** * 入参形态有三种,全都要认: * 1. data URI / 本地路径(字符串) * 2. segment.image(...) 造出来的元素(src 在 attrs 上) * 3. 上面两种的**数组** —— Render() 对多图模板返回的就是数组。 * 之前只处理前两种,数组进来时取不到 src 会静默失败(切片一直没生效就是这个原因)。 */ const first = Array.isArray(input) ? input[0] : input const source: string = typeof first === 'string' ? first : String(first?.attrs?.src ?? first?.data?.file ?? first?.data?.url ?? '') const buffer = readImage(source) if (!buffer) { logger.warn('[图片切片] 取不到图片内容(入参 ' + (Array.isArray(input) ? 'array' : typeof input) + '),回退原逻辑') return false } /** * **按需切片**(默认开,配置项 sliceImageOnDemand): * - 图片 ≤ 20MB 且不超高 → 先按普通图片发一次; * - 这次发送**失败** → 才切片重发。失败有两种:适配器抛异常(带错误码,例如 * `[40093011] 上传文件大小超过限制`),或兼容层发现**没拿到消息 ID** * (说明消息没发出去,见 compat/sendError 的 UnconfirmedSendError); * - 图片本身就超过 20MB → 直接切片,不用白试一次。 * 关掉开关就一律普通发送(超高的卡会被 QQ 拒收,但这是用户的选择)。 */ const runtimeConfig: any = (tryGetRuntime()?.config as any) ?? {} /** * **切片只在「QQ 那条链路」上生效**。 * * 切片是为了绕开 QQ 的图片上传限制(单图体积/像素上限),其它平台没这个问题, * 硬切只会增加消息条数、破坏观感。 * * ⚠️ **OneBot 也是 QQ**:用户的机器人跑在 NapCat / Lagrange / go-cqhttp 上时, * 适配器报的平台名是 `onebot`,底下的限制和 QQ 官方一模一样 —— * 实测 2880x35862 的评论卡直接发会拿到 `Error with request send_group_msg … retcode: 1200`, * 而这里以前只认 `/qq/`,于是**跳过切片**、把大图原样发出去,结果就是「评论卡发不出来」。 * 现在把 QQ 协议的常见平台名都算进来(含 `milky`,见 {@link isQqFamily})。 */ const platform = String((e as any)?.bot?.bot?.platform ?? (e as any)?.platform ?? (e as any)?.bot?.platform ?? '') if (platform && !isQqFamily(platform)) { logger.debug('[图片切片] 当前平台 ' + platform + ' 不是 QQ 链路,按普通图片发送') await e.reply(withExtra(segment.image(source))) return true } const onDemand = runtimeConfig.sliceImageOnDemand !== false const sliceHeight = Math.max(300, Number(runtimeConfig.sliceImageHeight) || SLICE_HEIGHT) const IMAGE_SIZE_LIMIT = 20 * 1024 * 1024 const meta = getImageMetadata(buffer) let width = Number(meta.width) || 0 let height = Number(meta.height) || 0 // 读不出尺寸时用 ffprobe 兜一次,绝不因为读不到尺寸就放弃切片 if (!width || !height) { try { const { spawnSync } = await import('node:child_process') const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', '-'], { input: buffer }) const parts = String(probe.stdout ?? '').trim().split(',') width = Number(parts[0]) || 0 height = Number(parts[1]) || 0 } catch { /* 兜底失败按 0 处理 */ } } logger.mark('[图片切片] 收到图片 ' + width + 'x' + height + '(' + (buffer.length / 1024).toFixed(0) + ' KB)') /** * 普通发送:失败判定与「为什么失败」分开看(见 compat/sendError)。 * * - **是不是失败**:适配器抛异常 / 返回体带 error / 兼容层发现没拿到消息 ID * (没 ID = 没发出去,对齐 qq-chat 的判法)—— 这三种都算失败; * - **为什么失败**:看错误码。`[40093011] 上传文件大小超过限制` 这类确定性失败 * 重试没有意义,直接去切片;网络/TLS/超时才是值得重试的。 * * 仍然保留超时 —— 超高图片(实测 2880×15520)上传时适配器会**长时间卡住不返回**, * 不加超时整条流程就停在这里,用户看到的就是「没反应」。 */ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** * 适配器多久没返回算「卡住」。 * * 线上踩坑(用户实测):一条 3MB 的评论卡走 QQ 官方上传经常要 15~40 秒, * 而老代码 **15 秒就判「发送未成功」** → 去切片再发一遍 → 群里出现两张(第一张其实发出去了), * 日志就是那句「普通发送未成功(发送超过 15s 没有返回),大图不重试,改走切片」。 * * 现在拆成三段: * 15 秒 —— 只在日志里提示「还在传,继续等」(不判失败); * 120 秒 —— 到这里才怀疑它卡住; * 再等 30 秒宽限 —— 等那个迟到的结果,成功了就当成功(不再重复发)。 */ const SEND_NOTICE_MS = 15000 const SEND_HARD_TIMEOUT_MS = 120000 const SEND_GRACE_MS = 30000 const TIMED_OUT = Symbol('send-timeout') const tryNormalSendOnce = async (): Promise => { const startedAt = Date.now() const sendPromise = (async (): Promise => { try { const result: any = await e.reply(withExtra(segment.image(source))) const failure = failureFromReplyResult(result) if (failure) logger.mark('[图片切片] 普通发送返回错误:' + describeSendFailure(failure)) return failure } catch (error: any) { // 这里不打日志:失败原因由外层统一输出一次(避免同一个失败连打三行) return classifySendFailure(error) } })() const notice = setTimeout(() => { logger.mark('[图片切片] 适配器还没返回(已 ' + Math.round(SEND_NOTICE_MS / 1000) + 's),继续等 —— 大图上传本来就要几十秒,超时不代表没发出去') }, SEND_NOTICE_MS) notice.unref?.() const raced = await Promise.race([ sendPromise, new Promise((resolve) => { const timer = setTimeout(() => resolve(TIMED_OUT), SEND_HARD_TIMEOUT_MS) timer.unref?.() }) ]) clearTimeout(notice) if (raced !== TIMED_OUT) return raced /** * 硬超时后再宽限一会儿:适配器可能只是慢,这一等能把「其实发出去了」的迟到结果等回来, * 避免外层去切片、把同一张图又发一遍。 */ logger.mark('[图片切片] 适配器 ' + Math.round(SEND_HARD_TIMEOUT_MS / 1000) + 's 没有返回,再等 ' + Math.round(SEND_GRACE_MS / 1000) + 's 看它是卡住还是在上传') const graced = await Promise.race([ sendPromise, new Promise((resolve) => { const timer = setTimeout(() => resolve(TIMED_OUT), SEND_GRACE_MS) timer.unref?.() }) ]) if (graced !== TIMED_OUT) { logger.mark('[图片切片] 那次「超时」的发送其实成功了(适配器 ' + Math.round((Date.now() - startedAt) / 1000) + 's 才返回),不再切片重发') return graced } // 到这里才真的当作「发不出去」;注意它仍然是**不确定**,不是确定失败,所以不重试 return { kind: 'unconfirmed', message: '发送超过 ' + Math.round((SEND_HARD_TIMEOUT_MS + SEND_GRACE_MS) / 1000) + 's 没有返回(适配器可能还卡在上传)', retryable: false } } /** * 带**重试**的普通发送(用户要求)。 * * 只重试**瞬时**失败:DNS 抖动、连接被重置、TLS 证书对不上 * (你环境里就有把 COS 域名解析到错服务器、返回「只有 IP 的证书」的情况)—— * 这类一失败就切片属于过度反应。 * * 而 `[40093011] 上传文件大小超过限制` 这类**确定性**失败(体积/尺寸超限)重试没有任何意义: * 同样的字节再传一遍还是超,只会让用户多等两轮大文件上传 —— 拿到码就直接去切片。 */ const tryNormalSend = async (attempts = 3): Promise => { let last: SendFailure | null = null for (let attempt = 1; attempt <= attempts; attempt++) { const failure = await tryNormalSendOnce() if (!failure) return null last = failure if (!failure.retryable) return failure if (attempt < attempts) { logger.mark('[图片切片] 普通发送第 ' + attempt + ' 次失败(' + describeSendFailure(failure) + '),' + attempt * 800 + 'ms 后重试') await sleep(attempt * 800) } } return last } // 开关关闭 → 永远普通发送 if (!onDemand) { await e.reply(withExtra(segment.image(source))) return true } /** * 顺序就是用户要的:**先按普通图片发一次**, * - 拿到消息 ID → 成功,结束(大多数卡片都走这里,不会多此一举切片) * - 拿不到消息 ID(QQ 拒收就是这样,不抛异常)/ 抛异常 → 再切片重发 * 只有图片本身就超过 20MB 才跳过这次尝试,直接切。 */ /** * **超高就别试发了,直接切**(配置项 `sliceImageAutoHeight`,0 = 关)。 * * 「按需切片」是**先整张发一次、被拒了再切**:QQ 收得下的超高图就这么整张出去了。 * 而太高的图 QQ 会整体缩小显示,卡片下面挂了按钮(提取封面 / 提取评论区图片)时, * 缩图剩下的空白会被按钮撑开 —— 一大片空白,非常难看。 * 设了阈值就直接切开,每片都在正常尺寸内,没这块空白。 */ const autoHeight = Math.max(0, Number(runtimeConfig.sliceImageAutoHeight) || 0) const tooTallForAuto = autoHeight > 0 && !!height && height > autoHeight const oversized = buffer.length > IMAGE_SIZE_LIMIT /** * **大图失败后不重试**:重传一次就是几 MB 起步(这张评论卡 4.6MB / 1.03 亿像素), * 而失败原因基本是「尺寸/体积超限」这类确定性问题,重试只是让用户白等。 * 小图(<2MB)才值得为瞬时抖动重试。 */ const heavyImage = buffer.length > 2 * 1024 * 1024 || height > sliceHeight * 6 /** * **合并转发模式下不能靠「试发一次」判断**。 * * 收集模式里 `e.reply()` 只是把元素攒进缓冲区、回一个**假的消息 ID**, * 拿不到任何成功/失败反馈(见 compat/forward-collect 的 isForwardCollecting)。 * 以前这里因此把 2880×35862 的评论卡当成「发送成功」,整张塞进转发节点, * `send_group_forward_msg` 直接因节点内容过大失败。 * 所以这种模式下**按尺寸自己判断**:超过一片的高度就切。 */ const collecting = isForwardCollecting() const tooTall = !!height && height > sliceHeight if (oversized) { logger.mark('[图片切片] 图片 ' + (buffer.length / 1024 / 1024).toFixed(1) + 'MB 超过 20MB 限制,直接切片') } else if (tooTallForAuto) { logger.mark('[图片切片] 图片高 ' + height + 'px 超过阈值 ' + autoHeight + 'px,直接切片(不再整张试发)') } else if (collecting && tooTall) { logger.mark('[图片切片] 合并转发模式:' + width + 'x' + height + ' 超过单片高度 ' + sliceHeight + ',直接切片(转发时不试发,试了也拿不到反馈)') } else { const failure = await tryNormalSend(heavyImage ? 1 : 3) if (!failure) return true logger.mark('[图片切片] 普通发送未成功(' + describeSendFailure(failure) + ')' + (heavyImage ? ',大图不重试' : '') + ',改走切片') } /** 走到「没得切、原样重发」这一支时,按阈值决定要切的情况要排除掉(阈值可能比单片高度还小) */ if (!width || !height || (height <= sliceHeight && !tooTallForAuto)) { // 尺寸本身就在范围内、只是发不出去:没得切,原样再发一次(再失败也如实报出来) try { const failure = failureFromReplyResult(await e.reply(withExtra(segment.image(source)))) if (failure) logger.warn('[图片切片] 重发仍未成功:' + describeSendFailure(failure)) } catch (error: any) { logger.warn('[图片切片] 重发失败:' + describeSendFailure(classifySendFailure(error))) } return true } const runtime: any = tryGetRuntime() const tmpDir = path.join(os.tmpdir(), 'kkk-slice-' + Date.now()) try { fs.mkdirSync(tmpDir, { recursive: true }) const original = path.join(tmpDir, 'full.jpg') fs.writeFileSync(original, buffer) /** * **先整体缩一次再从缩略图切片** —— 这一步是性能关键。 * * 卡片实测 2880×40000(1.15 亿像素):直接在原图上裁 7 刀,每刀都要把整图重新解码一遍, * 实测要跑好几分钟(用户会看到「怎么没反应了」)。缩到一半宽再裁, * 解码量降到 1/4,7 刀总共一两秒,而且 QQ 显示尺寸本来就不需要 2880 宽。 */ const scaleWidth = Math.min(width, SLICE_WIDTH) const input = path.join(tmpDir, 'small.jpg') const scaled = scaleWidth < width ? await cropWithFfmpeg(original, input, 0, 0, 0, 'scale=' + scaleWidth + ':-1') : true const useInput = scaled && fs.existsSync(input) ? input : original const useHeight = scaled && scaleWidth < width ? Math.round((height * scaleWidth) / width) : height const useWidth = scaled && scaleWidth < width ? scaleWidth : width if (useInput !== original) { logger.mark('[图片切片] 已缩放到 ' + useWidth + 'x' + useHeight + ' 再切片') } /** * **两条发送链路**(平台决定,见文件头的说明): * * - 官方 QQ(见 `canUseMarkdownImage`):每片先传到 assets 拿 https 地址, * 再拼成**一条 markdown**(连续图片紧贴渲染,视觉上仍是一整张); * - **其余一律**(OneBot 系、QQ 频道、以及任何认不出来的平台):**不传 assets、不拼 * markdown**,直接按普通图片段发 —— 省掉整轮上传(更快),而且 markdown 在这些地方 * 要么渲染不出来(NapCat / 频道),要么压根不是 QQ 的语法(telegram / discord…)。 * * ⚠️ 以前这里是 `!isOneBotLike(platform)`(「只要不是 OneBot 就用 markdown」), * 于是凡是**识别不出来**的平台(`platformOf()` 拿到空串、或者根本不是 QQ 的适配器) * 全被当成官方 QQ,发出去一串 `![#1440px #2000px](url)` 裸文本 —— 图一张都出不来。 * 所以这里必须用**正向点名**的判据。 */ const markdownMode = canUseMarkdownImage(platform) const slices: Buffer[] = [] const parts: string[] = [] /** * **每片等长**:先按最大高度算出片数,再把总高平均分配。 * 这样最后一片不会因为矮而被 QQ 按宽度铺开(用户说的「最后一张太宽」)。 * 除不尽时最后一片会与前一片重叠几个像素,视觉上几乎看不出来,比留白干净。 */ const total = Math.max(1, Math.ceil(useHeight / sliceHeight)) const each = Math.ceil(useHeight / total) for (let index = 0; index < total; index++) { const sliceHeight = each // 最后一片贴底裁,保证高度一致(宁可重叠几像素,也不要短一截) const offset = index === total - 1 ? Math.max(0, useHeight - each) : index * each const output = path.join(tmpDir, 'slice-' + index + '.jpg') const ok = await cropWithFfmpeg(useInput, output, useWidth, sliceHeight, offset) if (!ok || !fs.existsSync(output)) break const slice = fs.readFileSync(output) slices.push(slice) if (markdownMode) { const url = await uploadSlice(slice, 'kkk-slice-' + index + '.jpg') if (!url) break // 尺寸写死成实际像素:QQ 会按这个尺寸渲染,连续图片紧贴 = 视觉上仍是一整张 parts.push('![#' + useWidth + 'px #' + sliceHeight + 'px](' + url + ')') } } const usable = markdownMode ? parts.length : slices.length if (!usable) { // 切片失败就退回原图,至少不是完全没反应 await e.reply(withExtra(segment.image(source))) return true } if (markdownMode) { logger.mark('[图片切片] 长图 ' + width + 'x' + height + ' 已切成 ' + parts.length + ' 片,用 markdown 拼接发送') await e.reply(withExtra(segment.markdown(parts.join(String.fromCharCode(10))))) return true } /** * 非官方 QQ:按普通图片段发(base64 直接给适配器,不需要公网地址)。 * 一次 5 片,避免单条消息过大被客户端截断;视觉上依旧是「一条卡片的若干段」。 */ const groups = chunkElements(slices, SLICES_PER_MESSAGE) logger.mark('[图片切片] 长图 ' + width + 'x' + height + ' 已切成 ' + slices.length + ' 片,按图片段分 ' + groups.length + ' 条发送(' + (platform || '未知平台') + ' 不用 markdown)') for (const [groupIndex, group] of groups.entries()) { const lastGroup = groupIndex === groups.length - 1 /** * 用**带 mime 的 data URI**,别用 \`base64://\`:兼容层把 \`base64://\` 一律当 * \`image/png\`(compat/segment.ts 的 guessMime 拿不到扩展名就默认 png), * 而切片是 ffmpeg 出的 jpeg —— 标错 mime 会让适配器把 .png 扩展名的 jpeg 交给客户端。 */ const images = group.map((slice) => segment.image('data:image/jpeg;base64,' + slice.toString('base64'))) /** 附加元素(按钮 / 提示)只跟在**最后一组**后面:中途插会把一张长图切成两半 */ await e.reply(lastGroup ? [...images, ...extra] : images) } return true } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* 清理失败无所谓 */ } } } /** * 只切片、不发送:把可能超长的图片切成**可以直接发给适配器的元素数组**(调用方自己决定怎么发)。 * * 错误卡片就用它 —— 实测错误卡片能到 2880x40000 / 45MB(堆栈越长越夸张), * 直接发必然被 QQ 拒收,等于「报错本身也发不出来」。 * * 返回什么由平台决定(见文件头): * - 官方 QQ(`canUseMarkdownImage`):一个 \`markdown\` 元素(里面的连续图片紧贴渲染,视觉上仍是一整张); * - **其余一律**:若干 \`image\` 元素 —— markdown 不是哪儿都能渲染的, * 认不出来的平台也按图片段发,最坏只是「一张一张发」,不会变成一串裸链接。 * * @param input 图片(data URI / 本地路径 / 消息元素) * @param platform 适配器平台名;**缺省 = 不用 markdown**(只有官方 QQ 才点名允许) */ export const sliceImageToElements = async (input: any, platform = ''): Promise => { const first = Array.isArray(input) ? input[0] : input const source: string = typeof first === 'string' ? first : String(first?.attrs?.src ?? first?.data?.file ?? first?.data?.url ?? '') if (!source) return null const runtimeConfig: any = (tryGetRuntime()?.config as any) ?? {} /** * 注意:这里以前混进来一段**从 sendSlicedImage 复制过来的**非 QQ 分支, * 里面用了这个函数根本没有的 `e` 和 `valid` —— 函数每次一调用就 * `ReferenceError: e is not defined`,错误卡片的切片永远失败, * 最后那张 8.9MB 的长图直接原样发出去(还可能被 QQ 拒收)。 * 这个函数只负责「切片」,平台判断交给调用方,所以整段删掉。 */ const sliceHeight = Math.max(300, Number(runtimeConfig.sliceImageHeight) || SLICE_HEIGHT) const tmpDir = path.join(os.tmpdir(), 'kkk-sliceonly-' + Date.now()) try { fs.mkdirSync(tmpDir, { recursive: true }) const buffer = readImage(source) if (!buffer) return null const meta = getImageMetadata(buffer) let width = Number(meta.width) || 0 let height = Number(meta.height) || 0 if (!width || !height) { try { const { spawnSync } = await import('node:child_process') const probe = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', '-'], { input: buffer }) const ps = String(probe.stdout ?? '').trim().split(',') width = Number(ps[0]) || 0 height = Number(ps[1]) || 0 } catch { /* 忽略 */ } } if (!width || !height) return null /** * 只有官方 QQ 走「上传 → markdown 拼接」;其余(OneBot 系 / 频道 / 认不出来的平台) * 直接把图当普通图片段返回(也不需要先传 assets)。判据同上,必须正向点名。 */ const markdownMode = canUseMarkdownImage(platform) if (height <= sliceHeight) { // 带 mime 的 data URI(见上面 sendSlicedImage 里的说明) if (!markdownMode) return [segment.image('data:image/jpeg;base64,' + buffer.toString('base64'))] const url = await uploadSlice(buffer, 'kkk-one.jpg') return url ? [segment.markdown('![#' + width + 'px #' + height + 'px](' + url + ')')] : null } // 超高:先缩放再等分切片 const original = path.join(tmpDir, 'full.jpg') fs.writeFileSync(original, buffer) const scaleWidth = Math.min(width, SLICE_WIDTH) const small = path.join(tmpDir, 'small.jpg') const scaled = scaleWidth < width ? await cropWithFfmpeg(original, small, 0, 0, 0, 'scale=' + scaleWidth + ':-1') : false const useInput = scaled && fs.existsSync(small) ? small : original const useWidth = scaled ? scaleWidth : width const useHeight = scaled ? Math.round((height * scaleWidth) / width) : height const total = Math.max(1, Math.ceil(useHeight / sliceHeight)) const each = Math.ceil(useHeight / total) const slices: Buffer[] = [] const parts: string[] = [] for (let index = 0; index < total; index++) { const offset = index === total - 1 ? Math.max(0, useHeight - each) : index * each const output = path.join(tmpDir, 'slice-' + index + '.jpg') const ok = await cropWithFfmpeg(useInput, output, useWidth, each, offset) if (!ok || !fs.existsSync(output)) break const slice = fs.readFileSync(output) slices.push(slice) if (markdownMode) { const url = await uploadSlice(slice, 'kkk-slice-' + index + '.jpg') if (!url) break parts.push('![#' + useWidth + 'px #' + each + 'px](' + url + ')') } } if (!markdownMode) { if (!slices.length) return null logger.mark('[图片切片] 错误卡片等超长图已切成 ' + slices.length + ' 段(图片段,' + (platform || '未知平台') + ' 不用 markdown)') return chunkElements(slices, SLICES_PER_MESSAGE) .map((group) => group.map((slice) => segment.image('data:image/jpeg;base64,' + slice.toString('base64')))) .flat() } if (!parts.length) return null logger.mark('[图片切片] 错误卡片等超长图已切成 ' + parts.length + ' 段(markdown)') return [segment.markdown(parts.join(String.fromCharCode(10)))] } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch { /* 忽略 */ } } }