From 87be1916ee264411d470feb5fa0cadc4f6b33a2e Mon Sep 17 00:00:00 2001 From: passthem Date: Sun, 12 Oct 2025 13:36:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Shadertool=EF=BC=88?= =?UTF-8?q?=E8=B0=81=E9=9C=80=E8=A6=81=EF=BC=9F=EF=BC=9F=EF=BC=9F=EF=BC=9F?= =?UTF-8?q?=EF=BC=9F=EF=BC=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 9 ++ konabot/docs/user/shadertool.txt | 39 ++++++++ konabot/plugins/sksl/__init__.py | 41 ++++++++ konabot/plugins/sksl/run_sksl.py | 155 +++++++++++++++++++++++++++++++ poetry.lock | 64 ++++++++++++- pyproject.toml | 1 + 6 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 konabot/docs/user/shadertool.txt create mode 100644 konabot/plugins/sksl/__init__.py create mode 100644 konabot/plugins/sksl/run_sksl.py diff --git a/Dockerfile b/Dockerfile index 6295d8c..0549c9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,15 @@ FROM python:3.13-slim AS base ENV VIRTUAL_ENV=/app/.venv \ PATH="/app/.venv/bin:$PATH" +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libfontconfig1 \ + libgl1 \ + libegl1 \ + libglvnd0 \ + mesa-vulkan-drivers \ + && rm -rf /var/lib/apt/lists/* + FROM base AS builder diff --git a/konabot/docs/user/shadertool.txt b/konabot/docs/user/shadertool.txt new file mode 100644 index 0000000..d192527 --- /dev/null +++ b/konabot/docs/user/shadertool.txt @@ -0,0 +1,39 @@ +指令介绍 + shadertool - 使用 SkSL(Skia Shader Language)代码实时渲染并生成 GIF 动画 + +格式 + shadertool [选项] + +示例 + `shadertool "void main() { sk_FragColor = vec4(sin(sk_FragCoord.x * 0.01), 0, 0, 1); }"` + 使用默认参数(320×180,1秒,15fps)渲染一段红色水平波纹动画。 + + `shadertool --width 256 --height 256 --duration 2.0 --fps 20 "void main() { sk_FragColor = vec4(fract(sk_FragCoord * 0.02), 1); }"` + 以 256×256 分辨率、2 秒时长、20 帧每秒渲染一个彩色格点动画。 + +参数说明 + SkSL 代码(必填) + - 类型:字符串(建议用英文双引号包裹) + - 内容:符合 SkSL 语法的片段着色器代码,必须包含 `void main()` 函数,并为 `sk_FragColor` 赋值。 + - 注意:插件会自动去除代码首尾的单引号或双引号,便于命令行输入。 + + --width <整数>(可选) + - 默认值:320 + - 作用:输出 GIF 的宽度(像素),必须大于 0。 + + --height <整数>(可选) + - 默认值:180 + - 作用:输出 GIF 的高度(像素),必须大于 0。 + + --duration <浮点数>(可选) + - 默认值:1.0 + - 作用:动画总时长(秒),必须大于 0。 + - 限制:`duration × fps` 必须 ≥ 1 且 ≤ 100(即至少 1 帧,最多 100 帧)。 + + --fps <浮点数>(可选) + - 默认值:15.0 + - 作用:每秒帧数,控制动画流畅度,必须大于 0。 + - 常见值:10(低配流畅)、15(默认)、24/30(电影/视频级)。 + +使用方式 + 直接在群聊或私聊中发送 `shadertool` 指令,附上合法的 SkSL 代码即可。 diff --git a/konabot/plugins/sksl/__init__.py b/konabot/plugins/sksl/__init__.py new file mode 100644 index 0000000..c112672 --- /dev/null +++ b/konabot/plugins/sksl/__init__.py @@ -0,0 +1,41 @@ +from nonebot_plugin_alconna import Alconna, Args, Option, UniMessage, on_alconna + +from konabot.common.nb.exc import BotExceptionMessage +from konabot.plugins.sksl.run_sksl import render_sksl_shader_to_gif + + +cmd_run_sksl = on_alconna(Alconna( + "shadertool", + Option("--width", Args["width_", int]), + Option("--height", Args["height_", int]), + Option("--duration", Args["duration_", float]), + Option("--fps", Args["fps_", float]), + Args["code", str], +)) + +@cmd_run_sksl.handle() +async def _( + code: str, + width_: int = 320, + height_: int = 180, + duration_: float = 1.0, + fps_: float = 15.0, +): + if width_ <= 0 or height_ <= 0: + raise BotExceptionMessage("长宽应该大于 0") + if duration_ <= 0: + raise BotExceptionMessage("渲染时长应该大于 0") + if fps_ <= 0: + raise BotExceptionMessage("渲染帧率应该大于 0") + if fps_ * duration_ < 1: + raise BotExceptionMessage("时长太短或帧率太小,没有帧被渲染") + if fps_ * duration_ > 100: + raise BotExceptionMessage("太多帧啦!试着缩短一点时间吧!") + + code = code.strip("\"").strip("'") + + try: + res = await render_sksl_shader_to_gif(code, width_, height_, duration_, fps_) + await cmd_run_sksl.send(await UniMessage().image(raw=res).export()) + except (ValueError, RuntimeError) as e: + await cmd_run_sksl.send(await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export()) diff --git a/konabot/plugins/sksl/run_sksl.py b/konabot/plugins/sksl/run_sksl.py new file mode 100644 index 0000000..4cbe498 --- /dev/null +++ b/konabot/plugins/sksl/run_sksl.py @@ -0,0 +1,155 @@ +import asyncio +import io +import struct + +from loguru import logger +import numpy as np +import skia +from PIL import Image + + +def _pack_uniforms(uniforms_dict, width, height, time_val): + """ + 根据常见的教学用 uniform 布局打包字节数据 + 假设 SkSL 中 uniform 顺序为: float u_time; float2 u_resolution; + 内存布局: [u_time(4B)][u_res_x(4B)][u_res_y(4B)] (总共 12 字节,无填充) + 注意:为匹配 skia.RuntimeEffect 的紧凑布局,已移除 float 和 float2 之间的 4 字节填充。 + """ + # u_time (float) - 4 bytes + time_bytes = struct.pack('f', time_val) + + # u_resolution (vec2/float2) - 8 bytes + res_bytes = struct.pack('ff', float(width), float(height)) + + # 移除填充字节,使用紧凑布局 + return time_bytes + res_bytes + + +def _render_sksl_shader_to_gif( + sksl_code: str, + width: int = 256, + height: int = 256, + duration: float = 2.0, + fps: float = 15, +) -> io.BytesIO: + """ + 渲染 SkSL 着色器动画为 GIF(适配 skia-python >= 138) + """ + + logger.info(f"开始编译\n{sksl_code}") + + runtime_effect = skia.RuntimeEffect.MakeForShader(sksl_code) + if runtime_effect is None: + # SkSL 编译失败时,尝试获取错误信息(如果 skia 版本支持) + error_message = "" + # 注意:skia-python 的错误信息捕获可能因版本而异 + + # 尝试检查编译错误是否在日志中,但最直接的是抛出已知错误 + raise ValueError("SkSL 编译失败,请检查语法") + + # --- 修复: 移除对不存在的 uniformSize() 的调用,直接使用硬编码的 12 字节尺寸 --- + # float (4 bytes) + float2 (8 bytes) = 12 bytes (基于 _pack_uniforms 函数的紧凑布局) + EXPECTED_UNIFORM_SIZE = 12 + + # 创建 CPU 后端 Surface + surface = skia.Surface(width, height) + + frames = [] + total_frames = int(duration * fps) + + for frame in range(total_frames): + time_val = frame / fps + + # 打包 uniform 数据 + uniform_bytes = _pack_uniforms(None, width, height, time_val) + + # [检查] 确保打包后的字节数与期望值匹配 + if len(uniform_bytes) != EXPECTED_UNIFORM_SIZE: + raise ValueError( + f"Uniform 数据大小不匹配!期望 {EXPECTED_UNIFORM_SIZE} 字节,实际 {len(uniform_bytes)} 字节。请检查 _pack_uniforms 函数。" + ) + + uniform_data = skia.Data.MakeWithCopy(uniform_bytes) + + # 创建着色器 + try: + # makeShader 的参数: uniform_data, children_shaders, child_count + shader = runtime_effect.makeShader(uniform_data, None, 0) + if shader is None: + # 如果 SkSL 语法正确但 uniform 匹配失败,makeShader 会返回 None + raise RuntimeError("着色器创建返回 None!请检查 SkSL 语法和 uniform 数据匹配。") + except Exception as e: + raise ValueError(f"着色器创建失败: {e}") + + # 绘制 + canvas = surface.getCanvas() + canvas.clear(skia.Color(0, 0, 0, 255)) + + paint = skia.Paint() + paint.setShader(shader) + canvas.drawRect(skia.Rect.MakeWH(width, height), paint) + + # --- 修复 peekPixels() 错误:改用 readPixels() 将数据复制到缓冲区 --- + image = surface.makeImageSnapshot() + + # 1. 准备目标 ImageInfo (通常是 kBGRA_8888_ColorType) + target_info = skia.ImageInfo.Make( + image.width(), + image.height(), + skia.ColorType.kBGRA_8888_ColorType, # 目标格式,匹配 Skia 常见的输出格式 + skia.AlphaType.kPremul_AlphaType + ) + + # 2. 创建一个用于接收像素数据的 bytearray 缓冲区 + pixel_data = bytearray(image.width() * image.height() * 4) # 4 bytes per pixel (BGRA) + + # 3. 将图像数据复制到缓冲区 + success = image.readPixels(target_info, pixel_data, target_info.minRowBytes()) + + if not success: + raise RuntimeError("无法通过 readPixels() 获取图像像素") + + # 4. 转换 bytearray 到 NumPy 数组 (BGRA 顺序) + img_array = np.frombuffer(pixel_data, dtype=np.uint8).reshape((height, width, 4)) + + # 5. BGRA 转换成 RGB 顺序 (交换 R 和 B 通道) + # [B, G, R, A] -> [R, G, B] (丢弃 A 通道) + rgb_array = img_array[:, :, [2, 1, 0]] + + # 6. 创建 PIL Image + pil_img = Image.fromarray(rgb_array) + frames.append(pil_img) + # ------------------------------------------------------------------ + + # 生成 GIF + gif_buffer = io.BytesIO() + # 计算每帧的毫秒延迟 + frame_duration_ms = int(1000 / fps) + frames[0].save( + gif_buffer, + format='GIF', + save_all=True, + append_images=frames[1:], + duration=frame_duration_ms, + loop=0, # 0 表示无限循环 + optimize=True + ) + gif_buffer.seek(0) + return gif_buffer + + +async def render_sksl_shader_to_gif( + sksl_code: str, + width: int = 256, + height: int = 256, + duration: float = 2.0, + fps: float = 15, +) -> io.BytesIO: + return await asyncio.to_thread( + _render_sksl_shader_to_gif, + sksl_code, + width, + height, + duration, + fps, + ) diff --git a/poetry.lock b/poetry.lock index 52a1fee..6439eb3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2079,6 +2079,21 @@ type = "legacy" url = "https://gitea.service.jazzwhom.top/api/packages/Passthem/pypi/simple" reference = "pt-gitea-pypi" +[[package]] +name = "pybind11" +version = "3.0.1" +description = "Seamless operability between C++11 and Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pybind11-3.0.1-py3-none-any.whl", hash = "sha256:aa8f0aa6e0a94d3b64adfc38f560f33f15e589be2175e103c0a33c6bce55ee89"}, + {file = "pybind11-3.0.1.tar.gz", hash = "sha256:9c0f40056a016da59bab516efb523089139fcc6f2ba7e4930854c61efb932051"}, +] + +[package.extras] +global = ["pybind11-global (==3.0.1)"] + [[package]] name = "pycares" version = "4.11.0" @@ -2515,6 +2530,53 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "skia-python" +version = "138.0" +description = "Skia python binding" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "skia_python-138.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8bcf4f8d6ea08bdf718ed257f9a9c722f643223f9b93230fd4c15ac5c82029f"}, + {file = "skia_python-138.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1b7f24a5b306f57d75b8733658189dd61ce9114a84b5d119b3b1fb09cbc991cb"}, + {file = "skia_python-138.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2c4164013355bcf255651ac8a0ca519fd6095b6e58fa5e7a057919bec158411d"}, + {file = "skia_python-138.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:63a7f9dc8d5fdda969f35d7f8f3a774029093468a9c06771ccce9ab967b275ca"}, + {file = "skia_python-138.0-cp310-cp310-win_amd64.whl", hash = "sha256:740104424e9c94a70a767adcdbfa2bce131c93a82fa00a50432406dc04bac7e8"}, + {file = "skia_python-138.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e369105b2811b6c32d7f15624202609deeb428164ee395c715df01221c847f5"}, + {file = "skia_python-138.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:55a2ae437d1c6dc46d9bbc2e3e0b4b5642022dcebc0634591ae1c8acea52a413"}, + {file = "skia_python-138.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e67c9bcac953fbeb31b38474a4e566371e5719d4e27032686359cbe602819af8"}, + {file = "skia_python-138.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4e9fb097849604e290be28a6b4cc29cb0079b0233e599c2f6b2ec635e47169d2"}, + {file = "skia_python-138.0-cp311-cp311-win_amd64.whl", hash = "sha256:df5a0dd52e1038423f2e1c03677cba5ea974d215adec10763dd855f4a6ca0cfc"}, + {file = "skia_python-138.0-cp311-cp311-win_arm64.whl", hash = "sha256:054ca430003d52468974cf69d98719e61f188630709a3706a868efc16e817970"}, + {file = "skia_python-138.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b83d7b8101038ab0fcb920b1a91c872615e45b0ac19fe0c66b55d3ad0d61970"}, + {file = "skia_python-138.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:f22060f513b2fc258b4269e631128ca68889107bc4aa65255119f3d9c1c32993"}, + {file = "skia_python-138.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:02b0c2146d25f45224c2c156dd8b859e89265ee5894c3fa8f456c6c41b27122d"}, + {file = "skia_python-138.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fb9e980a61defcb6ca19d73c97ea23d1aea633a82e9d1dead6ed259c3856baaf"}, + {file = "skia_python-138.0-cp312-cp312-win_amd64.whl", hash = "sha256:625bb95dc225ea2257f1d1f7b0aee203290b91d909c8f5feaa7cd428f13bce22"}, + {file = "skia_python-138.0-cp312-cp312-win_arm64.whl", hash = "sha256:e495143edba2a7cdf59e83723f00c9a247e5baeda4981fc6587803ed46c5ed2a"}, + {file = "skia_python-138.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1433b5a0bd4ca1c1d050541f54c25d27d55a129894dbd827d4e440697090ff83"}, + {file = "skia_python-138.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:3f053128aa6344fa991c2485f29f40bda77b5f46467a43e778a34ec8f5b3615a"}, + {file = "skia_python-138.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:878212d9576d065a45fd143c57d412bc0496c6594ecfcd9cc2cd93b4fd943cb4"}, + {file = "skia_python-138.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a6b3e58756a9fa8af3714edc05aeafc7922090f5a276c4be11337f252921dbe8"}, + {file = "skia_python-138.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d596d5807fafef6bc43440f4df28f71db189f9e2cfb8613224713916837e3c"}, + {file = "skia_python-138.0-cp313-cp313-win_arm64.whl", hash = "sha256:20285bf4ee41da754b842c80076fc4a240cb3801f4a1cbbb50b26a5dca1ebc39"}, + {file = "skia_python-138.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ff8dcb98b19e85d565c1d0accf8919e4b8a35d2a121d2d6f1c21fea655c85884"}, + {file = "skia_python-138.0-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:ff3e692214b27490e38caf6ed123d82d2ff762da4561473a5a34fab1cb5dcd14"}, + {file = "skia_python-138.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:db3388f4e6e8f63c9e2b6cb0add0c2e483cbed783558b10eb7d94540f75db9b6"}, + {file = "skia_python-138.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:1dd9306b2025e296ad1d0e2c3038e3221ade1d48dfd0a20ce926f8116603b909"}, + {file = "skia_python-138.0-cp38-cp38-win_amd64.whl", hash = "sha256:9ae5de84629dba4c751b334a8c43b04e9b7fe2a65df26992770648aa5e131b0c"}, + {file = "skia_python-138.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:736e6618f246444bff2ff73b9efe8fd7159d9048c85ecdffb23290702a5c9099"}, + {file = "skia_python-138.0-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:ab438dfe40d1be3da6fdd94890862781dc4e545a0bfa169a8e9dcc25f3bb0afd"}, + {file = "skia_python-138.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2f5d842930d1e385aa7911e0705d4751d8e4aa550389baf88d40673672f180b1"}, + {file = "skia_python-138.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:d17ad82d6094f9c7bfc25b6525e1080bf3715abcc848be042c786b365f51a48c"}, + {file = "skia_python-138.0-cp39-cp39-win_amd64.whl", hash = "sha256:0f6136827a269a4a5294d92919bc6fad008da7d7e74256666d81506f863a6ff9"}, +] + +[package.dependencies] +numpy = "*" +pybind11 = ">=2.6" + [[package]] name = "sniffio" version = "1.3.1" @@ -3198,4 +3260,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "b4c3d28f7572c57e867d126ce0c64787ae608b114e66b8de06147caf13e049dd" +content-hash = "0c7709438b8eb3d468b775417b8ef642cd7a8c1031805fdc71fec32c48346e54" diff --git a/pyproject.toml b/pyproject.toml index cd97d1a..9785469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "opencv-python-headless (>=4.12.0.88,<5.0.0.0)", "returns (>=0.26.0,<0.27.0)", "ptimeparse (>=0.1.1,<0.2.0)", + "skia-python (>=138.0,<139.0)", ] [build-system]