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

522 lines (460 loc) 18.5 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) (需要额外安装) - [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自带) --- <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>--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>显示 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("注册的指令名称"), waitTimeout: Schema.number().default(50).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).description("限制输出 GIF 的最大帧率,防止卡顿、掉帧。<br>一般超过`50 FPS`的`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: { gif: "图片消息", }, description: "GIF 图片处理", messages: { "invalidFFmpeg": "没有安装 FFmpeg 服务!", "invalidFrame": "帧间隔必须是正整数", "waitprompt": "在 {0} 秒内发送想要处理的 GIF", "invalidimage": "未检测到图片输入,请重试。", "invalidGIF": "无法处理非 GIF 图片。", "generatefailed": "图片生成失败。", "invalidDirection": "无效的方向参数,请选择:左、右、上、下", "invalidRotation": "无效的旋转方向,请选择:顺、逆", "invalidTurn": "无效的转向角度,请输入 0-360 之间的数字,或 上/下/左/右/左上/左下/右上/右下", "information": "\nGIF 信息:\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)", information: "显示 GIF 信息", } }, } }); ctx.command(`${config.gifCommand} [gif]`) .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('information', '-i, --information', { type: 'boolean' }) .example(`回弹:${config.gifCommand} -b`) .example(`倒放:${config.gifCommand} -r`) .example(`指定帧间隔:${config.gifCommand} -f 20`) .example(`右滑:${config.gifCommand} -l 右`) .example(`逆时针旋转:${config.gifCommand} -o 逆`) .example(`转向30度:${config.gifCommand} -t 30`) .example(`转向向左上:${config.gifCommand} -t 左上`) .example(`45度右滑:${config.gifCommand} -l 右 -t 45`) .example(`顺时针旋转:${config.gifCommand} -o 顺`) .example(`显示 GIF 信息: ${config.gifCommand} -i`) .action(async ({ session, options }, gif) => { let { reverse, rebound, frame, slide, rotate, turn, 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 = ( h.select(gif, 'img').map(item => item.attrs.src)[0] || h.select(session.quote?.content, "img").map((a) => a.attrs.src)[0] || h.select(session.quote?.content, "mface").map((a) => a.attrs.url)[0] ); if (!src) { logInfo("暂未输入图片,即将交互获取图片输入"); } else { logInfo(src.slice(0, 500)); } 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 { 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) if (!['image/gif', 'application/octet-stream', 'video/mp4'].includes(file.type)) { await session.send(`${quote}${session.text(".invalidGIF")}`); return } const path = join(TMP_DIR, `gif-reverse-${Date.now()}`) await writeFile(path, Buffer.from(file.data)) // 获取 GIF 信息 let gifDuration = 0; let fps = 20; // 默认帧率 let frameCount = 0; let frameDelays: number[] = []; let fileSizeInKB = (Buffer.from(file.data).length / 1024).toFixed(2); let originalWidth = 0; let originalHeight = 0; try { const gifData = await readFile(path); const gif = parseGIF(gifData); const frames = decompressFrames(gif, true); frameCount = frames.length; frameDelays = frames.map(frame => frame.delay); const totalDelay = frameDelays.reduce((a, b) => a + b, 0); // const averageFrameDelay = frameCount > 0 ? totalDelay / frameCount : 0; gifDuration = totalDelay / 1000; // 转换为秒 // 计算帧率 if (frames.length > 0 && gifDuration > 0) { fps = frames.length / gifDuration; } logInfo(`GIF 帧率: ${fps}`); // 获取图片尺寸 const canvasimage = await ctx.canvas.loadImage(src); // @ts-ignore originalWidth = canvasimage.naturalWidth || canvasimage.width; // @ts-ignore originalHeight = canvasimage.naturalHeight || canvasimage.height; } catch (error) { logger.error("解析 GIF 时发生错误:", error); await session.send(`${quote}${session.text(".generatefailed")}`); return; } if (information) { // 显示 GIF 信息 const totalDelay = frameDelays.reduce((a, b) => a + b, 0); const averageFrameDelay = frameCount > 0 ? (totalDelay / frameCount).toFixed(2) : 0; unlink(path); return [ // quote, session.text(".information", [fileSizeInKB, originalWidth, originalHeight, frameCount, averageFrameDelay, fps.toFixed(2), gifDuration.toFixed(2)]), // h.image(src), ]; } let vf = '' const filters: string[] = [] let totalDuration = gifDuration; let outputFps = fps; // 应用 slide 效果 (必须在 speed 之前) if (slide) { try { const outputDuration = totalDuration; // 滑动效果不改变总时长 const totalFrames = Math.ceil(outputDuration * outputFps); // 向上 取整 logInfo(`输出时长: ${outputDuration}`); let slideFilter = ''; 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: await session.send(`${quote}${session.text(".invalidDirection")}`); return; } filters.push(slideFilter); logInfo(`应用${slide}方向滑动效果,总帧数: ${totalFrames}`); } catch (error) { logger.error("解析 GIF 时发生错误:", error); await session.send(`${quote}${session.text(".generatefailed")}`); return; } } // 回弹效果处理 if (rebound) { totalDuration = gifDuration * 2; // 总时长为原时长两倍 filters.push( '[0]split[main][back];' + '[back]reverse[reversed];' + '[main][reversed]concat=n=2:v=1' ); } else if (reverse) { filters.push('reverse'); } // 应用 frame 效果 if (frame) { 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(`应用帧间隔调整,原帧间隔: ${originalAverageFrameDelay}ms,目标帧间隔: ${frame}ms,速度比例: ${speedRatio},调整后帧率: ${outputFps}`); } if (rotate) { let rotateAngle = '' switch (rotate) { case '顺': rotateAngle = `rotate=${360 / gifDuration}*t*PI/180:fillcolor=${fillcolorHex}` logInfo(`应用顺时针旋转效果, 旋转速度: ${360 / gifDuration} 度/秒`) break case '逆': rotateAngle = `rotate=-${360 / gifDuration}*t*PI/180:fillcolor=${fillcolorHex}` logInfo(`应用逆时针旋转效果, 旋转速度: ${360 / gifDuration} 度/秒`) break default: await session.send(`${quote}${session.text(".invalidRotation")}`); return } filters.push(rotateAngle) } // 应用转向效果 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) { await session.send(`${quote}${session.text(".invalidTurn")}`); return; } angle = -parsedAngle; break; } logInfo(`应用转向效果,角度: ${angle}`); filters.push(`rotate=${angle}*PI/180:fillcolor=${fillcolorHex}`); } 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().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) unlink(path) await session.send(`${quote}${session.text(".generatefailed")}`); return } 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); } } }