Compare commits

..

2 Commits

Author SHA1 Message Date
f7212d6f67 Merge pull request 'feat: add --pingpong flag to giftool' (#64) from pi-agent/konabot:feat/giftool-pingpong into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #64
2026-04-02 20:17:25 +08:00
b87c58485c feat: add --pingpong flag to giftool
Add pingpong mode to giftool command. When --pingpong flag is used,
the generated GIF will play forward then backward, creating a
back-and-forth looping effect.

Features:
- Add --pingpong option to giftool command
- Support combining with --speed for adjusted playback speed
- Update documentation with new option

Examples:
- giftool [图片] --pingpong
- giftool [图片] --pingpong --speed 2.0
2026-04-02 20:06:15 +08:00
5 changed files with 49 additions and 141 deletions

View File

@ -1,3 +1,16 @@
FROM alpine:latest AS artifacts
RUN apk add --no-cache curl xz
WORKDIR /tmp
RUN mkdir -p /artifacts
RUN curl -L -o typst.tar.xz "https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz" \
&& tar -xJf typst.tar.xz \
&& mv typst-x86_64-unknown-linux-musl/typst /artifacts
RUN chmod -R +x /artifacts/
FROM python:3.13-slim AS base FROM python:3.13-slim AS base
ENV VIRTUAL_ENV=/app/.venv \ ENV VIRTUAL_ENV=/app/.venv \
@ -38,6 +51,7 @@ RUN uv sync --no-install-project
FROM base AS runtime FROM base AS runtime
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --from=artifacts /artifacts/ /usr/local/bin/
WORKDIR /app WORKDIR /app

View File

@ -1,10 +1,9 @@
import asyncio import asyncio
from typing import Any, Awaitable, Callable
import aiohttp import aiohttp
import hashlib import hashlib
import platform import platform
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import nonebot import nonebot
@ -15,8 +14,6 @@ from pydantic import BaseModel
@dataclass @dataclass
class ArtifactDepends: class ArtifactDepends:
_Callback = Callable[[bool], Awaitable[Any]]
url: str url: str
sha256: str sha256: str
target: Path target: Path
@ -30,9 +27,6 @@ class ArtifactDepends:
use_proxy: bool = True use_proxy: bool = True
"网络问题,赫赫;使用的是 Discord 模块配置的 proxy" "网络问题,赫赫;使用的是 Discord 模块配置的 proxy"
callbacks: list[_Callback] = field(default_factory=list)
"在任务完成以后,应该做的事情"
def is_corresponding_platform(self) -> bool: def is_corresponding_platform(self) -> bool:
if self.required_os is not None: if self.required_os is not None:
if self.required_os.lower() != platform.system().lower(): if self.required_os.lower() != platform.system().lower():
@ -42,43 +36,26 @@ class ArtifactDepends:
return False return False
return True return True
def on_finished(self, task: _Callback) -> _Callback:
self.callbacks.append(task)
return task
async def _finished(self, downloaded: bool) -> list[Any | BaseException]:
tasks = set()
for f in self.callbacks:
tasks.add(f(downloaded))
return await asyncio.gather(*tasks, return_exceptions=True)
class Config(BaseModel): class Config(BaseModel):
prefetch_artifact: bool = False prefetch_artifact: bool = False
"是否提前下载好二进制依赖" "是否提前下载好二进制依赖"
artifact_list: list[ArtifactDepends] = [] artifact_list = []
driver = nonebot.get_driver() driver = nonebot.get_driver()
config = nonebot.get_plugin_config(Config) config = nonebot.get_plugin_config(Config)
@driver.on_startup @driver.on_startup
async def _(): async def _():
if config.prefetch_artifact: if config.prefetch_artifact:
logger.info("启动检测中:正在检测需求的二进制是否下载") logger.info("启动检测中:正在检测需求的二进制是否下载")
semaphore = asyncio.Semaphore(10) semaphore = asyncio.Semaphore(10)
async def _task(artifact: ArtifactDepends): async def _task(artifact: ArtifactDepends):
async with semaphore: async with semaphore:
downloaded = await ensure_artifact(artifact) await ensure_artifact(artifact)
result = await artifact._finished(downloaded)
for r in result:
if isinstance(r, BaseException):
logger.warning("完成了二进制文件的下载,但是有未捕捉的错误")
logger.exception(r)
tasks: set[asyncio.Task] = set() tasks: set[asyncio.Task] = set()
for a in artifact_list: for a in artifact_list:
@ -101,43 +78,35 @@ async def download_artifact(artifact: ArtifactDepends):
async with aiohttp.ClientSession(proxy=proxy) as client: async with aiohttp.ClientSession(proxy=proxy) as client:
result = await client.get(artifact.url) result = await client.get(artifact.url)
if result.status != 200: if result.status != 200:
logger.warning( logger.warning(f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}")
f"已经下载了二进制,但是注意服务器没有返回 200 URL={artifact.url} TARGET={artifact.target} CODE={result.status}"
)
data = await result.read() data = await result.read()
artifact.target.write_bytes(data) artifact.target.write_bytes(data)
if not platform.system().lower() == "windows": if not platform.system().lower() == 'windows':
artifact.target.chmod(0o755) artifact.target.chmod(0o755)
logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}") logger.info(f"下载好了 TARGET={artifact.target} URL={artifact.url}")
m = hashlib.sha256(artifact.target.read_bytes()) m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower(): if m.hexdigest().lower() != artifact.sha256.lower():
logger.warning( logger.warning(f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}")
f"下载到的二进制的 sha256 与需求不同 TARGET={artifact.target} REQUESTED={artifact.sha256} ACTUAL={m.hexdigest()}"
)
async def ensure_artifact(artifact: ArtifactDepends) -> bool: async def ensure_artifact(artifact: ArtifactDepends):
if not artifact.is_corresponding_platform(): if not artifact.is_corresponding_platform():
return False return
if not artifact.target.exists(): if not artifact.target.exists():
logger.info(f"二进制依赖 {artifact.target} 不存在") logger.info(f"二进制依赖 {artifact.target} 不存在")
if not artifact.target.parent.exists(): if not artifact.target.parent.exists():
artifact.target.parent.mkdir(parents=True, exist_ok=True) artifact.target.parent.mkdir(parents=True, exist_ok=True)
await download_artifact(artifact) await download_artifact(artifact)
return True
else: else:
m = hashlib.sha256(artifact.target.read_bytes()) m = hashlib.sha256(artifact.target.read_bytes())
if m.hexdigest().lower() != artifact.sha256.lower(): if m.hexdigest().lower() != artifact.sha256.lower():
logger.info( logger.info(f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载")
f"二进制依赖 {artifact.target} 的哈希无法对应需求的哈希,准备重新下载"
)
artifact.target.unlink() artifact.target.unlink()
await download_artifact(artifact) await download_artifact(artifact)
return True
return False
def register_artifacts(*artifacts: ArtifactDepends): def register_artifacts(*artifacts: ArtifactDepends):
artifact_list.extend(artifacts) artifact_list.extend(artifacts)

View File

@ -71,6 +71,14 @@ giftool [图片] [选项]
- 调整 GIF 图的速度。若为负数,则代表倒放。 - 调整 GIF 图的速度。若为负数,则代表倒放。
### `--pingpong`(可选)
- 开启乒乓模式,生成正放-倒放拼接的 GIF 图。
- 即播放完正向后,会倒放回去,形成往复循环效果。
- 可与 `--speed` 配合使用,调整播放速度。
- 示例:`giftool [图片] --pingpong`
- 示例:`giftool [图片] --pingpong --speed 2.0`
## 使用方式 ## 使用方式
1. 发送指令前,请确保: 1. 发送指令前,请确保:

View File

@ -34,6 +34,7 @@ cmd_giftool = on_alconna(
Option("-t", Args["length", str]), Option("-t", Args["length", str]),
Option("-to", Args["end_point", str]), Option("-to", Args["end_point", str]),
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]), Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
Option("--pingpong", default=False),
) )
) )
@ -46,6 +47,7 @@ async def _(
length: str | None = None, length: str | None = None,
speed_factor: float = 1.0, speed_factor: float = 1.0,
end_point: str | None = None, end_point: str | None = None,
pingpong: bool = False,
): ):
ss: None | float = None ss: None | float = None
if start_point: if start_point:
@ -162,6 +164,16 @@ async def _(
rframes = rframes[::-1] rframes = rframes[::-1]
rdur_ms = rdur_ms[::-1] rdur_ms = rdur_ms[::-1]
# 处理 pingpong 模式
if pingpong:
# 复制一份反转的帧序列(去掉第一帧避免重复)
pingpong_frames = rframes[1:][::-1] if len(rframes) > 1 else rframes[::-1]
pingpong_durations = rdur_ms[1:][::-1] if len(rdur_ms) > 1 else rdur_ms[::-1]
# 拼接正放和倒放
rframes = rframes + pingpong_frames
rdur_ms = rdur_ms + pingpong_durations
output_img = BytesIO() output_img = BytesIO()
if rframes: if rframes:

View File

@ -1,11 +1,9 @@
import asyncio import asyncio
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import cast from typing import cast
import zipfile
from loguru import logger from loguru import logger
from nonebot import on_command from nonebot import on_command
@ -15,99 +13,22 @@ from nonebot.adapters.onebot.v11.event import MessageEvent as OB11MessageEvent
from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot from nonebot.adapters.onebot.v11.bot import Bot as OB11Bot
from nonebot.adapters.onebot.v11.message import Message as OB11Message from nonebot.adapters.onebot.v11.message import Message as OB11Message
from konabot.common.artifact import ArtifactDepends, ensure_artifact, register_artifacts
from konabot.common.longtask import DepLongTaskTarget from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import BINARY_PATH, TMP_PATH from konabot.common.path import TMP_PATH
arti_typst_linux = ArtifactDepends(
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-unknown-linux-musl.tar.xz",
sha256="a6044cbad2a954deb921167e257e120ac0a16b20339ec01121194ff9d394996d",
target=BINARY_PATH / "typst.tar.xz",
required_os="Linux",
required_arch="x86_64",
)
arti_typst_windows = ArtifactDepends(
url="https://github.com/typst/typst/releases/download/v0.14.2/typst-x86_64-pc-windows-msvc.zip",
sha256="51353994ac83218c3497052e89b2c432c53b9d4439cdc1b361e2ea4798ebfc13",
target=BINARY_PATH / "typst.zip",
required_os="Windows",
required_arch="AMD64",
)
bin_path: Path | None = None
@arti_typst_linux.on_finished
async def _(downloaded: bool):
global bin_path
tar_path = arti_typst_linux.target
bin_path = BINARY_PATH / "typst"
if downloaded or not bin_path.exists():
bin_path.unlink(missing_ok=True)
process = await asyncio.create_subprocess_exec(
"tar",
"-xvf",
tar_path,
"--strip-components=1",
"-C",
BINARY_PATH,
"typst-x86_64-unknown-linux-musl/typst",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0 or not bin_path.exists():
logger.warning(
"似乎没有成功解压 Typst 二进制文件,检查一下吧! "
f"stdout={stdout} stderr={stderr}"
)
else:
os.chmod(bin_path, 0o755)
@arti_typst_windows.on_finished
async def _(downloaded: bool):
global bin_path
zip_path = arti_typst_windows.target
bin_path = BINARY_PATH / "typst.exe"
if downloaded or not bin_path.exists():
bin_path.unlink(missing_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
target_name = "typst-x86_64-pc-windows-msvc/typst.exe"
if target_name not in zf.namelist():
logger.warning("在 Zip 压缩包里面没有找到目标文件")
return
zf.extract(target_name, BINARY_PATH)
(BINARY_PATH / target_name).rename(bin_path)
(BINARY_PATH / "typst-x86_64-pc-windows-msvc").rmdir()
register_artifacts(arti_typst_linux)
register_artifacts(arti_typst_windows)
TEMPLATE_PATH = Path(__file__).parent / "template.typ" TEMPLATE_PATH = Path(__file__).parent / "template.typ"
TEMPLATE = TEMPLATE_PATH.read_text() TEMPLATE = TEMPLATE_PATH.read_text()
def render_sync(code: str) -> bytes | None: def render_sync(code: str) -> bytes:
global bin_path
if bin_path is None:
return
with TemporaryDirectory(dir=TMP_PATH) as tmpdirname: with TemporaryDirectory(dir=TMP_PATH) as tmpdirname:
temp_dir = Path(tmpdirname).resolve() temp_dir = Path(tmpdirname).resolve()
temp_typ = temp_dir / "page.typ" temp_typ = temp_dir / "page.typ"
temp_typ.write_text(TEMPLATE + "\n\n" + code) temp_typ.write_text(TEMPLATE + "\n\n" + code)
cmd = [ cmd = [
bin_path, "typst",
"compile", "compile",
temp_typ.name, temp_typ.name,
"--format", "--format",
@ -140,7 +61,7 @@ def render_sync(code: str) -> bytes | None:
return result_png.read_bytes() return result_png.read_bytes()
async def render(code: str) -> bytes | None: async def render(code: str) -> bytes:
task = asyncio.to_thread(lambda: render_sync(code)) task = asyncio.to_thread(lambda: render_sync(code))
return await task return await task
@ -149,21 +70,7 @@ cmd = on_command("typst")
@cmd.handle() @cmd.handle()
async def _( async def _(evt: Event, bot: Bot, msg: UniMsg, target: DepLongTaskTarget):
evt: Event,
bot: Bot,
msg: UniMsg,
target: DepLongTaskTarget,
):
global bin_path
# 对于本地机器,一般不会在应用启动时自动下载,这里再保证存在
await ensure_artifact(arti_typst_linux)
await ensure_artifact(arti_typst_windows)
if bin_path is None or not bin_path.exists():
logger.warning("当前环境不存在 Typst但仍然调用了")
return
typst_code = "" typst_code = ""
if isinstance(evt, OB11MessageEvent): if isinstance(evt, OB11MessageEvent):
if evt.reply is not None: if evt.reply is not None:
@ -185,8 +92,6 @@ async def _(
try: try:
res = await render(typst_code) res = await render(typst_code)
if res is None:
raise FileNotFoundError("没有渲染出来内容")
except FileNotFoundError as e: except FileNotFoundError as e:
await target.send_message("渲染出错:内部错误") await target.send_message("渲染出错:内部错误")
raise e from e raise e from e