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
768 lines (747 loc) • 31.4 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name,
usage: () => usage
});
module.exports = __toCommonJS(src_exports);
var import_koishi = require("koishi");
var import_promises = require("node:fs/promises");
var import_node_os = require("node:os");
var import_node_path = require("node:path");
var import_gifuct_js = require("gifuct-js");
var name = "gif-reverse";
var inject = {
required: ["http", "i18n", "logger", "ffmpeg", "canvas"]
};
var 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> 查看指令用法
---
`;
var Config = import_koishi.Schema.intersect([
import_koishi.Schema.object({
gifCommand: import_koishi.Schema.string().default("gif-reverse").description("注册的指令名称"),
commandPrefix: import_koishi.Schema.string().description("在指令示例中显示的指令前缀。例如“/”。如果留空,则不显示前缀。").default(""),
waitTimeout: import_koishi.Schema.number().default(50).max(120).min(10).step(1).description("等待输入图片的最大时间(秒)")
}).description("基础设置"),
import_koishi.Schema.object({
usedReverse: import_koishi.Schema.boolean().default(false).description("开启后,在不指定选项时,默认使用`倒放`效果。<br>关闭后,在不指定选项时,执行`-h`选项查看帮助"),
outputinformation: import_koishi.Schema.boolean().default(true).description("开启后,在生成图片后,带上图片信息`自动 -i 选项`。<br>否则只会输出处理后的GIF图片"),
fillcolor: import_koishi.Schema.string().role("color").description("GIF图片的底色。默认透明。").default("rgba(255, 255, 255, 0)"),
maxFps: import_koishi.Schema.number().default(50).max(50).min(10).step(1).description("限制输出 GIF 的最大帧率,防止卡顿、掉帧。"),
staticImageFps: import_koishi.Schema.number().default(30).max(50).min(10).step(1).description("静态图默认的FPS。`不影响GIF,GIF使用原始帧率`")
}).description("进阶设置"),
import_koishi.Schema.object({
loggerinfo: import_koishi.Schema.boolean().default(false).description("日志调试模式")
}).description("开发者选项")
]);
function apply(ctx, config) {
const TMP_DIR = (0, import_node_os.tmpdir)();
const logger = ctx.logger("gif-reverse");
ctx.i18n.define("zh-CN", {
commands: {
[config.gifCommand]: {
arguments: {
args: "图片消息"
},
description: "GIF 图片处理",
examples: `➣注意:选项参数与图片参数之间有空格
回弹:${config.commandPrefix}${config.gifCommand} -b [图片]
倒放:${config.commandPrefix}${config.gifCommand} -r [图片]
指定帧间隔:${config.commandPrefix}${config.gifCommand} -f 20 [图片]
右滑:${config.commandPrefix}${config.gifCommand} -l 右 [图片]
逆时针旋转:${config.commandPrefix}${config.gifCommand} -o 逆 [图片]
转向30度:${config.commandPrefix}${config.gifCommand} -t 30 [图片]
转向向左上:${config.commandPrefix}${config.gifCommand} -t 左上 [图片]
45度右滑:${config.commandPrefix}${config.gifCommand} -l 右 -t 45 [图片]
顺时针旋转:${config.commandPrefix}${config.gifCommand} -o 顺 [图片]
上下震动:${config.commandPrefix}${config.gifCommand} -s [图片]
显示图片信息: ${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.
Rebound: ${config.commandPrefix}${config.gifCommand} -b [image]
Reverse: ${config.commandPrefix}${config.gifCommand} -r [image]
Set frame delay: ${config.commandPrefix}${config.gifCommand} -f 20 [image]
Slide right: ${config.commandPrefix}${config.gifCommand} -l right [image]
Rotate counter-clockwise: ${config.commandPrefix}${config.gifCommand} -o ccw [image]
Turn 30 degrees: ${config.commandPrefix}${config.gifCommand} -t 30 [image]
Turn to top-left: ${config.commandPrefix}${config.gifCommand} -t top-left [image]
Slide right at 45 degrees: ${config.commandPrefix}${config.gifCommand} -l right -t 45 [image]
Rotate clockwise: ${config.commandPrefix}${config.gifCommand} -o cw [image]
Shake vertically: ${config.commandPrefix}${config.gifCommand} -s [image]
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;
for (const arg of args) {
if (arg && typeof arg === "string") {
const imgSrc = import_koishi.h.select(arg, "img").map((item) => item.attrs.src)[0] || import_koishi.h.select(arg, "mface").map((item) => item.attrs.url)[0];
if (imgSrc) {
src = imgSrc;
break;
}
}
}
if (!src) {
src = import_koishi.h.select(session.content, "img").map((item) => item.attrs.src)[0] || import_koishi.h.select(session.content, "mface").map((item) => item.attrs.url)[0];
}
if (!src && session.quote) {
src = import_koishi.h.select(session.quote.content, "img").map((item) => item.attrs.src)[0] || import_koishi.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 * 1e3);
if (promptcontent !== void 0) {
src = import_koishi.h.select(promptcontent, "img")[0]?.attrs.src || import_koishi.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 = import_koishi.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 = (0, import_node_path.join)(TMP_DIR, `gif-reverse-${Date.now()}`);
await (0, import_promises.writeFile)(path, Buffer.from(file.data));
let gifDuration = 0;
let fps = config.staticImageFps;
let frameCount = 0;
let frameDelays = [];
let fileSizeInKB = (Buffer.from(file.data).length / 1024).toFixed(2);
let originalWidth = 0;
let originalHeight = 0;
try {
const canvasimage = await ctx.canvas.loadImage(src);
originalWidth = canvasimage.naturalWidth || canvasimage.width;
originalHeight = canvasimage.naturalHeight || canvasimage.height;
if (isGif) {
const gifData = await (0, import_promises.readFile)(path);
const gif = (0, import_gifuct_js.parseGIF)(Buffer.from(gifData).buffer.slice(0));
const frames = (0, import_gifuct_js.decompressFrames)(gif, true);
frameCount = frames.length;
frameDelays = frames.map((frame2) => frame2.delay);
const totalDelay = frameDelays.reduce((a, b) => a + b, 0);
gifDuration = totalDelay / 1e3;
if (frameCount <= 1) {
logInfo(`检测到单帧GIF,将作为静态图处理`);
const pngPath = (0, import_node_path.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");
pngBuilder.outputOption("-update", "1");
pngBuilder.outputOption("-pix_fmt", "rgba");
const pngBuffer = await pngBuilder.run("buffer");
if (pngBuffer.length === 0) {
logger.error("FFmpeg 返回空 buffer");
await session.send(`${quote}${session.text(".generatefailed")}`);
return;
}
await (0, import_promises.writeFile)(pngPath, pngBuffer);
await (0, import_promises.unlink)(path);
path = pngPath;
logInfo(`单帧GIF已提取为PNG: ${pngPath}`);
} catch (e) {
logger.error("单帧GIF提取失败", e);
}
frameCount = 1;
gifDuration = 2;
frameDelays = [2e3];
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;
frameDelays = [2e3];
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 (0, import_promises.unlink)(path);
const imageType = isGif ? "GIF" : "静态图片";
const infoText = isGif ? session.text(".information", [fileSizeInKB, originalWidth, originalHeight, frameCount, averageFrameDelay, fps.toFixed(2), gifDuration.toFixed(2)]) : `
${imageType} 信息:
文件大小:${fileSizeInKB} KB
图片尺寸:${originalWidth}x${originalHeight}
图片格式:${file.type}
`;
return [infoText];
}
let vf = "";
const filters = [];
let totalDuration = gifDuration;
let outputFps = fps;
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: /* @__PURE__ */ __name(() => {
if (rebound && isGif) {
totalDuration = gifDuration * 2;
filters.push(
"[0]split[main][back];[back]reverse[reversed];[main][reversed]concat=n=2:v=1"
);
logInfo("应用回弹效果");
}
}, "rebound"),
// 倒放效果处理
reverse: /* @__PURE__ */ __name(() => {
if (reverse && isGif) {
filters.push("reverse");
logInfo("应用倒放效果");
}
}, "reverse"),
// 应用 frame 效果
frame: /* @__PURE__ */ __name(() => {
if (frame) {
if (isStaticProcessing) {
const targetFrameDelay = frame;
const targetFps = 1e3 / targetFrameDelay;
totalDuration = 2;
outputFps = Math.min(targetFps, config.maxFps);
logInfo(`静态图帧间隔调整,目标帧间隔: ${frame}ms,目标帧率: ${targetFps},实际帧率: ${outputFps}`);
} else {
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}`);
}
}
}, "frame"),
// 应用转向效果
turn: /* @__PURE__ */ __name(() => {
if (turn) {
let angle;
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}`);
}
}, "turn"),
// 应用旋转效果
rotate: /* @__PURE__ */ __name(() => {
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);
}
}, "rotate"),
// 应用上下震动效果
shake: /* @__PURE__ */ __name(() => {
if (shake) {
try {
let shakeFilter = "";
const amplitude = Math.max(12, Math.round(originalHeight * 0.03));
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");
}
}
}, "shake"),
// 应用滑动效果
slide: /* @__PURE__ */ __name(() => {
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");
}
}
}, "slide")
};
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 {
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 (0, import_promises.unlink)(path);
await session.send(`${quote}${session.text(".generatefailed")}`);
return;
}
await (0, import_promises.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 = import_koishi.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) {
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}`;
}
__name(rgbaToHex, "rgbaToHex");
function logInfo(...args) {
if (config.loggerinfo) {
logger.info(...args);
}
}
__name(logInfo, "logInfo");
}
__name(apply, "apply");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Config,
apply,
inject,
name,
usage
});