UNPKG

koishi-plugin-gif-reverse

Version:

Process GIF images. Supports viewing GIF information, reversing, bouncing, changing speed, sliding, rotating, and turning GIFs. [Click here to view README](https://www.npmjs.com/package/koishi-plugin-gif-reverse). [Click here to preview effects](https://i

869 lines (770 loc) 33.8 kB
import { Schema, Context, h } from 'koishi' import { unlink, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { parseGIF, decompressFrames } from 'gifuct-js' import { } from 'koishi-plugin-ffmpeg' import { } from 'koishi-plugin-canvas' export const name = 'gif-reverse' export const inject = { required: ['http', 'i18n', 'logger', 'ffmpeg', 'canvas'] } export const usage = ` --- ## 开启插件前,请确保以下插件已经安装! ### 所需依赖: - [ffmpeg服务](/market?keyword=ffmpeg) (需要额外安装)(此插件可能还需download服务) - [puppeteer提供的canvas服务](/market?keyword=koishi-plugin-puppeteer+email:shigma10826@gmail.com) 或 [canvas服务](/market?keyword=canvas) (需要额外安装) - [http服务](/market?keyword=http+email:shigma10826@gmail.com) (koishi自带) - [logger服务](/market?keyword=logger+email:shigma10826@gmail.com) (koishi自带) - i18n服务 (koishi自带) --- ## 支持的图片格式 - **GIF 图片**: 支持所有效果(倒放、回弹、滑动、旋转、转向等) - **静态图片**: 支持滑动、旋转、转向效果,可将静态图转换为动态GIF - 支持格式:JPEG、PNG、WebP - 不支持:倒放、回弹效果(静态图无时间序列) --- <table> <thead> <tr> <th>选项</th> <th>简写</th> <th>描述</th> <th>类型</th> </tr> </thead> <tbody> <tr> <td><code>--rebound</code></td> <td><code>-b</code></td> <td>回弹效果(正放+倒放)</td> <td><code>boolean</code></td> </tr> <tr> <td><code>--reverse</code></td> <td><code>-r</code></td> <td>倒放 GIF</td> <td><code>boolean</code></td> </tr> <tr> <td><code>--frame</code></td> <td><code>-f</code></td> <td>指定处理gif的帧间隔</td> <td><code>number</code></td> </tr> <tr> <td><code>--slide</code></td> <td><code>-l</code></td> <td>滑动方向 (上/下/左/右)</td> <td><code>string</code></td> </tr> <tr> <td><code>--rotate</code></td> <td><code>-o</code></td> <td>旋转方向 (顺/逆)</td> <td><code>string</code></td> </tr> <tr> <td><code>--turn</code></td> <td><code>-t</code></td> <td>转向角度 (上/下/左/右/0-360)</td> <td><code>string</code></td> </tr> <tr> <td><code>--shake</code></td> <td><code>-s</code></td> <td>上下震动效果</td> <td><code>boolean</code></td> </tr> <tr> <td><code>--information</code></td> <td><code>-i</code></td> <td>显示 GIF 信息</td> <td><code>boolean</code></td> </tr> </tbody> </table> --- <h2>使用示例</h2> <details> <summary>点击此处————查看指令使用示例</summary> <ul> <li><strong>回弹 GIF:</strong> <pre><code>gif -b</code></pre> </li> <li><strong>倒放 GIF:</strong> <pre><code>gif -r</code></pre> </li> <li><strong>指定帧间隔 20ms:</strong> <pre><code>gif -f 20</code></pre> </li> <li><strong>右滑 GIF:</strong> <pre><code>gif -l 右</code></pre> </li> <li><strong>逆时针旋转 GIF:</strong> <pre><code>gif -o 逆</code></pre> </li> <li><strong>转向 30 度:</strong> <pre><code>gif -t 30</code></pre> </li> <li><strong>转向向上:</strong> <pre><code>gif -t 上</code></pre> </li> <li><strong>右上方滑动:</strong> <pre><code>gif -l 右 -t 45</code></pre> </li> <li><strong>顺时针旋转:</strong> <pre><code>gif -o 顺</code></pre> </li> <li><strong>上下震动:</strong> <pre><code>gif -s</code></pre> </li> <li><strong>显示 GIF 信息:</strong> <pre><code>gif -i</code></pre> </li> </ul> </details> 完整使用方法请使用 <code>gif -h</code> 查看指令用法 --- `; export const Config = Schema.intersect([ Schema.object({ gifCommand: Schema.string().default("gif-reverse").description("注册的指令名称"), commandPrefix: Schema.string().description("在指令示例中显示的指令前缀。例如“/”。如果留空,则不显示前缀。").default(""), waitTimeout: Schema.number().default(50).max(120).min(10).step(1).description("等待输入图片的最大时间(秒)"), }).description('基础设置'), Schema.object({ usedReverse: Schema.boolean().default(false).description("开启后,在不指定选项时,默认使用`倒放`效果。<br>关闭后,在不指定选项时,执行`-h`选项查看帮助"), outputinformation: Schema.boolean().default(true).description("开启后,在生成图片后,带上图片信息`自动 -i 选项`。<br>否则只会输出处理后的GIF图片"), fillcolor: Schema.string().role('color').description("GIF图片的底色。默认透明。").default("rgba(255, 255, 255, 0)"), maxFps: Schema.number().default(50).max(50).min(10).step(1).description("限制输出 GIF 的最大帧率,防止卡顿、掉帧。"), staticImageFps: Schema.number().default(30).max(50).min(10).step(1).description("静态图默认的FPS。`不影响GIF,GIF使用原始帧率`"), }).description('进阶设置'), Schema.object({ loggerinfo: Schema.boolean().default(false).description("日志调试模式"), }).description('开发者选项'), ]) export function apply(ctx: Context, config) { const TMP_DIR = tmpdir() const logger = ctx.logger('gif-reverse') ctx.i18n.define("zh-CN", { commands: { [config.gifCommand]: { arguments: { args: "图片消息", }, description: "GIF 图片处理", examples: '➣注意:选项参数与图片参数之间有空格\n' + `回弹:${config.commandPrefix}${config.gifCommand} -b [图片]\n` + `倒放:${config.commandPrefix}${config.gifCommand} -r [图片]\n` + `指定帧间隔:${config.commandPrefix}${config.gifCommand} -f 20 [图片]\n` + `右滑:${config.commandPrefix}${config.gifCommand} -l 右 [图片]\n` + `逆时针旋转:${config.commandPrefix}${config.gifCommand} -o 逆 [图片]\n` + `转向30度:${config.commandPrefix}${config.gifCommand} -t 30 [图片]\n` + `转向向左上:${config.commandPrefix}${config.gifCommand} -t 左上 [图片]\n` + `45度右滑:${config.commandPrefix}${config.gifCommand} -l 右 -t 45 [图片]\n` + `顺时针旋转:${config.commandPrefix}${config.gifCommand} -o 顺 [图片]\n` + `上下震动:${config.commandPrefix}${config.gifCommand} -s [图片]\n` + `显示图片信息: ${config.commandPrefix}${config.gifCommand} -i [图片]`, messages: { "invalidFFmpeg": "没有安装 FFmpeg 服务!", "invalidFrame": "帧间隔必须是正整数", "waitprompt": "在 {0} 秒内发送想要处理的图片", "invalidimage": "未检测到图片输入,请重试。", "invalidGIF": "无法处理此图片格式,请使用 GIF、JPEG、PNG 或 WebP 格式。", "generatefailed": "图片生成失败。", "invalidDirection": "无效的方向参数,请选择:左、右、上、下", "invalidRotation": "无效的旋转方向,请选择:顺、逆", "invalidTurn": "无效的转向角度,请输入 0-360 之间的数字,或 上/下/左/右/左上/左下/右上/右下", "information": "\n图片信息:\n文件大小:{0} KB\n图片尺寸:{1}x{2}\n帧数:{3}\n帧间隔:{4} 毫秒\n帧率:{5} FPS\n总时长:{6} 秒\n", }, options: { help: "查看指令帮助", rebound: "回弹效果(正放+倒放)", reverse: " 倒放 GIF", frame: "指定gif的帧间隔(毫秒,正整数)", slide: "滑动方向 (上/下/左/右)", rotate: "旋转方向 (顺/逆)", turn: "转向角度 (上/下/左/右/左上/左下/右上/右下/0-360)", shake: "上下震动效果", information: "显示图片信息", } }, } }); ctx.i18n.define("en-US", { commands: { [config.gifCommand]: { arguments: { args: "image", }, description: "GIF Image Processing", examples: 'Note: There is a space between the option and the image argument.\n' + `Rebound: ${config.commandPrefix}${config.gifCommand} -b [image]\n` + `Reverse: ${config.commandPrefix}${config.gifCommand} -r [image]\n` + `Set frame delay: ${config.commandPrefix}${config.gifCommand} -f 20 [image]\n` + `Slide right: ${config.commandPrefix}${config.gifCommand} -l right [image]\n` + `Rotate counter-clockwise: ${config.commandPrefix}${config.gifCommand} -o ccw [image]\n` + `Turn 30 degrees: ${config.commandPrefix}${config.gifCommand} -t 30 [image]\n` + `Turn to top-left: ${config.commandPrefix}${config.gifCommand} -t top-left [image]\n` + `Slide right at 45 degrees: ${config.commandPrefix}${config.gifCommand} -l right -t 45 [image]\n` + `Rotate clockwise: ${config.commandPrefix}${config.gifCommand} -o cw [image]\n` + `Shake vertically: ${config.commandPrefix}${config.gifCommand} -s [image]\n` + `Show image info: ${config.commandPrefix}${config.gifCommand} -i [image]`, messages: { "invalidFFmpeg": "FFmpeg service is not installed!", "invalidFrame": "Frame delay must be a positive integer.", "waitprompt": "Please send the image to process within {0} seconds.", "invalidimage": "No image input detected, please try again.", "invalidGIF": "Cannot process this image format. Please use GIF, JPEG, PNG, or WebP.", "generatefailed": "Image generation failed.", "invalidDirection": "Invalid direction. Please choose from: left, right, up, down.", "invalidRotation": "Invalid rotation direction. Please choose from: cw (clockwise), ccw (counter-clockwise).", "invalidTurn": "Invalid turn angle. Please enter a number between 0-360, or up/down/left/right/top-left/bottom-left/top-right/bottom-right.", "information": "\nImage Info:\nFile Size: {0} KB\nDimensions: {1}x{2}\nFrames: {3}\nFrame Delay: {4} ms\nFrame Rate: {5} FPS\nTotal Duration: {6} s\n", }, options: { help: "Show command help.", rebound: "Rebound effect (forward + reverse).", reverse: "Reverse the GIF.", frame: "Specify the frame delay for the GIF (in milliseconds, positive integer).", slide: "Sliding direction (up/down/left/right).", rotate: "Rotation direction (cw/ccw).", turn: "Turn angle (up/down/left/right/top-left/bottom-left/top-right/bottom-right/0-360).", shake: "Vertical shake effect.", information: "Show image information.", } }, } }); ctx.command(`${config.gifCommand} [...args]`) .option('rebound', '-b, --rebound', { type: 'boolean' }) .option('reverse', '-r, --reverse', { type: 'boolean' }) .option('frame', '-f <frame:number>', { type: 'number' }) .option('slide', '-l <direction:string>', { type: 'string' }) .option('rotate', '-o <direction:string>', { type: 'string' }) .option('turn', '-t <angle:string>', { type: 'string' }) .option('shake', '-s, --shake', { type: 'boolean' }) .option('information', '-i, --information', { type: 'boolean' }) .action(async ({ session, options, args }) => { let { reverse, rebound, frame, slide, rotate, turn, shake, information } = options const fillcolorHex = rgbaToHex(config.fillcolor); logInfo(options) logInfo("使用的底色:", config.fillcolor, " -> ", fillcolorHex) if (!ctx.ffmpeg) { await session.send(session.text(".invalidFFmpeg")); return } if (Object.keys(options).length === 0) { if (config.usedReverse) { reverse = true } else { await session.execute(`${config.gifCommand} -h`) return } } if (frame && (!Number.isInteger(frame) || frame <= 0)) { await session.send(session.text(".invalidFrame")); return } // 智能查找图片参数 let src: string | undefined; // 检查参数中是否有图片链接 for (const arg of args) { if (arg && typeof arg === 'string') { const imgSrc = h.select(arg, 'img').map(item => item.attrs.src)[0] || h.select(arg, 'mface').map(item => item.attrs.url)[0]; if (imgSrc) { src = imgSrc; break; } } } // 检查消息内容中是否有图片 if (!src) { src = h.select(session.content, 'img').map(item => item.attrs.src)[0] || h.select(session.content, 'mface').map(item => item.attrs.url)[0]; } // 检查引用消息中是否有图片 if (!src && session.quote) { src = h.select(session.quote.content, 'img').map(item => item.attrs.src)[0] || h.select(session.quote.content, 'mface').map(item => item.attrs.url)[0]; } if (!src) { logInfo("暂未输入图片,即将交互获取图片输入"); } else { logInfo(src.slice(0, 200)); } if (!src) { const [msgId] = await session.send(session.text(".waitprompt", [config.waitTimeout])) const promptcontent = await session.prompt(config.waitTimeout * 1000) if (promptcontent !== undefined) { src = h.select(promptcontent, 'img')[0]?.attrs.src || h.select(promptcontent, 'mface')[0]?.attrs.url } try { await session.bot.deleteMessage(session.channelId, msgId) } catch { ctx.logger.warn(`在频道 ${session.channelId} 尝试撤回消息ID ${msgId} 失败。`) } } const quote = h.quote(session.messageId) if (!src) { await session.send(`${quote}${session.text(".invalidimage")}`); return } const file = await ctx.http.file(src) logInfo(file) // 检查是否为支持的图片格式 const isGif = ['image/gif', 'application/octet-stream', 'video/mp4'].includes(file.type) let isStaticImage = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'].includes(file.type) if (!isGif && !isStaticImage) { await session.send(`${quote}${session.text(".invalidGIF")}`); return } // 静态图不支持的选项检查 if (isStaticImage && (rebound || reverse)) { await session.send(`${quote}静态图片不支持回弹和倒放效果,请使用滑动、旋转或转向效果。`); return } let path = join(TMP_DIR, `gif-reverse-${Date.now()}`) await writeFile(path, Buffer.from(file.data)) // 获取图片信息 let gifDuration = 0; let fps = config.staticImageFps; // 静态图默认30fps,GIF使用原始帧率 let frameCount = 0; let frameDelays: number[] = []; let fileSizeInKB = (Buffer.from(file.data).length / 1024).toFixed(2); let originalWidth = 0; let originalHeight = 0; try { // 获取图片尺寸 const canvasimage = await ctx.canvas.loadImage(src); // @ts-ignore originalWidth = canvasimage.naturalWidth || canvasimage.width; // @ts-ignore originalHeight = canvasimage.naturalHeight || canvasimage.height; if (isGif) { // GIF 信息解析 const gifData = await readFile(path); const gif = parseGIF(Buffer.from(gifData).buffer.slice(0)); const frames = decompressFrames(gif, true); frameCount = frames.length; frameDelays = frames.map(frame => frame.delay); const totalDelay = frameDelays.reduce((a, b) => a + b, 0); gifDuration = totalDelay / 1000; // 转换为秒 // 检测是否为单帧GIF if (frameCount <= 1) { // 单帧GIF作为静态图处理 logInfo(`检测到单帧GIF,将作为静态图处理`); // 将单帧GIF转换为PNG const pngPath = join(TMP_DIR, `gif-reverse-png-${Date.now()}.png`); try { const pngBuilder = ctx.ffmpeg.builder(); pngBuilder.input(path); // 使用本地临时文件路径 pngBuilder.outputOption('-vframes', '1'); // 只取第一帧 pngBuilder.outputOption('-f', 'image2'); // 指定输出为图片格式 pngBuilder.outputOption('-c:v', 'png'); // 强制使用PNG编码器 pngBuilder.outputOption('-update', '1'); // 强制更新输出 pngBuilder.outputOption('-pix_fmt', 'rgba'); // 确保保留透明度 // 运行转换并获取PNG格式的buffer const pngBuffer = await pngBuilder.run('buffer'); if (pngBuffer.length === 0) { logger.error('FFmpeg 返回空 buffer') await session.send(`${quote}${session.text(".generatefailed")}`); return } // 写入新文件 await writeFile(pngPath, pngBuffer); // 删除原GIF文件 await unlink(path); // 更新路径指向新的PNG文件 path = pngPath; logInfo(`单帧GIF已提取为PNG: ${pngPath}`); } catch (e) { logger.error('单帧GIF提取失败', e); // 即使转换失败,也继续尝试处理 } frameCount = 1; gifDuration = 2; // 静态图默认2秒循环 frameDelays = [2000]; // 2秒 // 标记为静态图处理 isStaticImage = true; // 如果选择了倒放或回弹,提示这些效果不适用于单帧图像 if (rebound || reverse) { await session.send(`${quote}检测到单帧GIF,将作为静态图处理。回弹和倒放效果不适用,请使用滑动、旋转或转向效果。`); rebound = false; reverse = false; } } else { // 计算帧率 if (gifDuration > 0) { fps = frames.length / gifDuration; } logInfo(`GIF 帧率: ${fps}, 帧数: ${frameCount}`); } } else { // 静态图信息 frameCount = 1; gifDuration = 2; // 静态图默认2秒循环 frameDelays = [2000]; // 2秒 logInfo(`静态图片,将生成2秒循环的动画,30fps`); } } catch (error) { logger.error("解析图片时发生错误:", error); await session.send(`${quote}${session.text(".generatefailed")}`); return; } if (information) { // 显示图片信息 const totalDelay = frameDelays.reduce((a, b) => a + b, 0); const averageFrameDelay = frameCount > 0 ? (totalDelay / frameCount).toFixed(2) : 0; await unlink(path); const imageType = isGif ? "GIF" : "静态图片"; const infoText = isGif ? session.text(".information", [fileSizeInKB, originalWidth, originalHeight, frameCount, averageFrameDelay, fps.toFixed(2), gifDuration.toFixed(2)]) : `\n${imageType} 信息:\n文件大小:${fileSizeInKB} KB\n图片尺寸:${originalWidth}x${originalHeight}\n图片格式:${file.type}\n`; return [infoText]; } let vf = '' const filters: string[] = [] let totalDuration = gifDuration; let outputFps = fps; // 静态图处理需要特殊的builder配置 const isStaticProcessing = isStaticImage; // 获取命令行参数的原始顺序 const optionOrder = []; // 从原始命令中提取选项顺序 const rawCommand = session.content || ''; const optionMatches = rawCommand.match(/\s-[a-z]|\s--[a-z-]+/g) || []; for (const match of optionMatches) { const option = match.trim(); if (option === '-l' || option === '--slide') { optionOrder.push('slide'); } else if (option === '-t' || option === '--turn') { optionOrder.push('turn'); } else if (option === '-s' || option === '--shake') { optionOrder.push('shake'); } else if (option === '-o' || option === '--rotate') { optionOrder.push('rotate'); } else if (option === '-f' || option === '--frame') { optionOrder.push('frame'); } else if (option === '-r' || option === '--reverse') { optionOrder.push('reverse'); } else if (option === '-b' || option === '--rebound') { optionOrder.push('rebound'); } } logInfo('选项处理顺序:', optionOrder); // 创建效果处理函数映射 const effectHandlers = { // 回弹效果处理 (仅GIF支持) rebound: () => { if (rebound && isGif) { totalDuration = gifDuration * 2; // 总时长为原时长两倍 filters.push( '[0]split[main][back];' + '[back]reverse[reversed];' + '[main][reversed]concat=n=2:v=1' ); logInfo('应用回弹效果'); } }, // 倒放效果处理 reverse: () => { if (reverse && isGif) { filters.push('reverse'); logInfo('应用倒放效果'); } }, // 应用 frame 效果 frame: () => { if (frame) { if (isStaticProcessing) { // 静态图的帧间隔处理:直接调整动画时长 const targetFrameDelay = frame; // 目标帧间隔(ms) const targetFps = 1000 / targetFrameDelay; // 目标帧率 totalDuration = 2; // 保持2秒循环 outputFps = Math.min(targetFps, config.maxFps); // 限制最大帧率 logInfo(`静态图帧间隔调整,目标帧间隔: ${frame}ms,目标帧率: ${targetFps},实际帧率: ${outputFps}`); } else { // GIF的帧间隔处理 const originalAverageFrameDelay = frameDelays.reduce((a, b) => a + b, 0) / frameCount; const speedRatio = frame / originalAverageFrameDelay; // 计算速度比例 totalDuration = gifDuration * speedRatio; // 更新总时长 outputFps = fps / speedRatio; // 更新帧率 filters.push(`setpts=PTS*${speedRatio}`); // 调整时间戳实现变速 logInfo(`GIF帧间隔调整,原帧间隔: ${originalAverageFrameDelay}ms,目标帧间隔: ${frame}ms,速度比例: ${speedRatio},调整后帧率: ${outputFps}`); } } }, // 应用转向效果 turn: () => { if (turn) { let angle: number; switch (turn) { case '上': angle = -90; break; case '下': angle = -270; break; case '左': angle = -180; break; case '右': angle = 0; break; case '左上': angle = -135; break; case '左下': angle = -225; break; case '右上': angle = -45; break; case '右下': angle = -315; break; default: const parsedAngle = parseInt(turn); if (isNaN(parsedAngle) || parsedAngle < 0 || parsedAngle > 360) { throw new Error("invalidTurn"); } angle = -parsedAngle; break; } logInfo(`应用转向效果,角度: ${angle}`); filters.push(`rotate=${angle}*PI/180:fillcolor=${fillcolorHex}`); } }, // 应用旋转效果 rotate: () => { if (rotate) { let rotateAngle = '' const rotationDuration = isStaticProcessing ? totalDuration : gifDuration; switch (rotate) { case '顺': if (isStaticProcessing) { // 静态图旋转 rotateAngle = `rotate=2*PI*t/${rotationDuration}:fillcolor=${fillcolorHex}` } else { rotateAngle = `rotate=${360 / rotationDuration}*t*PI/180:fillcolor=${fillcolorHex}` } logInfo(`应用顺时针旋转效果, 旋转周期: ${rotationDuration}秒`) break case '逆': if (isStaticProcessing) { // 静态图旋转 rotateAngle = `rotate=-2*PI*t/${rotationDuration}:fillcolor=${fillcolorHex}` } else { rotateAngle = `rotate=-${360 / rotationDuration}*t*PI/180:fillcolor=${fillcolorHex}` } logInfo(`应用逆时针旋转效果, 旋转周期: ${rotationDuration}秒`) break default: throw new Error("invalidRotation"); } filters.push(rotateAngle) } }, // 应用上下震动效果 shake: () => { if (shake) { try { let shakeFilter = ''; // 震动幅度按照图片高度的3%计算,最小12像素 const amplitude = Math.max(12, Math.round(originalHeight * 0.03)); // 偏移量为震动幅度的2倍,确保震动范围合理 const offset = amplitude * 2; // 裁剪后的高度 = 原高度 - 震动范围 const cropHeight = originalHeight - (amplitude * 2); if (cropHeight <= 0) { // 图片太小,无法震动 logInfo('图片高度太小,跳过震动效果'); return; } if (isStaticProcessing) { shakeFilter = `crop=${originalWidth}:${cropHeight}:0:'${offset}+${amplitude}*sin(2*PI*t*6)'`; } else { shakeFilter = `crop=${originalWidth}:${cropHeight}:0:'${offset}+${amplitude}*sin(2*PI*t*6)',setpts=PTS-STARTPTS`; } filters.push(shakeFilter); } catch (error) { logger.error("处理上下震动效果时发生错误:", error); throw new Error("generatefailed"); } } }, // 应用滑动效果 slide: () => { if (slide) { try { const outputDuration = totalDuration; // 滑动效果不改变总时长 const totalFrames = Math.ceil(outputDuration * outputFps); // 向上取整 logInfo(`输出时长: ${outputDuration}`); let slideFilter = ''; if (isStaticProcessing) { // 静态图滤镜 switch (slide) { case '左': slideFilter = `split[a][b];[a][b]hstack[tiled];[tiled]crop=iw/2:ih:x='t*(iw/2)/${outputDuration}':y=0`; break; case '右': slideFilter = `split[a][b];[a][b]hstack[tiled];[tiled]crop=iw/2:ih:x='iw/2-t*(iw/2)/${outputDuration}':y=0`; break; case '上': slideFilter = `split[a][b];[a][b]vstack[tiled];[tiled]crop=iw:ih/2:x=0:y='t*(ih/2)/${outputDuration}'`; break; case '下': slideFilter = `split[a][b];[a][b]vstack[tiled];[tiled]crop=iw:ih/2:x=0:y='ih/2-t*(ih/2)/${outputDuration}'`; break; default: throw new Error("invalidDirection"); } } else { switch (slide) { case '左': slideFilter = `split[a][b];[a][b]hstack[tiled];[tiled]crop=iw/2:ih:x='t*(iw/2)/${outputDuration}':y=0,setpts=PTS-STARTPTS`; break; case '右': slideFilter = `split[a][b];[a][b]hstack[tiled];[tiled]crop=iw/2:ih:x='iw/2 - t*(iw/2)/${outputDuration}':y=0,setpts=PTS-STARTPTS`; break; case '上': slideFilter = `split[a][b];[a][b]vstack[tiled];[tiled]crop=iw:ih/2:x=0:y='t*(ih/2)/${outputDuration}',setpts=PTS-STARTPTS`; break; case '下': slideFilter = `split[a][b];[a][b]vstack[tiled];[tiled]crop=iw:ih/2:x=0:y='ih/2 - t*(ih/2)/${outputDuration}',setpts=PTS-STARTPTS`; break; default: throw new Error("invalidDirection"); } } filters.push(slideFilter); logInfo(`应用${slide}方向滑动效果,总帧数: ${totalFrames}`); } catch (error) { if (error.message === "invalidDirection") { throw error; } logger.error("处理滑动效果时发生错误:", error); throw new Error("generatefailed"); } } } }; try { // 如果没有检测到选项顺序,使用默认顺序 if (optionOrder.length === 0) { // 默认顺序:先处理时间相关效果,再处理空间变换 if (rebound) effectHandlers.rebound(); if (reverse) effectHandlers.reverse(); if (frame) effectHandlers.frame(); if (turn) effectHandlers.turn(); if (rotate) effectHandlers.rotate(); if (shake) effectHandlers.shake(); if (slide) effectHandlers.slide(); } else { // 按照输入的选项顺序处理 // 先处理时间相关效果(倒放、回弹、帧率) if (optionOrder.includes('rebound')) effectHandlers.rebound(); if (optionOrder.includes('reverse')) effectHandlers.reverse(); if (optionOrder.includes('frame')) effectHandlers.frame(); // 然后按照指定的顺序处理空间变换效果 for (const option of optionOrder) { if (['turn', 'rotate', 'shake', 'slide'].includes(option)) { effectHandlers[option](); } } } } catch (error) { if (error.message === "invalidDirection") { await session.send(`${quote}${session.text(".invalidDirection")}`); return; } else if (error.message === "invalidRotation") { await session.send(`${quote}${session.text(".invalidRotation")}`); return; } else if (error.message === "invalidTurn") { await session.send(`${quote}${session.text(".invalidTurn")}`); return; } else { await session.send(`${quote}${session.text(".generatefailed")}`); return; } } filters.push('split[s0][s1];[s0]palettegen=stats_mode=full:reserve_transparent=on[p];[s1][p]paletteuse=new=1:dither=none') vf = filters.filter(f => f).join(',') // 限制最大帧率 if (outputFps > config.maxFps) { logInfo(`帧率超过限制(${config.maxFps} FPS),降至 ${config.maxFps} FPS`); outputFps = config.maxFps; } const builder = ctx.ffmpeg.builder() if (isStaticProcessing) { // 静态图需要特殊的输入参数 builder.input(path) builder.inputOption('-loop', '1') // 循环读取静态图 builder.inputOption('-t', String(totalDuration)) // 设置输入时长 builder.outputOption('-r', String(outputFps.toFixed(2)), '-loop', '0'); // 输出帧率和循环 } else { // GIF使用原有逻辑 builder.input(path) builder.outputOption('-r', String(outputFps.toFixed(2)), '-loop', '0'); // 使用获取到的帧率 } if (vf) { logInfo(`使用的滤镜: ${vf}`) builder.outputOption('-filter_complex', vf, '-f', 'gif') } else { builder.outputOption('-f', 'gif') } let buffer try { buffer = await builder.run('buffer') } catch (e) { logger.error('FFmpeg 执行失败', e) await unlink(path) await session.send(`${quote}${session.text(".generatefailed")}`); return } await unlink(path) if (buffer.length === 0) { logger.error('FFmpeg 返回空 buffer') await session.send(`${quote}${session.text(".generatefailed")}`); return } logInfo(`GIF 处理成功,选项: ${JSON.stringify(options)}`) const img = h.img(buffer, 'image/gif'); if (config.outputinformation) { const info = await session.execute(`${config.gifCommand} ${img} -i`, true); await session.send([quote, img, `${info}`]); return } else { await session.send([quote, img]); return } }) // 颜色转换函数 function rgbaToHex(rgba: string): string { // rgba(255, 0, 0, 1) const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (!match) { return '0x00000000'; // 默认透明 } const r = parseInt(match[1]); const g = parseInt(match[2]); const b = parseInt(match[3]); const a = parseFloat(match[4] || '1'); // 默认不透明 const rHex = r.toString(16).padStart(2, '0'); const gHex = g.toString(16).padStart(2, '0'); const bHex = b.toString(16).padStart(2, '0'); const aHex = Math.round(a * 255).toString(16).padStart(2, '0'); return `0x${rHex}${gHex}${bHex}${aHex}`; } function logInfo(...args: any[]) { if (config.loggerinfo) { (logger.info as (...args: any[]) => void)(...args); } } }