diff --git a/plugins/weekbin/octopus-meme-maker/.claude-plugin/plugin.json b/plugins/weekbin/octopus-meme-maker/.claude-plugin/plugin.json new file mode 100644 index 00000000..5ba0513c --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "octopus-meme-maker", + "version": "0.2.0", + "description": "Generate Smooth Squishmallow-style pink-octopus office-worker GIF memes from a scene description: a 720x720 looping GIF plus a 480x480 mini-GIF for chat apps. Requires MiniMax Code to provide the image_synthesize and gen_videos host tools.", + "author": { + "name": "weekbin", + "url": "https://github.com/weekbin" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/weekbin/MiniMax-Code-Plugins/tree/main/plugins/weekbin/octopus-meme-maker", + "repository": "https://github.com/weekbin/MiniMax-Code-Plugins/tree/main/plugins/weekbin/octopus-meme-maker", + "keywords": [ + "mcode", + "meme", + "octopus", + "gif", + "image-generation" + ], + "icon": "icon.png", + "skills": [ + "./skills/octopus-meme-maker/SKILL.md" + ] +} diff --git a/plugins/weekbin/octopus-meme-maker/.minimax-plugin/plugin.json b/plugins/weekbin/octopus-meme-maker/.minimax-plugin/plugin.json new file mode 100644 index 00000000..bb0b89b2 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/.minimax-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "name": "octopus-meme-maker", + "displayName": "章鱼表情包生成器 Octopus Meme Maker", + "version": "0.2.0", + "description": "把一段场景描述变成粉红色章鱼打工人的 GIF 表情包:输出 720x720 循环 GIF 和适合聊天发送的 480x480 小图,角色风格由包内参考素材锁定。需要 MiniMax Code 提供 image_synthesize 与 gen_videos 两个宿主工具。 / Turn a scene description into a pink octopus office-worker GIF meme: a 720x720 looping GIF plus a 480x480 mini-GIF for chat apps, with the character style locked by the bundled reference assets. Requires MiniMax Code to provide the image_synthesize and gen_videos host tools.", + "author": "weekbin", + "icon": "icon.png", + "category": "Other", + "exampleQueries": [ + "做个摆烂躺平的章鱼表情包:趴在桌上,一只眼睛半闭,触手尖端着一杯咖啡。 / Make a lying-flat octopus meme: sprawled on the desk, one eye half-closed, a coffee cup held by one tentacle.", + "生成一个「我裂开了」的章鱼 GIF 表情,双手抱头、眼睛转圈。 / Generate an 'I'm falling apart' octopus GIF meme with both hands on the head and eyes spiraling.", + "给现有章鱼表情包库加一个新场景:「请大家喝奶茶」,8 条触手各拿一杯奶茶。 / Add a scene to the octopus meme library: 'treating everyone to milk tea' with 8 tentacles each holding a cup." + ], + "apps": [], + "mcpServers": [], + "skills": [ + "skills/octopus-meme-maker/SKILL.md" + ] +} diff --git a/plugins/weekbin/octopus-meme-maker/LICENSE b/plugins/weekbin/octopus-meme-maker/LICENSE new file mode 100644 index 00000000..125be1b8 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/weekbin/octopus-meme-maker/README.md b/plugins/weekbin/octopus-meme-maker/README.md new file mode 100644 index 00000000..c75651a0 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/README.md @@ -0,0 +1,126 @@ +# Octopus Meme Maker + +Generate Smooth Squishmallow-style pink-octopus office-worker GIF memes from a scene description. Ships the 4-stage pipeline (base pose → 6s video → transparent text overlay → 720×720 GIF) plus the cross-platform scripts that assemble the final 480×480 mini-GIF for chat apps. + +## Try it + +```text +Make me a new octopus worker meme: 摆烂躺平 (lying flat on the desk, one eye half-closed, the other fully closed, the tip of one tentacle holding a coffee cup). The character must match the existing reference bases in examples/*.png (3D Squishmallow style). Save the result to / and follow the 4-stage pipeline in skills/octopus-meme-maker/SKILL.md (6-candidate contact-sheet round → user pick → base.png). +``` + +Expected result: `/base.png` (1024×1024 base) → `/video.mp4` (1080p 6s 24fps, 141 frames) → `/final.gif` (720×720, 141 frames) → `/final-mini.gif` (480×480, 141 frames). + +## What's in this Plugin + +- `skills/octopus-meme-maker/SKILL.md` — the 4-stage pipeline + ban-list + exit conditions +- `skills/octopus-meme-maker/reference.md` — character anatomy, style rules, prompt template, reference image list +- `skills/octopus-meme-maker/issues.md` — known failure modes and feedback signals +- `scripts/_fonts.py` — shared CJK font discovery and loading for the four scripts (not a CLI entry point) +- `scripts/make_contact_sheet.py` — compose the 6-candidate contact sheet stage 1 hands to the user (Pillow) +- `scripts/make_text_overlay.py` — render transparent Chinese text overlay (Pillow, cross-platform font picker) +- `scripts/make_preview_strip.py` — extract 5 key frames from the video and stitch a preview strip (ffmpeg + Pillow) +- `scripts/make_gif.py` — compose 720×720 final.gif + 480×480 final-mini.gif from video + overlay (ffmpeg subprocess) +- `examples/` — 3 base.png samples that the maintainer has successfully extracted. **This is the character / style reference.** Pass all three as `input_file_paths` to `image_synthesize` for every new scene to lock the Squishmallow style, anatomy, and rendering vibe; the prompt overrides the scene composition: `02-stay-late-base.png`, `10-toilet-slacking-base.png`, `11-touch-fish-base.png` +- `reference/videos/` — 2 h3 source videos (768×768, 24 fps, 6.58 s, h264+aac) picked as illustrative animation samples: `breakdown-h3.mp4` (我裂开了) and `treat-milk-tea-h3.mp4` (请大家喝奶茶) + +## Requirements + +- A MiniMax Code build that provides the `image_synthesize` and `gen_videos` host tools (those are host capabilities, not part of this Plugin; verified on mcode 0.4.6). Stage 1 and stage 2 of the pipeline call them directly. +- Python 3.10+ +- `pip install Pillow` +- `ffmpeg` 5.0+ on `PATH` (verify with `ffmpeg -version`). The scripts pass `-fps_mode`, which replaced `-vsync` in ffmpeg 5.0; `make_gif.py` checks the major version up front and fails with a clear message on an older build. +- A CJK font installed on the system for the text overlay. The script auto-picks the first match from this list: + - macOS: `/System/Library/Fonts/STHeiti Medium.ttc`, `/System/Library/Fonts/PingFang.ttc` + - Linux: `/usr/share/fonts/truetype/wqy/wqy-microhei.ttc`, `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` + - Windows: `C:\Windows\Fonts\msyh.ttc`, `C:\Windows\Fonts\simhei.ttf` + - Pass `--font ` to override. +- MiniMax Code's `image_synthesize` and `gen_videos` tools (used by stage 1 and stage 2 of the pipeline; not bundled in this Plugin — they are part of the host). + +## What this Plugin does NOT do + +- **No credentials**: it does not read, write, accept, or transmit any API key, OAuth token, refresh token, client secret, username, password, or any other form of authentication credential. The Plugin has no login flow and no `Authorization` header. +- **No network access at runtime**: the shipped Python scripts run entirely on the local machine and only call `ffmpeg` and `Pillow` from the local system. They do not open any TCP / UDP / WebSocket connection. +- **No telemetry**: the Plugin does not phone home, does not log usage, and does not embed any analytics SDK. It ships no Google Analytics, no Sentry, no Mixpanel, no Cloudflare beacon, and no error reporter. +- **No third-party services**: the Plugin does not call any remote service. (Stage 1 and stage 2 of the pipeline call MiniMax Code's `image_synthesize` and `gen_videos` host tools, but those are part of the host runtime — the Plugin itself never makes a remote call.) + +## Supported platforms + +- **macOS 13+** — verified. Python 3.14.7, Pillow 12.3.0, ffmpeg 8.1.2, STHeiti font. +- **Linux** — verified on Ubuntu 24.04.4. Python 3.12.3, Pillow 10.2.0, ffmpeg 6.1.1, Noto Sans CJK. All four scripts produced the same artifact geometry as the macOS run: 1080×220 overlay, 2400×530 preview, 1460×974 contact sheet, `720,720,141` and `480,480,141` GIFs, and a 14-character caption auto-fitting at size 88. +- **Windows 10/11** — not verified on a Windows host. The scripts use `subprocess.run` with argument lists (no shell, so no quoting or injection surface), and the font picker falls back to `C:\Windows\Fonts\msyh.ttc`. + +## Data and network + +- No network access at runtime. The Plugin runs entirely on the local machine. +- The character generation steps (stage 1, stage 2) call host-provided image and video synthesis tools; those are out of scope for this Plugin but their network and data behaviour follows the host's policy. +- No credentials, accounts, paid services, telemetry, installers, symlinks, or native binaries are bundled. + +## Verification + +Shell blocks in this README are POSIX shell (macOS, Linux, or Git Bash on Windows). On Windows without Git Bash, run the same `python3 …` lines from PowerShell or `cmd`; only `mkdir -p` and `cp` need replacing (for example `New-Item -ItemType Directory -Force`, `Copy-Item`). + +Every script pins its own stdout/stderr to UTF-8 and replaces characters the console cannot encode, so a Chinese caption prints on a cp1252 (English Windows) or cp936 console instead of raising `UnicodeEncodeError`. Subprocess output is decoded as UTF-8 with replacement rather than with the locale encoding. + +This is a self-contained run: it uses the sample video bundled in `reference/videos/`, so no scene has to exist first. Run from this Plugin's root directory. + +```bash +mkdir -p /tmp/octopus_selftest +cp reference/videos/breakdown-h3.mp4 /tmp/octopus_selftest/video.mp4 + +python3 scripts/make_text_overlay.py "再熬一会" /tmp/octopus_overlay.png +python3 scripts/make_preview_strip.py /tmp/octopus_selftest/video.mp4 /tmp/octopus_preview.png +python3 scripts/make_gif.py /tmp/octopus_selftest "再熬一会" +``` + +Expected: + +| Output | Check | +|---|---| +| `/tmp/octopus_overlay.png` | 1080×220 RGBA | +| `/tmp/octopus_preview.png` | 2400×530 RGB (5 frames + label band) | +| `/tmp/octopus_selftest/final.gif` | `720,720,141` from `ffprobe` | +| `/tmp/octopus_selftest/final-mini.gif` | `480,480,141` from `ffprobe` | + +All three scripts must exit 0. `make_gif.py` warns on stderr when the source video is not square; the bundled sample is 768×768, so that note is expected here and the render still succeeds. + +## Packaging and submission + +This directory carries the manifests for three runtime layouts. Each is read by a different reader, and the upstream repository documents the first two as valid choices for a submission. + +| Manifest | Reader | Purpose | +|---|---|---| +| `plugin.json` (top level) | portable Agent Plugins 1.0 | The cross-runtime baseline. `scripts/validate.mjs` checks this layout by default, and mcode 0.3.x and 0.4.0+ both read it. | +| `.claude-plugin/plugin.json` | Claude Code compatible plugin | The preferred form for mcode 0.4.0+. Read as `manifestKind: CLAUDE_CODE`. | +| `.minimax-plugin/plugin.json` | MiniMax manifest | The Marketplace submission entry point and the desktop app's richer manifest (icon, category, displayName, exampleQueries). | + +`icon.png` is referenced by the Claude Code compatible manifest (`icon`) and by the MiniMax manifest (`icon`). + +The two manifest formats are not interchangeable and cannot both be *active* in one directory. A valid portable root `plugin.json` wins over `.minimax-plugin/plugin.json`: + +```ts +if (await pluginPathExists(root, 'plugin.json', 'file')) { + const agentPlugin = await tryReadAgentPluginPackage(rootPath); + if (agentPlugin) return agentPlugin; // root plugin.json wins +} +if (manifests.hasMiniMax) return readMiniMaxPluginPackage(rootPath, { source: 'LOCAL_MINIMAX' }); +``` + +So when both files are present, the MiniMax manifest — `icon`, `category`, `displayName`, `exampleQueries` — is not read, and a locally installed copy shows the runtime's default icon. The Claude Code compatible manifest is a separate reader and is not affected by that precedence. + +For a Marketplace submission, point the source at this directory (`plugins/weekbin/octopus-meme-maker`) and build the ZIP from a copy with the portable manifest removed: + +```bash +rsync -a --exclude '/plugin.json' \ + plugins/weekbin/octopus-meme-maker/ \ + / +``` + +The `--exclude` is anchored (`/plugin.json`), so the nested manifests are kept. + +### Localization + +The client's plugin data model carries a single scalar `displayName` / `description` — there is no `displayName_zh`-style locale variant. Region (CN / US) is chosen at submission time, not per field. This package therefore uses bilingual user-facing strings (Chinese first, English second) so one package reads correctly in both regions. + +## License + +Apache-2.0. See [LICENSE](./LICENSE). diff --git a/plugins/weekbin/octopus-meme-maker/README.zh-CN.md b/plugins/weekbin/octopus-meme-maker/README.zh-CN.md new file mode 100644 index 00000000..41894d89 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/README.zh-CN.md @@ -0,0 +1,82 @@ +# 章鱼表情包生成器 Octopus Meme Maker + +把一段场景描述变成 Smooth Squishmallow 风格的粉红色章鱼打工人 GIF 表情包。包内自带完整 4 阶段流水线(基础姿态 → 6 秒视频 → 透明文字叠加 → 720×720 GIF),以及组装最终 480×480 小图的跨平台脚本。 + +## 试试看 + +```text +给我做一个新的章鱼打工人表情包:摆烂躺平(趴在桌上,一只眼睛半闭,另一只全闭,一条触手尖端着一杯咖啡)。 +角色必须和 examples/*.png 里的 3D Squishmallow 风格 base 图一致。 +结果存到 /,按 skills/octopus-meme-maker/SKILL.md 里的 4 阶段流水线执行(6 张候选拼图 → 用户挑选 → base.png)。 +``` + +预期产物:`/base.png`(1024×1024 底图)→ `/video.mp4`(1080p 6 秒 24fps,141 帧)→ `/final.gif`(720×720,141 帧)→ `/final-mini.gif`(480×480,141 帧)。 + +## 包内有什么 + +- `skills/octopus-meme-maker/SKILL.md` — 4 阶段流水线 + 禁用清单 + 每步的退出条件 +- `skills/octopus-meme-maker/reference.md` — 角色解剖、风格规则、提示词模板、参考图清单 +- `skills/octopus-meme-maker/issues.md` — 已知失败模式与反馈信号对照 +- `scripts/make_text_overlay.py` — 渲染透明中文文字叠加层(Pillow,跨平台字体探测) +- `scripts/make_preview_strip.py` — 从 6 秒视频抽 5 个关键帧拼成预览条 +- `scripts/make_gif.py` — 由视频 + 叠加层组装 720×720 `final.gif` 与 480×480 `final-mini.gif`(调用 ffmpeg) +- `examples/` — 3 张底图样片,**就是真正的角色 / 风格参考**。每次新建场景都必须把这 3 张全部作为 `input_file_paths` 传给 `image_synthesize`,用来锁住 Squishmallow 风格、解剖和渲染氛围;场景构图由 prompt 覆盖:`02-stay-late-base.png`、`10-toilet-slacking-base.png`、`11-touch-fish-base.png` +- `reference/videos/` — 2 个 H3 源视频样片(768×768、24fps、6.58 秒、h264+aac):`breakdown-h3.mp4`(我裂开了)、`treat-milk-tea-h3.mp4`(请大家喝奶茶) +- `icon.png` — 插件图标(512×512 PNG) + +## 依赖 + +- `minMcodeVersion`:`0.2.0`(流水线用到 `image_synthesize` 与 `gen_videos` 两个宿主工具,二者在 0.2.0 起稳定) +- Python 3.10+ +- `pip install Pillow` +- `ffmpeg` 5.0+,且在 `PATH` 上(用 `ffmpeg -version` 验证)。脚本使用 `-fps_mode`,该参数在 ffmpeg 5.0 取代了 `-vsync`;`make_gif.py` 会预先检查主版本号,版本过低时给出明确报错。 +- 系统中需安装一个中文字体用于文字叠加。脚本会按下列顺序自动挑选第一个存在的: + - macOS:`/System/Library/Fonts/STHeiti Medium.ttc`、`/System/Library/Fonts/PingFang.ttc` + - Linux:`/usr/share/fonts/truetype/wqy/wqy-microhei.ttc`、`/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` + - Windows:`C:\Windows\Fonts\msyh.ttc`、`C:\Windows\Fonts\simhei.ttf` + - 也可用 `--font <路径>` 手动指定。 +- MiniMax Code 的 `image_synthesize` 与 `gen_videos` 工具(流水线第 1、2 阶段使用;不属于本插件,属宿主能力)。 + +## 本插件不做什么 + +- **不涉及任何凭据**:不读取、不写入、不接受、不传输任何 API Key、OAuth token、refresh token、client secret、用户名、密码或其他形式的认证凭据。没有登录流程,不设置 `Authorization` 头。 +- **运行时不联网**:包内 Python 脚本完全在本机运行,只调用本机的 `ffmpeg` 与 `Pillow`,不建立任何 TCP / UDP / WebSocket 连接。 +- **无埋点**:不 phone home、不记录使用情况、不内嵌任何分析 SDK。不含 Google Analytics、Sentry、Mixpanel、Cloudflare beacon 或错误上报。 +- **不调用第三方服务**:插件本身不发起任何远程调用。(流水线第 1、2 阶段调用 MiniMax Code 的 `image_synthesize` 与 `gen_videos` 宿主工具,那属于宿主运行时,插件自身不发起远程调用。) + +## 支持平台 + +- macOS 13+(原作者在 macOS 上验证) +- Linux(Ubuntu 22.04+、Debian 12+;只需 ffmpeg + Python + Pillow) +- Windows 10/11(PowerShell 或 Git Bash;流水线是纯 Python + ffmpeg 子进程调用) + +## 数据与网络 + +- 运行时无网络访问,完全在本机运行。 +- 角色生成步骤(第 1、2 阶段)调用宿主提供的图像与视频合成工具;那不属于本插件职责,其网络与数据行为遵循宿主策略。 +- 不打包任何凭据、账号、付费服务、埋点、安装器、符号链接或原生二进制。 + +## 自检 + +在本插件根目录执行: + +```bash +python3 scripts/make_text_overlay.py "再熬一会" /tmp/octopus_test_overlay.png +python3 scripts/make_preview_strip.py /path/to/some/video.mp4 /tmp/octopus_test_strip.png +python3 scripts/make_gif.py /path/to/some/scene-dir "再熬一会" +``` + +三个脚本都必须退出码为 0,并生成对应输出文件。 + +## 打包与提交 + +本目录同时是社区仓库插件和 MiniMax Marketplace 包: + +- `plugin.json` — 社区仓库清单(`$schema: https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`) +- `.minimax-plugin/plugin.json` — Marketplace 入口清单。Marketplace 的 ZIP 或 GitHub 子目录提交必须让该文件直接位于根目录 + +**关于图标**:`icon.png` 由 Marketplace 清单的 `icon` 字段引用。客户端渲染插件时读取的是服务端 catalog 返回的 `icon_url`,不会读取本地目录里的 `icon.png`。因此本图标只有在插件**发布到 Marketplace 之后**才会显示;从社区仓库本地安装的插件在客户端里会显示默认图标,这是预期行为。 + +## 许可 + +Apache-2.0,见 [LICENSE](./LICENSE)。 diff --git a/plugins/weekbin/octopus-meme-maker/examples/02-stay-late-base.png b/plugins/weekbin/octopus-meme-maker/examples/02-stay-late-base.png new file mode 100644 index 00000000..e5ac835a Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/examples/02-stay-late-base.png differ diff --git a/plugins/weekbin/octopus-meme-maker/examples/10-toilet-slacking-base.png b/plugins/weekbin/octopus-meme-maker/examples/10-toilet-slacking-base.png new file mode 100644 index 00000000..7618f1ce Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/examples/10-toilet-slacking-base.png differ diff --git a/plugins/weekbin/octopus-meme-maker/examples/11-touch-fish-base.png b/plugins/weekbin/octopus-meme-maker/examples/11-touch-fish-base.png new file mode 100644 index 00000000..3fd178f8 Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/examples/11-touch-fish-base.png differ diff --git a/plugins/weekbin/octopus-meme-maker/examples/README.md b/plugins/weekbin/octopus-meme-maker/examples/README.md new file mode 100644 index 00000000..25803c47 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/examples/README.md @@ -0,0 +1,14 @@ +# Examples — base pose samples + +This directory holds `base.png` samples that the maintainer has successfully extracted. They serve two purposes: + +1. **抽卡的基础图** — pass them as `input_file_paths` to `image_synthesize` to lock the Squishmallow style, the character anatomy (8 stubby pink tentacles, head bumps, large white-circle eyes with black round pupils), and the soft 3D render vibe. The prompt overrides the scene composition for the new scene. **All three should be passed together** for every new base pose; do not cherry-pick one. +2. **Reference for the scene table** — the file naming matches the maintainer's external scene table, e.g. `/octopus-worker-meme/-/`, so consumers can cross-reference. + +| File | Source | Use as `image_synthesize` input_file_path? | +|---|---|---| +| `02-stay-late-base.png` | image_synthesize (seeded with maintainer's external H3 first frame) | yes — locks coffee + half-lidded eyes | +| `10-toilet-slacking-base.png` | image_synthesize (no matching H3 source) | yes — locks toilet tiles + phone + open-mouth smile | +| `11-touch-fish-base.png` | image_synthesize (seeded with maintainer's external H3 first frame) | yes — locks desk + salmon + side-glance | + +When adding a new base, follow the `--base.png` convention. The matching H3 source video is not required and is not part of this package; this Plugin only ships 2 illustrative H3 videos in `reference/videos/` (`breakdown-h3.mp4`, `treat-milk-tea-h3.mp4`). diff --git a/plugins/weekbin/octopus-meme-maker/icon.png b/plugins/weekbin/octopus-meme-maker/icon.png new file mode 100644 index 00000000..01551e04 Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/icon.png differ diff --git a/plugins/weekbin/octopus-meme-maker/plugin.json b/plugins/weekbin/octopus-meme-maker/plugin.json new file mode 100644 index 00000000..cfd4b6c4 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "octopus-meme-maker", + "version": "0.2.0", + "description": "把一段场景描述变成粉红色章鱼打工人的 GIF 表情包:输出 720x720 循环 GIF 和适合聊天发送的 480x480 小图。需要 MiniMax Code 提供 image_synthesize 与 gen_videos 两个宿主工具。 / Turn a scene description into a pink octopus office-worker GIF meme: a 720x720 looping GIF plus a 480x480 mini-GIF for chat apps. Requires MiniMax Code to provide the image_synthesize and gen_videos host tools.", + "author": { + "name": "weekbin", + "email": "weekbin@example.com", + "url": "https://github.com/weekbin" + }, + "homepage": "https://github.com/weekbin/MiniMax-Code-Plugins/tree/main/plugins/weekbin/octopus-meme-maker", + "repository": "https://github.com/weekbin/MiniMax-Code-Plugins/tree/main/plugins/weekbin/octopus-meme-maker", + "license": "Apache-2.0", + "keywords": [ + "minimax-code", + "plugin", + "meme", + "octopus", + "gif", + "image-generation" + ] +} diff --git a/plugins/weekbin/octopus-meme-maker/reference/videos/breakdown-h3.mp4 b/plugins/weekbin/octopus-meme-maker/reference/videos/breakdown-h3.mp4 new file mode 100644 index 00000000..6b4d834b Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/reference/videos/breakdown-h3.mp4 differ diff --git a/plugins/weekbin/octopus-meme-maker/reference/videos/treat-milk-tea-h3.mp4 b/plugins/weekbin/octopus-meme-maker/reference/videos/treat-milk-tea-h3.mp4 new file mode 100644 index 00000000..ce25da81 Binary files /dev/null and b/plugins/weekbin/octopus-meme-maker/reference/videos/treat-milk-tea-h3.mp4 differ diff --git a/plugins/weekbin/octopus-meme-maker/scripts/_fonts.py b/plugins/weekbin/octopus-meme-maker/scripts/_fonts.py new file mode 100644 index 00000000..ef56c32d --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/_fonts.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Shared CJK font discovery and loading for the octopus-meme-maker scripts. + +Every script in this directory needs the same thing: find a usable CJK font on +whatever OS this is, load it, and fail with an actionable message if it cannot. +Keeping that in one place means a new font family is added once, not four +times, and that "the file exists but Pillow cannot open it" is reported as a +sentence rather than an uncaught OSError traceback. + +Not a CLI entry point: import it. +""" +import os + +from PIL import ImageFont + +# Ordered by preference. Each script picks the first entry that both exists and +# loads, so a missing or unreadable candidate is skipped rather than fatal. +FONT_CANDIDATES = [ + # macOS + "/System/Library/Fonts/STHeiti Medium.ttc", + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + # Linux + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc", + # Windows + r"C:\Windows\Fonts\msyh.ttc", + r"C:\Windows\Fonts\msyhbd.ttc", + r"C:\Windows\Fonts\simhei.ttf", +] + +INSTALL_HINT = ( + "Install a CJK font (wqy-microhei or noto-cjk on Linux, STHeiti or " + "PingFang on macOS, msyh on Windows) or pass --font ." +) + + +def load_font(path, size): + """Load `path` at `size`, raising ValueError with a readable message.""" + try: + return ImageFont.truetype(path, size) + except Exception as exc: # Pillow raises OSError / ValueError / IndexError + raise ValueError(f"cannot load font {path}: {exc}") from exc + + +def load_first_usable(candidates, size): + """Return (font, path) for the first candidate that exists AND loads. + + Returns (None, None) when nothing works, so the caller can print + INSTALL_HINT and exit 1. A candidate whose file is present but unreadable + is skipped instead of crashing the run, and is reported on the way past. + """ + broken = [] + for path in candidates: + if not os.path.exists(path): + continue + try: + return load_font(path, size), path + except ValueError as exc: + broken.append(str(exc)) + if broken: + raise ValueError("; ".join(broken)) + return None, None + + +def pick_font_path(): + """Return the first candidate path that exists, or None. Existence only.""" + for path in FONT_CANDIDATES: + if os.path.exists(path): + return path + return None + + +class FontUnavailable(Exception): + """No usable CJK font. The message is ready to show the user.""" + + +def resolve_font_path(requested, probe_size=32): + """Return a font path Pillow can open, or raise FontUnavailable. + + A caller-supplied path is verified rather than trusted, so a typo or a + present-but-unreadable file produces a sentence instead of a traceback. + """ + if requested: + try: + load_font(requested, probe_size) + except ValueError as exc: + raise FontUnavailable(str(exc)) from exc + return requested + try: + _, path = load_first_usable(FONT_CANDIDATES, probe_size) + except ValueError as exc: + raise FontUnavailable(f"{exc}\n{INSTALL_HINT}") from exc + if path is None: + raise FontUnavailable(f"no CJK font found on this system. {INSTALL_HINT}") + return path diff --git a/plugins/weekbin/octopus-meme-maker/scripts/_platform.py b/plugins/weekbin/octopus-meme-maker/scripts/_platform.py new file mode 100644 index 00000000..eb9eb567 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/_platform.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Cross-platform runtime helpers for the octopus-meme-maker scripts. + +Three things differ enough between macOS, Linux and Windows to need one place +to get them right: + +1. **Console encoding.** A Windows console defaults to a legacy code page + (cp1252 on an English install, cp936 on a Chinese one) and some Linux + setups still run under the C locale. Printing a Chinese caption to either + raises UncaughtError, so the scripts would die on their own success message. + `setup_console()` pins stdout/stderr to UTF-8 and never raises on an + unencodable glyph. + +2. **Subprocess decoding.** `text=True` decodes using the locale encoding, so + a tool that emits UTF-8 (ffmpeg) can produce mojibake or a decode error. + `SUBPROCESS_TEXT` decodes UTF-8 and replaces what it cannot read. + +3. **Path comparison.** Windows paths are case-insensitive and accept both + separators. `same_dir()` normalises before comparing, so the containment + checks behave the same on all three platforms. + +Not a CLI entry point: import it. +""" +import os +import sys + +# Pass as **SUBPROCESS_TEXT to subprocess.run(...) instead of text=True. +SUBPROCESS_TEXT = {"encoding": "utf-8", "errors": "replace"} + + +def setup_console(): + """Make stdout/stderr UTF-8 and tolerant of unencodable characters. + + Safe to call more than once, and a no-op when the stream is not a real + text stream (e.g. a StringIO under test). + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError, OSError): + pass + + +def same_dir(left, right): + """Compare two directory paths the way the running platform does. + + Windows is case-insensitive and accepts `/` and `\\`; POSIX is neither. + """ + return os.path.normcase(os.path.normpath(left)) == os.path.normcase(os.path.normpath(right)) + + +def dirname_normalised(path): + """`os.path.dirname` with the platform's normalisation applied.""" + return os.path.normcase(os.path.normpath(os.path.dirname(path))) + + +# Windows treats these as devices, not file names, anywhere in the path. +_WINDOWS_RESERVED = {"CON", "PRN", "AUX", "NUL"} +_WINDOWS_RESERVED.update(f"COM{i}" for i in range(1, 10)) +_WINDOWS_RESERVED.update(f"LPT{i}" for i in range(1, 10)) + + +def windows_reserved_reason(name): + """Return a human-readable reason if `name` is unusable on Windows, else None. + + Checked on every platform so a name that works on macOS cannot silently + fail on a Windows user's machine. + """ + stem = os.path.splitext(name)[0].upper() + if stem in _WINDOWS_RESERVED: + return f"{stem} is a reserved Windows device name" + if name != name.rstrip(" ."): + return "Windows strips a trailing space or dot from a file name" + for char in '<>:"|?*': + if char in name: + return f"{char!r} is not a legal character in a Windows file name" + return None diff --git a/plugins/weekbin/octopus-meme-maker/scripts/make_contact_sheet.py b/plugins/weekbin/octopus-meme-maker/scripts/make_contact_sheet.py new file mode 100644 index 00000000..5935bc49 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/make_contact_sheet.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Compose a 3x2 contact sheet of base-pose candidates for the user to pick from. + +Usage: + python3 make_contact_sheet.py \ + [--labels "1,2,3,4,5,6"] \ + [--cols 3] + +Six images are arranged in a 3-cols x 2-rows grid; each cell is downscaled to +480x480 with white padding, and a bold number is drawn in the top-left corner +so the user can answer "pick 3" / "选第 2 张" without ambiguity. The grid line +between cells is a 4 px black rule for visual separation. The script accepts +.jpg / .jpeg / .png files of any resolution; very large sources are downscaled +to fit before being composited. With the default 3 columns the sheet is +1460x974 px; wider --cols values produce a proportionally wider sheet. + +Exit 0: exists, six labelled cells visible. +Exit 1: wrong number of inputs (must be exactly 6), a source image is unreadable, + Pillow is missing, or the output cannot be written. + +Stage 1 of the octopus-meme-maker pipeline must run this script after the base +pose batch lands and BEFORE asking the user to confirm. See SKILL.md § 2.2. +""" +import argparse +import os +import sys + +from PIL import Image, ImageDraw + +import _fonts +import _platform + +CELL_SIZE = 480 +COLS = 3 +PADDING = 6 +RULE = 4 +LABEL_FONT_SIZE = 72 +LABEL_BG = (255, 255, 255, 200) +LABEL_FG = (0, 0, 0, 255) + + + + + +def fit_into_square(img, size): + """Resize-and-pad an image into a size x size RGB square on a white background.""" + img = img.convert("RGB") + w, h = img.size + scale = min(size / w, size / h) + new_w = max(1, int(round(w * scale))) + new_h = max(1, int(round(h * scale))) + resized = img.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGB", (size, size), (255, 255, 255)) + canvas.paste(resized, ((size - new_w) // 2, (size - new_h) // 2)) + return canvas + + +def main(): + _platform.setup_console() + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + p.add_argument("images", nargs=6, help="Six candidate base-pose image paths.") + p.add_argument("output", help="Output contact-sheet PNG path.") + p.add_argument( + "--labels", + default=None, + help="Comma-separated 6 labels (defaults to '1,2,3,4,5,6').", + ) + p.add_argument( + "--cols", type=int, default=COLS, help=f"Grid columns (default {COLS}; rows = ceil(6 / cols))." + ) + p.add_argument("--font", default=None, help="Path to a CJK .ttc / .ttf file for the labels.") + args = p.parse_args() + + if args.labels is not None: + labels = args.labels.split(",") + if len(labels) != 6: + print( + f"ERROR: --labels must have exactly 6 comma-separated values, got {len(labels)}.", + file=sys.stderr, + ) + sys.exit(1) + else: + labels = [str(i) for i in range(1, 7)] + + if args.cols < 1 or args.cols > 6: + print(f"ERROR: --cols must be 1..6, got {args.cols}.", file=sys.stderr) + sys.exit(1) + + try: + font_path = _fonts.resolve_font_path(args.font) + except _fonts.FontUnavailable as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + + cells = [] + for path in args.images: + if not os.path.isfile(path): + print(f"ERROR: input image not found: {path}", file=sys.stderr) + sys.exit(1) + try: + cells.append(fit_into_square(Image.open(path), CELL_SIZE)) + except Exception as exc: # PIL raises a variety of exception types + print(f"ERROR: failed to read {path}: {exc}", file=sys.stderr) + sys.exit(1) + + cols = args.cols + rows = (6 + cols - 1) // cols + sheet_w = cols * CELL_SIZE + (cols - 1) * PADDING + 2 * RULE + sheet_h = rows * CELL_SIZE + (rows - 1) * PADDING + 2 * RULE + sheet = Image.new("RGB", (sheet_w, sheet_h), (0, 0, 0)) + draw = ImageDraw.Draw(sheet) + font = _fonts.load_font(font_path, LABEL_FONT_SIZE) + + for i, (cell, label) in enumerate(zip(cells, labels)): + row = i // cols + col = i % cols + x = RULE + col * (CELL_SIZE + PADDING) + y = RULE + row * (CELL_SIZE + PADDING) + sheet.paste(cell, (x, y)) + + # Number badge: white rectangle background + bold black number, top-left. + badge_w = 80 + badge_h = 90 + draw.rectangle([x, y, x + badge_w, y + badge_h], fill=LABEL_BG) + # Center the digit horizontally and vertically inside the badge. + bbox = draw.textbbox((0, 0), label, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + draw.text( + (x + (badge_w - text_w) // 2 - bbox[0], y + (badge_h - text_h) // 2 - bbox[1]), + label, + font=font, + fill=LABEL_FG, + ) + + out_dir = os.path.dirname(os.path.abspath(args.output)) + os.makedirs(out_dir, exist_ok=True) + sheet.save(args.output, "PNG") + print( + f"OK: {args.output} ({sheet_w}x{sheet_h}, {cols}x{rows} grid, font={font_path})" + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/weekbin/octopus-meme-maker/scripts/make_gif.py b/plugins/weekbin/octopus-meme-maker/scripts/make_gif.py new file mode 100644 index 00000000..5e9fe711 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/make_gif.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Compose the final 720x720 + 480x480 GIFs from a video and a Chinese caption. + +Usage: + python3 make_gif.py "" [--output-name final.gif] + +The script runs 4 ffmpeg steps and one Pillow text-overlay step: + 1. Make a transparent 1080x220 PNG with the caption (delegated to make_text_overlay.py). + 2. Burn the overlay onto the video at y=520, scale to 720x720, dump 141 frames as PNGs. + 3. palettegen on the 141 PNGs to a 720x720 palette. + 4. paletteuse -> 720x720 final.gif. + 5. Downscale the 141 PNGs to 480x480 + paletteuse -> 480x480 final-mini.gif. + +Exit 0: /final.gif and /final-mini.gif both exist. +Exit 1: video.mp4 missing, ffmpeg missing, or any step fails. +""" +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile + +import _platform + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEXT_OVERLAY_SCRIPT = os.path.join(HERE, "make_text_overlay.py") + +MIN_FFMPEG_MAJOR = 5 # -fps_mode replaced -vsync in ffmpeg 5.0 + +DURATION = "5.87" # seconds kept from the source video (141 frames @ 24fps) +OUTPUT_W = 720 # main GIF width +OUTPUT_H = 720 # main GIF height +MINI_W = 480 # mini GIF width +MINI_H = 480 # mini GIF height +OVERLAY_Y = 520 # vertical offset of the text overlay (top-left of the 720x176 strip) +OVERLAY_W = 720 +OVERLAY_H = 176 +FPS = 24 +PALETTE_DITHER = "bayer:bayer_scale=5" + + +def fail(message): + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + +def resolve_output(scene_dir, name, label): + """Resolve an output file name inside scene_dir, refusing escapes. + + `--output-name` reads as a bare file name, so it must not be able to leave + the scene directory: no absolute paths, no `..`, and no symlink that + resolves outside. + """ + if not name or os.path.isabs(name) or name.startswith(("\\", "/")): + fail(f"{label} must be a bare file name, not an absolute path: {name!r}") + if os.sep in name or (os.altsep and os.altsep in name) or "/" in name or "\\" in name: + fail(f"{label} must not contain a path separator: {name!r}") + reserved = _platform.windows_reserved_reason(name) + if reserved: + fail(f"{label} {name!r} would fail on Windows: {reserved}") + resolved = os.path.realpath(os.path.join(scene_dir, name)) + if not _platform.same_dir(os.path.dirname(resolved), scene_dir): + fail(f"{label} escapes the scene directory: {name!r}") + return resolved + + +def run(cmd, label): + print(f"[{label}] {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, **_platform.SUBPROCESS_TEXT) + if result.returncode != 0: + print(f"ERROR during {label}:\n{result.stderr}", file=sys.stderr) + sys.exit(1) + return result + + +def require_ffmpeg(): + exe = shutil.which("ffmpeg") + if exe is None: + fail(f"ffmpeg not found on PATH. Install ffmpeg {MIN_FFMPEG_MAJOR}.0+ first.") + probe = subprocess.run([exe, "-version"], capture_output=True, **_platform.SUBPROCESS_TEXT) + match = re.search(r"ffmpeg version n?(\d+)\.", probe.stdout or "") + if match and int(match.group(1)) < MIN_FFMPEG_MAJOR: + fail( + f"ffmpeg {match.group(1)}.x found, but this script uses -fps_mode, which " + f"requires ffmpeg {MIN_FFMPEG_MAJOR}.0+ (released 2022)." + ) + + +def require_text_overlay_script(): + if not os.path.exists(TEXT_OVERLAY_SCRIPT): + print(f"ERROR: {TEXT_OVERLAY_SCRIPT} not found. Run from the Plugin's scripts/ directory.", file=sys.stderr) + sys.exit(1) + + +def warn_if_not_square(video): + """The composite forces OUTPUT_W x OUTPUT_H, so a non-square source is squashed.""" + probe = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", video], + capture_output=True, **_platform.SUBPROCESS_TEXT, + ) + if probe.returncode != 0: + return # rendering will surface the real error + parts = probe.stdout.strip().split("x") + if len(parts) != 2: + return + try: + w, h = int(parts[0]), int(parts[1]) + except ValueError: + return + if w != h: + print( + f"WARNING: source video is {w}x{h}, not square. The composite scales it to " + f"{OUTPUT_W}x{OUTPUT_H}, which will distort the image. Re-encode to 1:1 first.", + file=sys.stderr, + ) + if (w, h) != (1080, 1080): + print( + f"NOTE: source video is {w}x{h}; the intended capture size is 1080x1080. " + "Rendering continues and scales to " + f"{OUTPUT_W}x{OUTPUT_H}.", + file=sys.stderr, + ) + + +def main(): + _platform.setup_console() + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("scene_dir", help="Scene directory containing video.mp4") + p.add_argument("caption", help="Chinese caption rendered onto the GIF") + p.add_argument("--output-name", default="final.gif", help="Main GIF name (default: final.gif)") + p.add_argument("--mini-name", default="final-mini.gif", help="Mini GIF name (default: final-mini.gif)") + args = p.parse_args() + + scene_dir = os.path.realpath(os.path.abspath(args.scene_dir)) + + # Argument validation first: a bad flag must fail before any I/O. + output = resolve_output(scene_dir, args.output_name, "--output-name") + mini = resolve_output(scene_dir, args.mini_name, "--mini-name") + + video = os.path.join(scene_dir, "video.mp4") + if not os.path.exists(video): + fail(f"{video} not found. Run stage 2 (gen_videos) first.") + + require_text_overlay_script() + require_ffmpeg() + warn_if_not_square(video) + + workdir = tempfile.mkdtemp(prefix="octopus_gif_") + frames_dir = os.path.join(workdir, "frames") + os.makedirs(frames_dir, exist_ok=True) + overlay = os.path.join(workdir, "overlay.png") + palette = os.path.join(workdir, "palette.png") + mini_frames_dir = os.path.join(workdir, "mini_frames") + os.makedirs(mini_frames_dir, exist_ok=True) + mini_palette = os.path.join(workdir, "mini_palette.png") + + try: + # 1. text overlay + run([sys.executable, TEXT_OVERLAY_SCRIPT, args.caption, overlay], "1/5 overlay") + + # 2. burn overlay -> 141 PNG frames @ 720x720 + run([ + "ffmpeg", "-y", + "-i", video, + "-loop", "1", "-i", overlay, + "-filter_complex", + f"[0:v]scale={OUTPUT_W}:{OUTPUT_H}[vid];" + f"[1:v]scale={OVERLAY_W}:{OVERLAY_H}[ovl];" + f"[vid][ovl]overlay=0:{OVERLAY_Y}[out]", + "-map", "[out]", "-t", DURATION, "-fps_mode", "passthrough", + os.path.join(frames_dir, "f_%04d.png"), + ], "2/5 burn-overlay -> frames") + + # 3. palettegen @ 720x720 + run([ + "ffmpeg", "-y", + "-framerate", str(FPS), + "-i", os.path.join(frames_dir, "f_%04d.png"), + "-vf", "palettegen=stats_mode=diff", + palette, + ], "3/5 palettegen") + + # 4. paletteuse -> final.gif + run([ + "ffmpeg", "-y", + "-framerate", str(FPS), + "-i", os.path.join(frames_dir, "f_%04d.png"), + "-i", palette, + "-lavfi", f"paletteuse=dither={PALETTE_DITHER}", + output, + ], "4/5 paletteuse -> final.gif") + + # 5. mini: downscale frames + palette + run([ + "ffmpeg", "-y", + "-framerate", str(FPS), + "-i", os.path.join(frames_dir, "f_%04d.png"), + "-vf", f"scale={MINI_W}:{MINI_H}", + os.path.join(mini_frames_dir, "f_%04d.png"), + ], "5a/5 downscale -> mini frames") + + run([ + "ffmpeg", "-y", + "-framerate", str(FPS), + "-i", os.path.join(mini_frames_dir, "f_%04d.png"), + "-vf", "palettegen=stats_mode=diff", + mini_palette, + ], "5b/5 mini palettegen") + + run([ + "ffmpeg", "-y", + "-framerate", str(FPS), + "-i", os.path.join(mini_frames_dir, "f_%04d.png"), + "-i", mini_palette, + "-lavfi", f"paletteuse=dither={PALETTE_DITHER}", + mini, + ], "5c/5 mini paletteuse -> final-mini.gif") + + main_size = os.path.getsize(output) / (1024 * 1024) + mini_size = os.path.getsize(mini) / (1024 * 1024) + print() + print(f"Done. Main: {output} ({main_size:.1f} MB) Mini: {mini} ({mini_size:.1f} MB)") + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/plugins/weekbin/octopus-meme-maker/scripts/make_preview_strip.py b/plugins/weekbin/octopus-meme-maker/scripts/make_preview_strip.py new file mode 100644 index 00000000..0dca34ba --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/make_preview_strip.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Extract 5 evenly-spaced key frames from a video and stitch them into a preview strip. + +Usage: + python3 make_preview_strip.py [--labels "a,b,c,d,e"] + +The 5 sampling points are 0%, 30%, 50%, 70% and 100% of the video duration, so +the script adapts to any 24 fps source (a 5.87 s / 141-frame clip and a 6.58 s / +158-frame clip both work). When --labels is omitted the labels are the real +timestamps of the sampled frames. + +Exit 0: exists (5 frames wide, label band at the bottom). +Exit 1: ffmpeg/ffprobe missing, the video has no decodable video stream, or the + output could not be written. +""" +import argparse +import os +import shutil +import subprocess +import sys +import tempfile + +from PIL import Image, ImageDraw + +import _fonts +import _platform + +SAMPLE_FRACTIONS = (0.0, 0.30, 0.50, 0.70, 1.0) +FRAME_WIDTH = 480 +LABEL_BAND_H = 50 +LABEL_BG = "white" +LABEL_FG = "black" +LABEL_FONT_SIZE = 24 + + + + + +def probe_video(video): + """Return (frame_count, fps) for the first video stream, or raise RuntimeError.""" + if shutil.which("ffprobe") is None: + raise RuntimeError("ffprobe not found on PATH. Install ffmpeg (which ships ffprobe).") + out = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=nb_frames,r_frame_rate", + "-of", "csv=p=0", video], + capture_output=True, **_platform.SUBPROCESS_TEXT, + ) + if out.returncode != 0: + raise RuntimeError(f"ffprobe failed on {video}:\n{out.stderr}") + parts = [p for p in out.stdout.strip().split(",") if p] + if len(parts) < 2: + raise RuntimeError(f"could not read frame count / fps from {video}: {out.stdout!r}") + rate_text, frames_text = parts[0], parts[1] + num, _, den = rate_text.partition("/") + fps = float(num) / float(den) if den else float(num) + frames = int(frames_text) if frames_text.isdigit() else 0 + if frames <= 0 or fps <= 0: + raise RuntimeError( + f"video {video} reports nb_frames={frames_text!r} r_frame_rate={rate_text!r}; " + "re-encode it with an explicit frame count first." + ) + return frames, fps + + +def sample_indices(frames): + """Return 5 unique frame indices spread across the clip.""" + last = frames - 1 + picked = [] + for fraction in SAMPLE_FRACTIONS: + idx = min(last, int(round(fraction * last))) + if idx not in picked: + picked.append(idx) + # Guarantee 5 distinct indices even on very short clips. + fill = 0 + while len(picked) < 5 and fill <= last: + if fill not in picked: + picked.append(fill) + fill += 1 + return sorted(picked)[:5] + + +def main(): + _platform.setup_console() + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("video", help="Input video (any square 24fps clip; 1080x1080 5.87s or 768x768 6.58s both work)") + p.add_argument("output", help="Output preview.png path") + p.add_argument("--labels", default=None, + help="Comma-separated 5 labels. Defaults to the sampled frames' real timestamps.") + p.add_argument("--font", default=None, help="Path to a CJK .ttc / .ttf file") + p.add_argument("--workdir", default=None, + help="Existing empty directory to extract frames into. Defaults to a temp dir that is removed on exit.") + args = p.parse_args() + + # 1. Argument validation first: fail fast, and never touch the filesystem + # before we know the caller's arguments are sane. + labels = None + if args.labels is not None: + labels = args.labels.split(",") + if len(labels) != 5: + print(f"ERROR: --labels must have exactly 5 comma-separated values, got {len(labels)}.", + file=sys.stderr) + sys.exit(1) + + # A caller-supplied --workdir is never deleted: refusing a non-empty one is + # cheaper than explaining why the user's files are gone. We also refuse a path + # that already exists as a regular file — `os.makedirs(..., exist_ok=True)` + # would raise an uncaught `FileExistsError` and leak a Python traceback. + cleanup = None + if args.workdir: + workdir = os.path.abspath(args.workdir) + if os.path.exists(workdir) and not os.path.isdir(workdir): + print(f"ERROR: --workdir {workdir} is not a directory. Pass a directory path, or omit " + "--workdir to use a temporary one.", file=sys.stderr) + sys.exit(1) + if os.path.isdir(workdir) and os.listdir(workdir): + print(f"ERROR: --workdir {workdir} is not empty. Pass an empty directory, or omit " + "--workdir to use a temporary one.", file=sys.stderr) + sys.exit(1) + else: + workdir = tempfile.mkdtemp(prefix="octopus_preview_") + cleanup = workdir + + # 2. Environment and input checks. + if shutil.which("ffmpeg") is None: + print("ERROR: ffmpeg not found on PATH. Install ffmpeg 5.0+ first " + "(-fps_mode replaced -vsync in 5.0).", file=sys.stderr) + sys.exit(1) + + try: + font_path = _fonts.resolve_font_path(args.font) + except _fonts.FontUnavailable as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + + if not os.path.isfile(args.video): + print(f"ERROR: {args.video} not found.", file=sys.stderr) + sys.exit(1) + + try: + frames, fps = probe_video(args.video) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + + indices = sample_indices(frames) + if labels is None: + labels = [f"t={i / fps:.1f}s" for i in indices] + + os.makedirs(workdir, exist_ok=True) + + try: + select_expr = "+".join(f"eq(n,{i})" for i in indices) + cmd = [ + "ffmpeg", "-y", + "-i", args.video, + "-vf", f"select='{select_expr}',scale={FRAME_WIDTH}:-1", + "-fps_mode", "passthrough", + os.path.join(workdir, "f_%d.png"), + ] + print(f"Extracting 5 key frames at {[f'{i / fps:.2f}s' for i in indices]}...") + result = subprocess.run(cmd, capture_output=True, **_platform.SUBPROCESS_TEXT) + if result.returncode != 0: + print(f"ERROR: ffmpeg failed:\n{result.stderr}", file=sys.stderr) + sys.exit(1) + + frame_files = sorted(f for f in os.listdir(workdir) if f.endswith(".png")) + if len(frame_files) != 5: + print(f"ERROR: expected 5 frames, got {len(frame_files)}: {frame_files}", file=sys.stderr) + sys.exit(1) + + imgs = [Image.open(os.path.join(workdir, f)) for f in frame_files] + w, h = imgs[0].size + strip = Image.new("RGB", (w * 5, h + LABEL_BAND_H), LABEL_BG) + font = _fonts.load_font(font_path, LABEL_FONT_SIZE) + draw = ImageDraw.Draw(strip) + for i, im in enumerate(imgs): + strip.paste(im, (i * w, 0)) + draw.text((i * w + 10, h + 10), labels[i], font=font, fill=LABEL_FG) + strip.save(args.output, "PNG") + print(f"OK: {args.output} ({w * 5}x{h + LABEL_BAND_H}, 5 frames, font={font_path})") + finally: + if cleanup: + shutil.rmtree(cleanup, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/plugins/weekbin/octopus-meme-maker/scripts/make_text_overlay.py b/plugins/weekbin/octopus-meme-maker/scripts/make_text_overlay.py new file mode 100644 index 00000000..031fcfe9 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/scripts/make_text_overlay.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Render a transparent Chinese text overlay PNG. + +Usage: + python3 make_text_overlay.py [--size 130] [--stroke 10] [--font ] + +The text is composited as black-fill + white-stroke on a transparent RGBA canvas. +By default the script picks the first available CJK font from a cross-platform +candidate list; pass --font to override. + +`--size` is the MAXIMUM point size. Longer captions are auto-shrunk until they +fit inside the canvas, so the text is never silently clipped; the size actually +used is printed on stdout. Pass --no-fit to disable the shrink and keep the +requested size. + +Exit 0: file written to , stdout prints + "OK: (x, text='', size=) ". +Exit 1: missing font and no --font override, or the text cannot fit at the + smallest allowed size. +""" +import argparse +import os +import sys + +from PIL import Image, ImageDraw + +import _fonts +import _platform + +TEXT_COLOR = (24, 24, 24) +STROKE_COLOR = (255, 255, 255) +CANVAS_W = 1080 +CANVAS_H = 220 +DEFAULT_SIZE = 130 +MIN_SIZE = 24 +SIZE_STEP = 2 +DEFAULT_STROKE = 10 + + +def fail(message): + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(1) + + + + + + +def measure(draw, text, font, stroke): + bbox = draw.textbbox((0, 0), text, font=font, stroke_width=stroke) + return bbox, bbox[2] - bbox[0], bbox[3] - bbox[1] + + +def main(): + _platform.setup_console() + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("text", help="Chinese text to render") + p.add_argument("output", help="Output PNG path") + p.add_argument("--size", type=int, default=DEFAULT_SIZE, + help=f"Maximum point size (default {DEFAULT_SIZE}); shrunk to fit if needed") + p.add_argument("--stroke", type=int, default=DEFAULT_STROKE) + p.add_argument("--font", default=None, help="Path to a CJK .ttc / .ttf file") + p.add_argument("--width", type=int, default=CANVAS_W) + p.add_argument("--height", type=int, default=CANVAS_H) + p.add_argument("--no-fit", action="store_true", + help="Keep the requested --size even if the text overflows the canvas") + args = p.parse_args() + + try: + font_path = _fonts.resolve_font_path(args.font) + except _fonts.FontUnavailable as exc: + fail(str(exc)) + + overlay = Image.new("RGBA", (args.width, args.height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # A caption longer than the canvas would be clipped silently, so shrink the + # font until it fits. --size is therefore a maximum, not a fixed value. + size = max(MIN_SIZE, args.size) + font = _fonts.load_font(font_path, size) + bbox, text_w, text_h = measure(draw, args.text, font, args.stroke) + if not args.no_fit: + while (text_w > args.width or text_h > args.height) and size > MIN_SIZE: + size = max(MIN_SIZE, size - SIZE_STEP) + font = _fonts.load_font(font_path, size) + bbox, text_w, text_h = measure(draw, args.text, font, args.stroke) + + if text_w > args.width or text_h > args.height: + if args.no_fit: + print( + f"ERROR: text is {text_w}x{text_h}px and does not fit the " + f"{args.width}x{args.height} canvas at --size {size}. Shorten the " + "caption, raise --width/--height, or drop --no-fit.", + file=sys.stderr, + ) + sys.exit(1) + print( + f"ERROR: text is still {text_w}x{text_h}px at the minimum size {MIN_SIZE}; " + f"it cannot fit the {args.width}x{args.height} canvas. Shorten the caption " + "or raise --width/--height.", + file=sys.stderr, + ) + sys.exit(1) + + x = (args.width - text_w) / 2 - bbox[0] + y = (args.height - text_h) / 2 - bbox[1] + draw.text( + (x, y), + args.text, + font=font, + fill=TEXT_COLOR, + stroke_width=args.stroke, + stroke_fill=STROKE_COLOR, + ) + overlay.save(args.output, "PNG") + print( + f"OK: {args.output} ({args.width}x{args.height}, text='{args.text}', " + f"size={size}, text={text_w}x{text_h}, font={font_path})" + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/SKILL.md b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/SKILL.md new file mode 100644 index 00000000..797d88b2 --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/SKILL.md @@ -0,0 +1,302 @@ +--- +name: octopus-meme-maker +description: Use this skill when the user asks for a new pink-octopus office-worker GIF meme, a Smooth Squishmallow-style worker octopus sticker, a 4-character Chinese caption on a 720x720 looping GIF, or wants to add a scene to an existing meme library. Drives a 4-stage pipeline (base pose via image_synthesize -> 6s 24fps video via gen_videos -> transparent Chinese text overlay via Pillow -> 720x720 + 480x480 GIF via ffmpeg) and enforces a strict character-anatomy ban-list. Read this skill before starting any new scene. +license: Apache-2.0 +metadata: + author: weekbin + version: 0.2.0 +--- + +# Octopus Meme Maker + +Generate one 720×720 looping GIF + one 480×480 mini-GIF per scene. Read this entire file before producing any output. + +## 1. Pre-flight + +`${PLUGIN_ROOT}` is the environment variable MiniMax Code sets to this Plugin's root directory. Every command in this file is anchored to it, so the commands work from any working directory. + +Read `reference.md` (anatomy + prompt template) and `issues.md` (ban-list + failure modes) before stage 1. + +### 1.1 Copy the reference frames + +Shell blocks in this file are POSIX shell: macOS, Linux, or Git Bash on Windows. + +The character / style reference is the **3 `examples/*.png` files** (3D +Squishmallow-style bases from prior scenes). `reference.md` § Reference images +explains why a finished base pose, and not a video frame, is the right anchor. + +```shell +mkdir -p /iterations /frames-check +cp "${PLUGIN_ROOT}"/examples/*.png / +``` + +Exit condition: `ls "${PLUGIN_ROOT}"/examples/*.png` lists 3 files; `/iterations/` and `/frames-check/` exist. + +## 2. Stage 1 — base pose (image_synthesize, 2K 1:1) + +Generate a 1024×1024 PNG that the user has personally picked from a contact +sheet. **The hard gate is "抽 6 张拼图给用户挑" — never ask the user to +confirm a single image, and never pick on the user's behalf.** The first +non-contact-sheet round is treated as an unauthorised shortcut. + +### 2.1 Generate 6 candidates in one batch + +Submit one `image_synthesize` call with **6 parallel requests** in the +`requests` array (NOT 6 separate calls). The tool accepts up to 10 requests. +All 6 share the same prompt and the same `input_file_paths` — the 3 +`examples/*.png` files, since the tool accepts at most 4 reference images per +request. The model returns 6 distinct candidates in one round-trip. + +Save successful outputs to `/iterations/vN-{1..6}-base.jpg`, +where `N` is the round number starting at 1. Reject any candidate that +matches an `issues.md` ban-list entry — note the bad index, but keep the +rest of the batch. + +If the round's batch has fewer than 4 non-banned candidates, re-roll only +the bad indices with a refined prompt (see § 7.1) and append the new ones +to the same round. Do not move on to round N+1 just because one image was +weak. + +### 2.2 Compose the contact sheet + +```shell +python3 "${PLUGIN_ROOT}/scripts/make_contact_sheet.py" \ + /iterations/vN-1-base.jpg \ + /iterations/vN-2-base.jpg \ + /iterations/vN-3-base.jpg \ + /iterations/vN-4-base.jpg \ + /iterations/vN-5-base.jpg \ + /iterations/vN-6-base.jpg \ + /iterations/vN-contact-sheet.png +``` + +The script lays the 6 candidates in a 3-cols × 2-rows grid with a bold +number badge (1-6) on each cell so the user can answer "3" / "选 2" / +"第 5 张" without ambiguity. Default cell size is 480×480; the sheet is +1460×974 px. + +Exit condition: `/iterations/vN-contact-sheet.png` exists and +shows 6 distinct numbered cells. + +### 2.3 Hand the contact sheet to the user + +Deliver the contact sheet to the user as an inline image — use a `` +tag so all 6 candidates are visible at once without leaving the chat. Then +**STOP and wait** for the user to pick. This is a hard gate that nothing +in Stage 1 or Stage 2 may bypass. + +#### Output to the free canvas + +The contact sheet MUST land on the user's free canvas (the workspace panel +that surfaces generated files) as well as in the chat reply. The action is +two-part: + +1. **Persist to disk** — the contact sheet is already at + `/iterations/vN-contact-sheet.png` after § 2.2; the 6 + individual candidates are at `/iterations/vN-{1..6}-base.jpg`. + Both paths must remain valid after the contact-sheet delivery. +2. **Surface in chat** — emit a `` + tag inside an assistant turn so the user can see all 6 candidates inline. + +Both steps are required. Persisting without surfacing leaves the user +guessing; surfacing without persisting means the user cannot re-pick later. + +| User signal | Action | +|---|---| +| **A number** ("3", "选 2", "第 5 张") | Copy `iterations/vN-{N}-base.jpg` → `/base.png` and proceed to Stage 2. | +| **All-bad signal** ("全部不行" / "再抽" / "继续抽卡" / "都不行") | Start a new round: bump N → N+1, refine the prompt per § 7.1, re-run § 2.1 and § 2.2. Repeat until the user picks. | +| **Specific feedback** ("3 号嘴有问题" / "想要更慵懒" / "表情再焦一点") | Treat as both a pick AND a refinement: lock the chosen one as `base.png`, note the feedback, then start a follow-up round N+1 with the feedback applied before committing to Stage 2. | + +**The contact-sheet loop is mandatory and runs until the user picks.** + +- **STOP after every contact-sheet delivery.** Do not start Stage 2, do not + start a follow-up round, do not move on in any way — the loop must wait + for the user's reply. +- **Loop indefinitely.** There is no upper bound on `N`; keep re-rolling + with the user's feedback applied until they pick a number or signal + abandon / relax. +- **Only the user ends the loop.** A single 'best of 1-2' round, picking + on the user's behalf because a candidate 'looks great', or skipping the + contact sheet to save time are all unauthorised shortcuts and forbidden. + The user is the curator; the model's taste does not count. + +Exit signal of § 2.3: the user has typed a number (e.g. "3" / "选 2") OR +has explicitly signalled that they want to abandon / relax the prompt. No +other input counts. + +### 2.4 Prompt skeleton + +```text +A cute pink octopus mascot plush toy character in . +[Full anatomy block — see reference.md § Anatomy] +[Scene-specific action / expression / prop] +Style: Squishmallow / Pop Mart 3D render, NOT furry, NOT stitched plush. +``` + +Exit condition: `/base.png` is a 1024×1024 PNG copied from a +contact-sheet candidate the user has explicitly chosen. The contact-sheet +loop has no upper bound on rounds — keep re-rolling until the user picks. +Only an explicit user signal ("选了 / OK / 继续 / 放弃 / 放松 prompt") ends +the loop; nothing else counts. + +## 3. Stage 2 — video (gen_videos) + +Call `gen_videos` once with a single request in the `requests` array (the tool +accepts up to 5). + +### 3.1 gen_videos parameters + +| Field | Value | +|---|---| +| `prompt` | the stage 1 style block + this scene's motion description | +| `output_file_path` | `/video.mp4` — required | +| `input_image_path` | `/base.png` — the base pose the user picked | +| `reference_type` | `first_frame` | +| `duration` | `6` | +| `resolution` | `1080P` | + +Every path passed to a host tool must be **workspace-relative**. The host +rejects absolute paths and any path outside the session workspace, system temp +directories among them. `output_file_path` is where the host writes the mp4 — +there is no separate copy step. + +`fps` is not a `gen_videos` parameter. The host picks the frame rate and +returns 24 fps for these clips, which is what the stage 4 scripts expect. +Generation is asynchronous and can take several minutes; the file appears at +`output_file_path` only after the job succeeds. + +Exit condition: `/video.mp4` exists and `ffprobe -show_streams video.mp4` reports `width=1080 height=1080` at `r_frame_rate=24/1` with `nb_frames=141`. + +## 4. Stage 3 — preview strip (sanity check, not the deliverable) + +### 4.1 Generate the strip + +```shell +python3 "${PLUGIN_ROOT}/scripts/make_preview_strip.py" /video.mp4 \ + /frames-check/preview.png \ + --labels "t=0.0s,,t=1.8s,,t=3.0s,,t=4.2s,,t=5.8s," +``` + +Exit condition: `frames-check/preview.png` exists, 5 frames wide, and the user confirms the animation reads as expected. + +## 5. Stage 4 — GIF assembly (the deliverable) + +### 5.1 Compose both GIFs + +```shell +python3 "${PLUGIN_ROOT}/scripts/make_gif.py" "" +``` + +The script writes both `/final.gif` (720×720) and `/final-mini.gif` (480×480). Both scripts resolve their own location, so the command works from any working directory. + +File size tracks scene complexity, not a fixed budget. Across the 23 reference scenes the main GIF lands between 2.8 MB and 15.8 MB (median 7.2 MB), and the mini GIF between 0.8 MB and 5.3 MB (median 1.6 MB). Report both sizes to the user as information; treat the ranges as a sanity band rather than a cap. + +Exit condition: both files exist, `ffprobe -select_streams v:0 -show_entries stream=width,height,nb_frames` reports `720,720,141` for `final.gif` and `480,480,141` for `final-mini.gif`, and the caption reads correctly. If `final.gif` exceeds 16 MB, apply the reduction levers in § Failure Recovery. + +### 5.2 Output to the free canvas + +The final GIFs are the deliverable. They MUST land on the user's free canvas +(workspace panel that surfaces generated files) as well as in the chat +reply. Same two-part action as § 2.3: + +1. **Persist to disk** — `final.gif` and `final-mini.gif` are already at + `/final.gif` and `/final-mini.gif` after § 5.1. + Both paths must remain valid after delivery. +2. **Surface in chat** — emit two `` tags (one for + `final.gif`, one for `final-mini.gif`) inside the assistant turn that + announces the GIF is ready. Wrap them in a single `` + block so the user can download both files from the chat: + ```xml + + + + + ``` + +Both steps are required. The user wants to see the GIF inline AND have it +persisted for reuse in chat apps / downloads. + +## 6. Examples + +### 6.1 摆烂躺平 (lying flat) + +- Caption: "摆烂躺平" +- Pose: sprawled on the desk, one eye half-closed, one tentacle holding a coffee cup +- Expected: `base.png` 1024×1024; `final.gif` ≈ 6 MB; `final-mini.gif` ≈ 1.6 MB. + +### 6.2 假装很忙 (pretending to be busy, no caption) + +- Caption: (none) +- Pose: phone in one tentacle, eyes down, neutral mouth +- Stage 4 command: `python3 "${PLUGIN_ROOT}/scripts/make_gif.py" ""` — the overlay is empty, only the video frames survive. + +### 6.3 期待 m3pro (waiting for m3pro) + +- Caption: "期待 m3pro" +- Pose: tentacles cupping the cheeks, eyes wide +- Expected: `base.png` 1024×1024; `final.gif` ≈ 4 MB; `final-mini.gif` ≈ 1.0 MB. + +## 7. Failure Recovery + +### 7.1 Stage 1 contact-sheet round needs a re-roll + +If the user issues an all-bad signal ("全部不行" / "再抽" / "继续抽卡") or +specific feedback ("嘴不行" / "表情不够慵懒" / "质感不对" / "缺耳朵"): + +1. Re-read `issues.md` § Ban-list and § Feedback signals. Map the feedback + to one or more concrete prompt deltas — e.g.: + - "mouth too dark" → drop any word that could be misread as a lip color; + - "想要更慵懒" → push the eyelid-arc coverage from "top 30%" to "top 50%"; + - "再焦一点" → raise grill-mark count from 3-4 to 5-6 stripes; + - **"质感不对" / "fuzzy" / "flocked" / "像毛绒玩具"** → add the SURFACE + block from `reference.md` § Prompt template at the TOP of the prompt; + call out the failure mode explicitly; re-check ALL 6 candidates not + just the ones the user flagged (texture drift tends to cluster in + the same batch); + - **"缺耳朵" / "头上没凸起"** → count the candidates that clearly show the + nubs. If **≥ 1 of 6** shows them, they are HIDDEN by pose / angle / smoke + (the common case): change the pose or the camera angle. If **0 of 6** + shows them, they are genuinely absent: add the bumps line at the very top + of the prompt. +2. Increment N → N+1. Generate a new batch of 6 candidates with the + refined prompt. **Do not** delete the previous round's files; they are + evidence and let the user compare. +3. Compose a fresh contact sheet and re-deliver to the user. + +If `N` reaches 4 without a user pick, escalate: surface the best candidate +from rounds 1-3 in a final contact sheet and ask the user to either pick +the best of the rest, relax the prompt scope, or abandon the scene. + +### 7.2 Stage 2 gen_videos rejects the path + +The host rejects absolute paths and any path outside the session workspace, +system temp directories among them. Keep both `output_file_path` and +`input_image_path` workspace-relative, e.g. `/video.mp4` and +`/base.png` relative to the session workspace root. If `base.png` +was generated outside the workspace, move it inside first, then re-submit with +the relative path. + +### 7.3 Stage 4 final.gif exceeds 16 MB + +The scene carries heavy motion blur, a wide gradient, or many distinct colours. Lower `PALETTE_DITHER` from `bayer:bayer_scale=5` to `bayer:bayer_scale=4` in `"${PLUGIN_ROOT}/scripts/make_gif.py"`, or reduce `FPS` from 24 to 20. Re-run stage 4; do not regenerate base or video. + +## 8. Quick Reference + +| Stage | Tool | Output | Exit criterion | +|---|---|---|---| +| 1. base pose (loop) | `image_synthesize` + `make_contact_sheet.py` | `/base.png` | 1024×1024 PNG, user-picked from a 6-image contact sheet; loop N → N+1 indefinitely until user picks | +| 2. video | `gen_videos` | `/video.mp4` | 1080×1080, 141 frames @ 24fps || 3. preview | `make_preview_strip.py` | `frames-check/preview.png` | 5 frames wide | +| 4. GIF | `make_gif.py` | `final.gif` + `final-mini.gif` | 720,720,141 and 480,480,141 | + +For character anatomy, prompt template, and reference frames, see `reference.md`. For known failure modes and feedback-signal translations, see `issues.md`. + +## 9. Hard Rules + +- **Stage 1 IS a contact-sheet loop. Generate 6 candidates, compose a contact sheet, deliver to the user via ``, then STOP and wait for the user to pick. Loop N → N+1, N+2, ... indefinitely until the user picks a number or explicitly signals abandon / relax. Stage 2 must not start until the user has picked.** This is the most important rule in this file. A single 'best of 1-2' round, picking on the user's behalf, or skipping the contact sheet because a candidate 'looks great' are all forbidden. The user is the curator, not the model. +- **Never pick on the user's behalf.** Stage 2 must not start until the user has explicitly chosen a number or issued an abandon / relax signal. +- **Never crop the video to fit text.** The text is composited as a transparent overlay, not baked into the video. +- **Never hardcode scene paths.** Use `` as a parameter; the Plugin must work for any user-provided scene name. +- **Never delete old iteration files.** They are evidence and let the user compare across rounds. +- **Output to the free canvas at every key checkpoint.** Stage 1 contact sheets (§ 2.3) and Stage 4 final GIFs (§ 5.2) MUST be persisted to the workspace AND surfaced inline via `` (and the final GIFs wrapped in ``). Persisting without surfacing leaves the user guessing; surfacing without persisting means the user cannot reuse the file. +- **Do not over-constrain the prompt.** Each MUST / SHOULD / NEVER rule you add increases the chance of character drift. Add a rule only when the model has *demonstrably* failed at something. The "烤焦了" scene went through 5 re-roll rounds (v1 → v5) precisely because every round added another rule; the user ultimately picked the v1 candidate that was generated by the original simple prompt. Reference images do most of the anchoring work — trust them. +- After stage 4, append a row to the maintainer's `README.md` scene table with `path`, `caption`, and `size` for both GIFs. diff --git a/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/issues.md b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/issues.md new file mode 100644 index 00000000..cbe3ea6c --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/issues.md @@ -0,0 +1,79 @@ +# Known Issues and Failure Modes + +When the user reports a problem, find the matching row, read the root cause, apply the fix. Do not skip this file. + +## Ban-list (rejects any base pose that matches) + +| Symptom | Root cause | Fix | +|---|---|---| +| Eyes drawn as beige sockets with a black line | Anatomy description omitted | Re-paste the full eye-anatomy block from `reference.md` into the prompt | +| Sinister / evil smirk | Prompt contains "smirk", "squinted upward", or "evil" | Remove these words; replace with "HORIZONTAL half-lidded" and "cute, not sinister" | +| Visible teeth or tongue | Prompt contains "show teeth" or "tongue" | Add explicit "NEVER show teeth or tongue" | +| Eyebrows drawn as a single black bar | Prompt contains "eyebrow" (misread as one thick bar) | Replace with the per-eye upper-eyelid arc description | +| Mouth tilts upward into a grin | Prompt or model defaulted to a smile | Add "Mouth opens HORIZONTALLY, never angles upward" | +| Head bumps absent (model rendered zero bumps) | Prompt did not mention them | Add "TWO small soft rounded nubs integrated into the upper-left and upper-right of the head silhouette" to the anatomy block | +| Head bumps HIDDEN by pose / angle / smoke / framing (model rendered them, but the camera angle, a tucked pose, dense smoke, or a tight crop hides one or both bumps) | Pose or composition overrules the bumps; this is the more common failure mode of the two | (1) Use phrasing like "soft nubs integrated into the head silhouette" instead of "ears on top" (the model produces better proportions), (2) when the scene requires a tight pose (e.g. octopus on its back, head tucked under tentacles), change the pose or the camera angle so both bumps read | +| Body surface looks flocked / fuzzy / felt / furry / woven / cloth instead of smooth Squishmallow vinyl | Model drifts toward "well-done plush" or "charred plush" and adds a fabric texture; common when the scene is BBQ / fire / hot / cozy | (1) Add the SURFACE block from `reference.md` § Prompt template verbatim, (2) call out the failure mode explicitly: "DO NOT render the body as flocked velvet, fuzzy fabric, felt, fur, woven cloth, or stitched plush", (3) add "Texture must be consistent across the entire body — no smooth/fuzzy zones", (4) re-check ALL 6 candidates in the contact sheet, not just one | +| One candidate in the batch looks sharper than the others (texture / lighting / crispness drift across the 6) | Inconsistent prompt application across parallel requests | Reject the off-style candidates and re-roll ONLY those indices with the SURFACE block added at the top of the prompt, not the bottom | + +### Failure modes specific to the character anatomy (the "烤焦了" lesson) + +These came up during the live contact-sheet loop and are now banned outright. +Each row maps to a specific round of re-rolls where a feedback signal +correctly translated to a prompt fix. + +| Symptom (烤焦了 rounds) | Root cause | Fix | +|---|---|---| +| **Eyes too big / chibi / anime scale** (the model drew eyes covering ~30-40% of the head width when the reference shows ~15-18%) | Writing "LARGE WHITE CIRCLES" or giving the model exact numeric eye ratios ("each eye 15-18% of head width"); the model over-applies the ratio and exaggerates | Just say "MEDIUM-sized white circles" and let the reference images set the scale. Do NOT specify numeric eye ratios in the prompt — the model distorts them | +| **Bumps became bear-ear / mouse-ear / hamster-ear shape** with a flat or patch-like area on top of the head between them | Writing "ear-like bumps on top of the head" makes the model render them as discrete ears; adding "flat area between them" makes the model render that as a separate patch | Describe them as "two soft rounded nubs integrated into the head silhouette" and "emerging from the head", NOT as "ears". Do NOT add a separate "flat area" — say the top of the head is the normal pink dome | +| **Bumps too small / close together / pointy horns** (v3 — flat blob head with tiny pin-prick horns) | Strict "BOTH bumps must be visible and unobscured" rule pushed the model toward minimum-tiny horns to maximize visibility | Use the soft-nub framing (above) instead of the "must be visible" rule. The visibility comes from the head silhouette, not from explicit bumps | +| **Head became flat blob with no bumps and tentacles spread like a starfish** (v3 — model overcompensated after the "no marks on head" rule) | Removing all decoration from the head made the model simplify the character into a flat blob | Keep the soft-nub framing (which produces bumps as part of the silhouette) and trust the model. Do NOT over-restrict | +| **Head marks look like X / frown / tally / cross-hatch / tattoo** (v2 — model drew the grill marks as patterns on the head) | Asking for grill marks anywhere on the head, even subtly, leads to over-decorated patterns | All grill marks / singes / scratches / tattoo lines / X marks MUST go on the outer tentacles only. NEVER ask for marks on the head, face, or forehead | + +###### Meta-rule: do not over-constrain + +The "烤焦了" scene went through 5 re-roll rounds (v1 → v5) because each fix +added another MUST / SHOULD / NEVER rule. The user ultimately picked v1-5, +which was generated by the **original simple prompt** (before any of the +"fixes"). The lesson: + +> **The more MUST / NEVER / ALWAYS rules you pile on, the further the +> character drifts from the reference.** Trust the model's natural +> interpretation when given a clean anatomy block + style block; only add +> rules for things the model has *demonstrably* failed at. The reference +> images do most of the anchoring work. + +When you must add a rule, place it at the TOP of the prompt (after the +anatomy summary), not the bottom — rules at the bottom get deprioritized by +the model. + +## Pipeline failures + +| Symptom | Root cause | Fix | +|---|---|---| +| `gen_videos` rejects the path | The host rejects absolute paths and any path outside the session workspace, system temp directories included | Keep `output_file_path` and `input_image_path` workspace-relative. If `base.png` was generated outside the workspace, move it inside first | +| `make_gif.py` hangs on ffmpeg filter_complex | Single ffmpeg invocation with too many filters | The shipped script already splits the work into separate ffmpeg runs (overlay, palettegen, paletteuse); do not collapse them into one | +| `final.gif` exceeds 16 MB | Background contains gradient / motion blur / many distinct colors | Lower `PALETTE_DITHER` from `bayer:bayer_scale=5` to `bayer:bayer_scale=4` in `"${PLUGIN_ROOT}/scripts/make_gif.py"`, or reduce `FPS` from 24 to 20. Re-run stage 4; do not regenerate base or video | +| `final-mini.gif` is unexpectedly large (5 MB+) | High-frequency detail survives the 480×480 downscale, so the mini palette still needs many colors | Reduce fps from 24 to 20, or accept it — the reference scenes range 0.8–5.3 MB, so a large mini is not by itself a defect | +| Caption is longer than 8 characters | The 1080-wide overlay canvas only fits about 8 CJK glyphs at the maximum size 130 | `make_text_overlay.py` auto-shrinks the font until the caption fits and prints the size it used (`text=x`); check that line. Pass `--no-fit` only if you want the overflow reported as an error instead | +| `gen_videos` task stays "running" for a long time (H3) | The host is async and queued workers can take many minutes | Re-poll with `query_video_generation` about once a minute; the file appears at `output_file_path` only after the job succeeds | + +## Feedback signals (translate user reports into action) + +| User says | Action | +|---|---| +| "眼睛是白色的嘛" or "眼睛画错了" | Re-read `reference.md` § Anatomy; re-paste the full eye block; re-run stage 1 | +| "邪恶" / "太丑了" | Sweep the prompt for any ban-list word; replace with horizontal / cute / not-sinister | +| "质感不对" / "fuzzy" / "flocked" / "像毛绒玩具" / "像布料" | Add the SURFACE block from `reference.md` § Prompt template verbatim; reject any candidate whose body looks like fabric; re-roll with the SURFACE block at the TOP of the prompt | +| "耳朵没了" / "缺耳朵" / "头上没凸起" | First check whether the bumps are HIDDEN (camera angle / pose / framing) rather than absent — if so, change the pose or the camera angle per the "Head bumps HIDDEN" ban-list row; if truly absent, add the soft-nub framing at the very top of the prompt (NOT the "ear" framing) | +| "耳朵不对" / "bump 不像耳朵" / "bump 太大太小" / "bumps 像小熊耳" | Re-read the failure-mode row "Bumps became bear-ear / mouse-ear / hamster-ear" — reframe the bumps as "soft nubs integrated into the head silhouette", drop "ears" / "flat area" / "ear-like" from the prompt | +| "眼睛太大" / "像 chibi" / "像 anime" / "眼睛变形" | Re-read the failure-mode row "Eyes too big / chibi / anime scale" — drop "LARGE WHITE CIRCLES" and any numeric eye ratio; say "MEDIUM-sized" and trust the reference | +| "切掉了一部分" or "图片被裁了" | Stop cropping the video for text. Use the transparent overlay path (stage 4) | +| "文字后面有白底" | The script's overlay is transparent by default; check that the canvas is RGBA and `(0,0,0,0)` | +| "视频太短 / 太快" | Check `ffprobe` `nb_frames` and `duration`; the target is 141 frames at 24fps = 5.87s | +| "和之前不一样了" | The character drifted from the reference. Re-run stage 1 with all 3 reference frames attached; check whether the new prompt is over-constraining (see meta-rule above) | +| "嘴巴不对" / "嘴唇变形" / "嘴画错了" | Re-read `reference.md` § Anatomy mouth line; ensure prompt contains "small horizontal squiggly line or omega-frown, slightly open, just below the eye line. No teeth, no tongue, no visible inner mouth" | + +## Maintenance + +When a new failure mode is found, add a row. When a fix is no longer relevant (e.g. a ban-list word is no longer triggering the model), remove the row — do not keep a "previously this was broken" history. \ No newline at end of file diff --git a/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/reference.md b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/reference.md new file mode 100644 index 00000000..21fa871a --- /dev/null +++ b/plugins/weekbin/octopus-meme-maker/skills/octopus-meme-maker/reference.md @@ -0,0 +1,172 @@ +# Character & Style Reference + +Use this file to keep the character consistent across scenes. Read it before +every `image_synthesize` call in stage 1. + +## What the bundled reference folders actually are + +This Plugin ships two folders that work together: + +| Folder | What it actually contains | Role in the pipeline | +|---|---|---| +| `examples/-base.png` | Three complete 3D Squishmallow-style base images produced by `image_synthesize` for prior scenes (stay-late / toilet-slacking / touch-fish). | **The character + style reference.** Pass all three as `input_file_paths` for every new scene; the prompt overrides the scene composition but inherits the Squishmallow style, anatomy, and rendering vibe. | +| `reference/videos/*.mp4` | Two illustrative H3-generated videos (breakdown, treat-milk-tea) showing the animation feel this Plugin targets. | **Animation-feel reference.** Optional; see § h3 Source Videos below. | + +## Character — what the Squishmallow octopus ACTUALLY looks like + +The character is a **3D Squishmallow / Pop Mart plush toy**. The shape below is +what `image_synthesize` produces when left to its own devices with a clean +prompt — `examples/02-stay-late-base.png` and the user-picked v1-5 of the +"烤焦了" scene are the visual ground truth. + +- **Head** — a ROUND coral-pink dome, slightly egg-shaped (a touch taller than + wide). NOT a sphere, NOT a teardrop, NOT a stretched oval. +- **Two soft head bumps** — present on the upper-LEFT and upper-RIGHT of the + head silhouette. They are SOFT ROUNDED little nubs that **emerge from the + head**, NOT separate ears glued on top. The bumps are part of the head's + outline, not protruding significantly. There is a clear FLAT or gently + domed pink area between them — about 20-30% of the head width. +- **Eyes** — TWO MEDIUM-sized WHITE CIRCLES drawn directly on the pink face. + Each eye is roughly 15-18% of the head WIDTH (NOT oversized, NOT chibi / + anime scale). Positioned in the LOWER-MIDDLE of the head (centers around + 55-65% down from the top). +- **Pupil** — a BLACK ROUND shape filling about 70% of the white eyeball, with + a small WHITE HIGHLIGHT DOT (typically upper-left of the pupil) that gives + the eye life. +- **Upper eyelid arc** — a SHORT THIN BLACK ARC sitting just above each + white eyeball, HORIZONTAL, covering roughly the top 30% of the white + circle in a neutral state. Never tilts up at the outer corners. +- **Mouth** — a SMALL HORIZONTAL squiggly line or omega-frown, slightly open. + Located just BELOW the eye line (about 75% down from the top of the head). + Subtle, not exaggerated. +- **Tentacles** — 8 short stubby pink tentacles around the body, slightly + curved. +- **Body** — uniform coral-pink, NO second tone, NO shading patches, NO + accessory ears / hair / clothes. +- **Surface** — smooth Squishmallow / Pop Mart plush-toy vinyl sheen. NOT + flocked velvet, NOT fuzzy fabric, NOT felt, NOT fur, NOT woven cloth, + NOT stitched plush. + +### Critical anatomy rules + +- **Bumps MUST stay integrated into the head silhouette.** If you describe them + as "ears" or emphasize them as separate features, the model produces + mouse-ear / bear-ear shapes. Use the soft-nub framing. +- **Eyes MUST stay medium-sized.** If you write "LARGE WHITE CIRCLES" or + specify exact eye proportions, the model drifts toward chibi / anime scale. + Just say "medium-sized white circles" and trust the reference images to + set the scale. +- **No markings / singes / textures on the head or face.** Tentacle singes + are OK on the underside of outer tentacles. Marks on the head turn into + X's, tally lines, frown marks, or patch artifacts. +- **No "EXACT" numeric ratios** in the prompt. The model over-applies numeric + ratios (e.g. "each bump 18-22% of head width") and produces distorted + cartoon geometry. Describe the SHAPE, not the NUMBER. + +## Style + +- Smooth Squishmallow / Pop Mart 3D render, vibrant coral-pink plush-toy + surface with a soft vinyl sheen. +- Render quality: high-resolution, crisp details, consistent surface texture + across the whole body. Reject any output that looks blurry, fuzzy, or has + inconsistent texture zones (e.g. smooth head but flocked tentacles). +- Soft warm lighting. Background varies by scene (office desk for work scenes, + restroom for toilet scenes, BBQ grill for grill scenes, etc.). +- Cute, not sinister. The whole character reads as a friendly office worker / + meme character. + +## Reference images + +**Always pass the three `examples/*.png` files** as `input_file_paths` to +`image_synthesize` for every new scene. They lock the +Squishmallow style, the 8 tentacles, the eye anatomy, and the head bumps. +The prompt supplies the new scene composition; the references supply the +visual style. + +| File | What it shows | +|---|---| +| `examples/02-stay-late-base.png` | Half-lidded eyes, octopus at desk with coffee + monitor | +| `examples/10-toilet-slacking-base.png` | Open-mouth smile, big sparkly eyes, octopus on toilet reading phone | +| `examples/11-touch-fish-base.png` | Side-glance, octopus at desk with salmon | + +If you also have a relevant H3 source video (see § h3 Source Videos below), +extract its first frame into the session workspace and pass it as a fourth +`input_file_path` to lock the scene composition too. + +## Prompt template (paste into image_synthesize) + +```text +A cute pink octopus mascot plush toy character in [SCENE_POSE]. +Round coral-pink head with TWO small soft rounded nubs integrated into the +upper-left and upper-right of the head silhouette. There is a flat pink area +between the two nubs (about 25% of the head width). 8 stubby pink tentacles. + +[Concrete action / expression / prop description for this scene] + +CRITICAL EYE ANATOMY (must match the reference exactly): +- The whole head and face is the SAME coral-pink plush body color. +- The eyes are MEDIUM-sized white circles drawn directly on the pink face. +- Inside each white eyeball there is a BLACK ROUND PUPIL that fills about + 70% of the white, with a small WHITE HIGHLIGHT DOT on the upper-left of + the pupil. +- A SHORT THIN BLACK UPPER EYELID ARC sits just above each individual white + eyeball, covering roughly the top 30% of the white circle. +- The eyelid arc is HORIZONTAL — never tilts upward at the outer corners + (NEVER smirk, NEVER evil). +- Both arcs are the same length and mirror each other. +- Plenty of WHITE EYEBALL VISIBLE around the black pupil and under the + eyelid. + +MOUTH: small horizontal squiggly line or omega-frown, slightly open, just +below the eye line. No teeth, no tongue, no visible inner mouth. + +EXPRESSION: [scene-specific emotion], cute, not sinister. + +[Pose / background / KEEP / NO list per scene] + +SURFACE (highest priority after anatomy): +- The character body must have a smooth Squishmallow / Pop Mart plush-toy + vinyl surface with a soft sheen. +- DO NOT render the body as flocked velvet, fuzzy fabric, felt, fur, woven + cloth, or stitched plush. +- Texture must be consistent across the entire body — no smooth/fuzzy + zones. +- Crisp high-resolution render, no soft blur. + +Style: high-quality 3D character render (NOT 2D cartoon, NOT pixel art, NOT +clay), vibrant coral-pink Squishmallow plush-toy vinyl surface, soft warm +lighting, [scene background]. The character is the same pink octopus mascot +from the reference images. +``` + +## h3 Source Videos + +`reference/videos/` holds 2 illustrative H3-generated videos — one expressive-anxiety scene and one multi-tentacle-coordination scene — selected as samples of the animation feel (tentacle weight, eye-blink timing, prop interaction). Both are 768×768, 24 fps, 6.58 s, 158 frames, h264 + aac. + +| File | Size | Scene | Notes | +|---|---|---|---| +| `breakdown-h3.mp4` | 478 KB | 我裂开了 | Both hands on the head, eyes spiraling | +| `treat-milk-tea-h3.mp4` | 631 KB | 请大家喝奶茶 | All 8 tentacles holding a colorful cup | + +This Plugin ships 2 sample videos to keep the package small. They are illustrations of the animation feel, not a required input. Generate the H3 video for your own scene when you reach stage 2. + +### When to use an h3 video as the base input + +When an H3 video for the target scene already exists (this Plugin's +`reference/videos/`, or one you generated), extract its first frame **into the +session workspace** and pass that frame as a fourth `input_file_path` — for +example, write it to `/iterations/h3-first.png` and pass +`input_file_paths = [the 3 examples/*.png, "/iterations/h3-first.png"]`. +The host accepts at most 4 reference images and rejects paths outside the +session workspace, so a frame left in `/tmp` cannot be used. This locks the +Squishmallow style and the scene composition, so stage 1 only needs to vary the +pose / expression — not the whole layout. + +## Default settings + +- `resolution`: `1K`, `aspect_ratio`: `1:1` → 1024×1024 PNG +- `input_file_paths`: the 3 `examples/*.png` files (the host accepts at most 4) +- Negative prompt: anything that triggers the `issues.md` ban-list (smirk, + evil, teeth, tongue, eyebrow, tilted eyelids, missing head bumps, + bear-ear bumps, chibi / anime proportions, fuzzy / flocked body, + head markings) \ No newline at end of file diff --git a/tests/plugins/octopus-meme-maker/smoke.test.mjs b/tests/plugins/octopus-meme-maker/smoke.test.mjs new file mode 100644 index 00000000..08dca495 --- /dev/null +++ b/tests/plugins/octopus-meme-maker/smoke.test.mjs @@ -0,0 +1,1008 @@ +// tests/plugins/octopus-meme-maker/smoke.test.mjs +// +// Static self-audit + negative-injection for the octopus-meme-maker Plugin. +// Run by `npm test` (which is `node --test`). No side effects; all reads. +// +// The negative-injection block at the bottom verifies that the audit +// correctly catches deliberately broken inputs (PR review #21 round-4 / +// #33 round-4 — "false-green holes" defence). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, statSync, readdirSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, unlinkSync, openSync, readSync, closeSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; + +const REPO = process.cwd(); +const PLUGIN = join(REPO, 'plugins', 'weekbin', 'octopus-meme-maker'); +const PLUGIN_JSON = join(PLUGIN, 'plugin.json'); +const MARKETPLACE_JSON = join(PLUGIN, '.minimax-plugin', 'plugin.json'); +const CLAUDE_JSON = join(PLUGIN, '.claude-plugin', 'plugin.json'); +const README = join(PLUGIN, 'README.md'); +const LICENSE = join(PLUGIN, 'LICENSE'); +const ICON = join(PLUGIN, 'icon.png'); +const SKILL = join(PLUGIN, 'skills', 'octopus-meme-maker', 'SKILL.md'); +const REF_DIR = join(PLUGIN, 'reference'); +const EX_DIR = join(PLUGIN, 'examples'); +const SCRIPTS_DIR = join(PLUGIN, 'scripts'); + +const VALID_CATEGORIES = new Set([ + 'Office', 'Studio', 'Design & Sites', 'Code', 'Business', 'Sales', + 'Productivity', 'Science & Healthcare', 'Education', 'Other', +]); + +function readJson(p) { return JSON.parse(readFileSync(p, 'utf8')); } +function readText(p) { return readFileSync(p, 'utf8'); } + +// --------------------------------------------------------------------------- +// 1. Required files +// --------------------------------------------------------------------------- + +test('required files exist', () => { + for (const p of [PLUGIN_JSON, MARKETPLACE_JSON, README, LICENSE, ICON, SKILL]) { + assert.ok(existsSync(p), `missing: ${p}`); + } +}); + +test('icon is a square PNG, right-sized for a store listing', () => { + assert.ok(existsSync(ICON)); + const s = statSync(ICON); + assert.ok(s.size > 0, 'icon empty'); + assert.ok(s.size < 16 * 1024 * 1024, `icon exceeds the 16 MiB per-file cap: ${s.size}`); + assert.ok(s.size <= 512 * 1024, `icon should be compressed; got ${(s.size / 1024).toFixed(0)} KB`); + const head = readFileSync(ICON).subarray(0, 8); + assert.deepEqual( + Array.from(head.subarray(0, 8)), + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + 'icon must be a real PNG (8-byte magic header)', + ); + // PNG IHDR: width and height are big-endian uint32 at bytes 16..24 + const buf = readFileSync(ICON); + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + assert.equal(width, height, `icon must be square; got ${width}x${height}`); + assert.ok(width >= 128 && width <= 1024, `icon side should be 128..1024; got ${width}`); +}); + +// --------------------------------------------------------------------------- +// 2. plugin.json (community registry shape) +// --------------------------------------------------------------------------- + +const PLUGIN_FIELDS = new Set([ + '$schema', 'name', 'version', 'description', 'author', 'homepage', + 'repository', 'license', 'keywords', 'extensions', +]); + +test('plugin.json uses only the closed-schema root fields', () => { + const m = readJson(PLUGIN_JSON); + for (const key of Object.keys(m)) { + assert.ok(PLUGIN_FIELDS.has(key), `plugin.json has unknown field: ${key}`); + } +}); + +test('plugin.json: name, version, license, description, homepage, repository', () => { + const m = readJson(PLUGIN_JSON); + assert.equal(m.name, 'octopus-meme-maker'); + assert.ok(m.$schema, '$schema required'); + assert.match(m.version, /^\d+\.\d+\.\d+$/, 'version must be SemVer'); + assert.ok(m.description.length > 0 && m.description.length <= 1024, `description 1..1024 (got ${m.description.length})`); + assert.ok(m.author && m.author.name, 'author.name required'); + assert.ok(m.license && m.license.length > 0, 'license required'); + assert.ok(typeof m.homepage === 'string' && m.homepage.startsWith('https://'), 'homepage string required'); + assert.ok(typeof m.repository === 'string' && m.repository.startsWith('https://'), 'repository string required'); +}); + +test('plugin.json does NOT carry Marketplace fields (those live in .minimax-plugin/plugin.json)', () => { + const m = readJson(PLUGIN_JSON); + const MARKETPLACE_ONLY = [ + 'schemaVersion', 'displayName', 'icon', 'category', + 'exampleQueries', 'apps', 'mcpServers', 'skills', + ]; + for (const f of MARKETPLACE_ONLY) { + assert.equal(m[f], undefined, `plugin.json must not have ${f}`); + } +}); + +// --------------------------------------------------------------------------- +// 3. .minimax-plugin/plugin.json (Marketplace shape) +// --------------------------------------------------------------------------- + +test('.minimax-plugin/plugin.json: schemaVersion, name, displayName, description, author, icon, category, exampleQueries, apps, mcpServers, skills', () => { + const m = readJson(MARKETPLACE_JSON); + assert.equal(m.schemaVersion, 1); + assert.equal(m.name, 'octopus-meme-maker'); + assert.ok(m.displayName && m.displayName.length > 0, 'displayName required'); + assert.ok(m.description.length > 0 && m.description.length <= 1024, 'description 1..1024'); + assert.equal(typeof m.author, 'string', 'Marketplace author must be a string'); + assert.ok(m.author.length > 0 && m.author.length <= 1024, 'author 1..1024'); + assert.ok(typeof m.icon === 'string' && m.icon.endsWith('.png'), 'icon path ends with .png'); + assert.ok(VALID_CATEGORIES.has(m.category), `unknown category: ${m.category}`); + assert.ok(Array.isArray(m.exampleQueries) && m.exampleQueries.length <= 3, 'exampleQueries 0..3'); + for (const q of m.exampleQueries) { + assert.ok(q.length > 0 && q.length <= 4096, `exampleQueries entry 1..4096 (got ${q.length})`); + } + assert.ok(Array.isArray(m.apps), 'apps must be array'); + assert.ok(Array.isArray(m.mcpServers), 'mcpServers must be array'); + assert.ok(Array.isArray(m.skills) && m.skills.length >= 1, 'at least 1 skill'); +}); + +test('Marketplace exampleQueries have no host-literal paths', () => { + const m = readJson(MARKETPLACE_JSON); + for (const q of m.exampleQueries) { + assert.ok(!/\/Users\//.test(q), `exampleQueries must not contain /Users/ path: ${q}`); + assert.ok(!/~\/minimax\//.test(q), `exampleQueries must not contain ~/.minimax/ path: ${q}`); + assert.ok(!/~\/Works\//.test(q), `exampleQueries must not contain ~/Works/ path: ${q}`); + } +}); + +// --------------------------------------------------------------------------- +// 4. README 4-section disclosure (PR review #33 round-3) +// --------------------------------------------------------------------------- + +test('README has "What this Plugin does NOT do" 4-section disclosure', () => { + const text = readText(README); + assert.match(text, /What this Plugin does NOT do/, 'must have section heading'); + for (const phrase of [ + 'No credentials', + 'No network access at runtime', + 'No telemetry', + 'No third-party services', + ]) { + assert.ok(text.includes(phrase), `README must say "${phrase}"`); + } +}); + +test('README states the host-tool requirement', () => { + const text = readText(README); + assert.match(text, /image_synthesize/, 'README must name the image_synthesize host tool'); + assert.match(text, /gen_videos/, 'README must name the gen_videos host tool'); +}); + +// --------------------------------------------------------------------------- +// 4b. Host version pin surface (fail-closed, mirroring the PR #33 round-7 +// precedent). The portable Plugin schema declares no `requirements` field, +// and `metadata.minMcodeVersion` is the wrong surface for it: the only +// permitted place for a host version constraint is the human-readable +// `description`, which both the marketplace UI and the agent's own +// discovery surface render. +// --------------------------------------------------------------------------- + +test('plugin.json does NOT declare a requirements field', () => { + const m = readJson(PLUGIN_JSON); + assert.equal( + 'requirements' in m, + false, + 'the portable Plugin schema has no `requirements` field; the repository validator rejects it as an unknown field', + ); +}); + +test('descriptions are plain text, not markdown', () => { + // The desktop UI renders `description` as plain text. Markdown emphasis or + // code spans therefore show up literally, e.g. "**Requires ...**" and + // "`image_synthesize`". Keep both manifests free of markdown syntax. + for (const [label, raw] of [ + ['plugin.json', readJson(PLUGIN_JSON)], + ['.minimax-plugin/plugin.json', readJson(MARKETPLACE_JSON)], + ]) { + for (const field of ['description', 'displayName']) { + const value = raw[field]; + if (typeof value !== 'string') continue; + for (const token of ['**', '`', '__', '##', '](']) { + assert.equal( + value.includes(token), + false, + `${label}.${field} must not contain markdown "${token}": ${value}`, + ); + } + } + } +}); + +test('documented GIF guidance matches the reference data, not a hard cap', () => { + // The 23 reference scenes produce final.gif 2.8-15.8 MB and final-mini.gif + // 0.8-5.3 MB, and base.png 1.5-4.5 MB. An earlier revision capped these at + // 6.4 MB / 1.7 MB / 2 MB, which flagged most of the author's own accepted + // scenes as failures. Guard against reintroducing a byte-size cap. + const skill = readText(SKILL); + const readme = readText(README); + for (const [label, text] of [['SKILL.md', skill], ['README.md', readme]]) { + for (const stale of ['≤ 6.4 MB', '≤ 1.7 MB', '> 7 MB', '≥ 2 MB', '≥ 3 MB', '4-7 MB']) { + assert.equal(text.includes(stale), false, `${label} still asserts a stale size threshold: "${stale}"`); + } + } +}); + +test('SKILL.md exit conditions are dimension-based', () => { + const skill = readText(SKILL); + for (const probe of ['1024×1024', '720,720,141', '480,480,141']) { + assert.ok(skill.includes(probe), `SKILL.md exit criteria must check ${probe}`); + } +}); + +test('README documents a runnable self-test that uses the bundled sample video', () => { + const text = readText(README); + assert.match(text, /reference\/videos\/breakdown-h3\.mp4/, 'must use the bundled sample so the self-test needs no scene'); + assert.match(text, /1080×220/, 'must state the expected overlay size'); + assert.match(text, /2400×530/, 'must state the expected preview size'); +}); + +test('plugin.json description carries the host-tool requirement', () => { + const m = readJson(PLUGIN_JSON); + assert.match(m.description, /image_synthesize/, 'description must name the image_synthesize host tool'); + assert.match(m.description, /gen_videos/, 'description must name the gen_videos host tool'); +}); + +test('SKILL.md does not pin the host version via metadata.minMcodeVersion', () => { + const text = readText(SKILL); + const fm = text.match(/^---\n([\s\S]+?)\n---/); + assert.ok(fm, 'YAML frontmatter required'); + assert.equal( + /minMcodeVersion/.test(fm[1]), + false, + 'metadata.minMcodeVersion is the wrong surface for a host version pin (PR #33 round-7)', + ); + assert.equal( + /^\s*scope:/m.test(fm[1]), + false, + 'invented metadata key `scope` must not be declared', + ); +}); + +test('SKILL.md metadata maps strings to strings only', () => { + const text = readText(SKILL); + const fm = text.match(/^---\n([\s\S]+?)\n---/); + assert.ok(fm, 'YAML frontmatter required'); + const block = fm[1].match(/^metadata:\n((?:\s+.+\n?)+)/m); + if (!block) return; // metadata is optional + for (const line of block[1].trimEnd().split('\n')) { + const value = line.replace(/^\s+[^:]+:\s*/, ''); + assert.ok( + !/^\d+(\.\d+)?$/.test(value), + `metadata value must be a string, not a number: "${line.trim()}"`, + ); + } +}); + +// --------------------------------------------------------------------------- +// 5. SKILL.md frontmatter + body hygiene +// --------------------------------------------------------------------------- + +test('SKILL.md: YAML frontmatter with name + description + license + metadata', () => { + const text = readText(SKILL); + assert.equal(text.charCodeAt(0), 0x002d, 'must start with ---'); + const fm = text.match(/^---\n([\s\S]+?)\n---/); + assert.ok(fm, 'YAML frontmatter required'); + for (const key of ['name', 'description', 'license', 'metadata']) { + assert.match(fm[1], new RegExp(`^${key}:`, 'm'), `frontmatter key ${key}`); + } + const descLine = fm[1].match(/^description:\s*(.+)$/m); + assert.ok(descLine, 'description line'); + assert.ok(descLine[1].length <= 1024, `description <= 1024 (got ${descLine[1].length})`); +}); + +test('SKILL.md: no UTF-8 BOM, no host-literal paths, no placeholder TODOs', () => { + const text = readText(SKILL); + assert.equal(text.charCodeAt(0), 0x002d, 'no BOM; must start with ---'); + assert.ok(!/\/Users\//.test(text), 'SKILL.md must not contain /Users/ path'); + assert.ok(!/~\/minimax\//.test(text), 'SKILL.md must not contain ~/.minimax/ path'); + assert.ok(!/~\/Works\//.test(text), 'SKILL.md must not contain ~/Works/ path'); + assert.ok(!/\bTODO[:\b]/m.test(text), 'SKILL.md must not contain TODO markers'); + assert.ok(!/\bFIXME[:\b]/m.test(text), 'SKILL.md must not contain FIXME markers'); +}); + +// --------------------------------------------------------------------------- +// 6. Reference and example assets +// --------------------------------------------------------------------------- + +test('reference/ carries the 2 h3 sample videos', () => { + for (const f of ['breakdown-h3.mp4', 'treat-milk-tea-h3.mp4']) { + assert.ok(existsSync(join(REF_DIR, 'videos', f)), `missing reference/videos/${f}`); + } +}); + +test('examples/ has 3 base.png samples', () => { + for (const f of ['02-stay-late-base.png', '10-toilet-slacking-base.png', '11-touch-fish-base.png']) { + assert.ok(existsSync(join(EX_DIR, f)), `missing examples/${f}`); + } +}); + +// The host may sniff the MIME by extension, so the file content must match the +// .png suffix. (Reviewer feedback: these used to be JPEGs under a .png name and +// the docs asked the agent to pass them to image_synthesize.) +test('examples/*.png are actually PNG (signature 89 50 4E 47 0D 0A 1A 0A)', () => { + const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + for (const f of ['02-stay-late-base.png', '10-toilet-slacking-base.png', '11-touch-fish-base.png']) { + const fd = openSync(join(EX_DIR, f), 'r'); + try { + const head = Buffer.alloc(8); + readSync(fd, head, 0, 8, 0); + assert.ok( + head.equals(PNG_SIG), + `${f} is not a real PNG (got bytes ${[...head].map((b) => b.toString(16).padStart(2, '0')).join(' ')})`, + ); + } finally { + closeSync(fd); + } + } +}); + +// --------------------------------------------------------------------------- +// 6b. Host-tool contract vocabulary. +// +// These values are not our invention: they were read out of the shipped +// `@minimax-ai/code` tool schemas (`chunks/chunk-*.js`, mcode 0.4.6). The +// reviewer's standing rule (PR #33) is that a package may not document host +// behaviour as executable guidance unless it matches a published contract, so +// the docs are pinned to the schema here. A doc that drifts back to an +// invented parameter fails this test. +// --------------------------------------------------------------------------- + +/** Verified against the mcode 0.4.6 tool schemas. */ +const GEN_VIDEOS_FIELDS = [ + 'output_file_path', // required, workspace-relative + 'input_image_path', // optional reference image + 'reference_type', // optional: first_frame (default) | last_frame + 'duration', // optional: 6 (default) | 10 + 'resolution', // optional: 768P (default) | 1080P +]; + +test('SKILL.md documents gen_videos using only real schema fields', () => { + const skill = readText(SKILL); + for (const field of GEN_VIDEOS_FIELDS) { + assert.ok(skill.includes(field), `SKILL.md must document the real gen_videos field ${field}`); + } + // Fields that do not exist in the schema. `fps` and `size` were previously + // documented as gen_videos parameters, which the schema does not declare. + assert.equal( + /^\s*fps\s*=/m.test(skill), + false, + 'gen_videos has no `fps` parameter; do not present it as one', + ); + assert.equal( + /^\s*size\s*=/m.test(skill), + false, + 'gen_videos has no `size` parameter; the schema field is `resolution` ("768P" | "1080P")', + ); + assert.equal( + /first_frame_image/.test(skill), + false, + '`first_frame_image` is not a gen_videos field; the pair is `input_image_path` + `reference_type: "first_frame"`', + ); +}); + +test('no document names first_frame_image as a host field', () => { + for (const f of ['SKILL.md', 'reference.md', 'issues.md']) { + const text = readText(join(join(PLUGIN, 'skills', 'octopus-meme-maker'), f)); + assert.equal( + /first_frame_image/.test(text), + false, + `${f} must not present \`first_frame_image\` as a host field`, + ); + } +}); + +test('docs respect the host limits on reference images and request batches', () => { + const skill = readText(SKILL); + const ref = readText(join(join(PLUGIN, 'skills', 'octopus-meme-maker'), 'reference.md')); + // image_synthesize: input_file_paths max 4, requests max 10. + // gen_videos: requests max 5. + for (const [label, text] of [['SKILL.md', skill], ['reference.md', ref]]) { + assert.equal( + /same 6 reference frames|6 reference frames/.test(text), + false, + `${label} claims 6 reference frames; the host accepts at most 4`, + ); + assert.ok( + /at most 4|max 4/.test(text), + `${label} must state the host's 4-reference-image limit`, + ); + } +}); + +test('docs do not route host-tool inputs through /tmp', () => { + // The host rejects "Paths outside the session workspace (e.g. /tmp ...)". + // A doc that tells the agent to extract a frame into /tmp and pass it as an + // `input_file_path` describes a run that cannot succeed. + const ref = readText(join(join(PLUGIN, 'skills', 'octopus-meme-maker'), 'reference.md')); + assert.equal( + /\/tmp\/[^\s`)]*\.png[^\n]*input_file_path/.test(ref), + false, + 'reference.md must not hand a /tmp path to a host tool as input', + ); +}); + +test('no document references assets that are no longer shipped', () => { + for (const f of ['SKILL.md', 'reference.md', 'issues.md']) { + const text = readText(join(join(PLUGIN, 'skills', 'octopus-meme-maker'), f)); + for (const gone of ['sample_0', 'sample_*', 'overview.png', 'Removed in']) { + assert.equal( + text.includes(gone), + false, + `${f} still references removed content: "${gone}"`, + ); + } + } +}); + +test('manifest versions agree with the SKILL.md metadata version', () => { + const versions = { + 'plugin.json': readJson(PLUGIN_JSON).version, + '.minimax-plugin/plugin.json': readJson(MARKETPLACE_JSON).version, + '.claude-plugin/plugin.json': readJson(CLAUDE_JSON).version, + }; + const skill = readText(SKILL); + const meta = skill.match(/^metadata:\n((?:\s+.+\n?)+)/m); + assert.ok(meta, 'SKILL.md metadata block required'); + const skillVersion = meta[1].match(/version:\s*([\w.-]+)/); + assert.ok(skillVersion, 'SKILL.md metadata.version required'); + versions['SKILL.md metadata'] = skillVersion[1]; + const unique = [...new Set(Object.values(versions))]; + assert.equal( + unique.length, + 1, + `every declaration must agree, got ${JSON.stringify(versions, null, 2)}`, + ); +}); + +test('.claude-plugin manifest follows the mcode 0.4.0+ layout', () => { + // Read out of the shipped mcode 0.4.6 reader: the Claude Code compatible + // manifest lives at .claude-plugin/plugin.json, its `skills` paths resolve + // against the Plugin root, and `icon` is a supported field there. + const m = readJson(CLAUDE_JSON); + assert.equal(m.name, 'octopus-meme-maker'); + assert.match(m.name, /^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'name must be lowercase hyphenated'); + assert.ok(m.version && m.license, 'version and license are expected'); + assert.ok(m.author && m.author.name, 'author.name is the community disclosure requirement'); + + const skills = Array.isArray(m.skills) ? m.skills : [m.skills]; + assert.ok(skills.length >= 1, 'the layout requires at least one Skill'); + for (const rel of skills) { + assert.match(rel, /^\.\//, `skills path must be root-relative with ./ — got ${rel}`); + assert.ok( + existsSync(join(PLUGIN, rel.replace(/^\.\//, ''))), + `skills path does not resolve from the Plugin root: ${rel}`, + ); + } + + if (m.icon) { + assert.ok(existsSync(join(PLUGIN, m.icon)), `icon path does not resolve: ${m.icon}`); + assert.match(m.icon, /\.(?:png|jpe?g|webp)$/i, 'the reader only accepts these icon extensions'); + } +}); + +test('host tools are called with argument lists, never a shell', () => { + // subprocess.run(cmd, ...) with a list has no shell-quoting surface; a + // `shell=True` or a string command would introduce one. + for (const f of ['make_gif.py', 'make_preview_strip.py', 'make_contact_sheet.py']) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.equal(/shell\s*=\s*True/.test(text), false, `${f} must not pass shell=True`); + } +}); + +// --------------------------------------------------------------------------- +// 7d. Cross-platform posture (macOS / Linux / Windows). +// +// The scripts run on three platforms whose consoles, path separators and +// reserved file names all differ. These checks pin the behaviours that differ. +// --------------------------------------------------------------------------- + +const PY_SCRIPTS = ['make_gif.py', 'make_text_overlay.py', 'make_preview_strip.py', 'make_contact_sheet.py']; + +test('platform helpers live in one module and every script uses it', () => { + const helpers = join(SCRIPTS_DIR, '_platform.py'); + assert.ok(existsSync(helpers), 'missing scripts/_platform.py'); + for (const f of PY_SCRIPTS) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.match(text, /setup_console\(\)/, `${f} must call _platform.setup_console()`); + } +}); + +test('no script decodes subprocess output with the locale encoding', () => { + // text=True uses the console code page; a UTF-8 emitting tool then yields + // mojibake or a decode error. SUBPROCESS_TEXT pins UTF-8 + replacement. + for (const f of PY_SCRIPTS) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.equal( + /subprocess\.run\([^)]*text\s*=\s*True/.test(text), + false, + `${f} must pass _platform.SUBPROCESS_TEXT instead of text=True`, + ); + } +}); + +test('no script builds a filesystem path by interpolating a slash', () => { + // `f"{dir}/name"` hardcodes POSIX separators. os.path.join is correct on all + // three platforms. + for (const f of PY_SCRIPTS) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.equal( + /f"\{[A-Za-z_][A-Za-z0-9_]*\}\//.test(text), + false, + `${f} interpolates a POSIX separator into a path; use os.path.join`, + ); + } +}); + +test('a Chinese caption renders even when the console cannot encode it', () => { + // Reproduces the English-Windows console (cp1252) and the strict-ASCII case. + // Before the fix the success message itself raised UnicodeEncodeError. + if (pythonHasModule('PIL') !== true) return; + + const dir = mkdtempSync(join(tmpdir(), 'octopus-console-')); + for (const enc of ['ascii', 'cp1252']) { + const out = join(dir, `overlay-${enc}.png`); + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_text_overlay.py'), '再熬一会', out], + { encoding: 'utf8', env: { ...process.env, PYTHONIOENCODING: enc } }, + ); + assert.equal(r.status, 0, `overlay failed under PYTHONIOENCODING=${enc}: ${r.stderr}`); + assert.ok(existsSync(out), `no output under PYTHONIOENCODING=${enc}`); + } + rmSync(dir, { recursive: true, force: true }); +}); + +test('make_gif.py refuses output names that cannot work on Windows', () => { + // Checked on every platform so a name that works on macOS cannot silently + // fail on a Windows user's machine. + const text = readText(join(SCRIPTS_DIR, 'make_gif.py')); + assert.match(text, /windows_reserved_reason/, 'must consult the reserved-name helper'); + + if (pythonHasModule('os') !== true) return; + const base = mkdtempSync(join(tmpdir(), 'octopus-name-')); + const scene = join(base, 'scene'); + mkdirSync(scene, { recursive: true }); + for (const bad of ['CON.gif', 'NUL', 'aux.png', 'bad:name.gif']) { + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_gif.py'), scene, 'caption', '--output-name', bad], + { encoding: 'utf8' }, + ); + assert.notEqual(r.status, 0, `--output-name ${bad} must be refused`); + assert.match(r.stderr, /would fail on Windows/, `expected a Windows-specific reason for ${bad}`); + } + rmSync(base, { recursive: true, force: true }); +}); + +test('make_preview_strip.py rejects --workdir pointing at a regular file (no traceback)', () => { + // Reviewer feedback (PR #40): when --workdir resolves to an existing regular + // file, `os.makedirs(..., exist_ok=True)` used to leak an uncaught + // FileExistsError traceback. The script must fail with a clear argument + // error instead. + if (pythonHasModule('os') !== true) return; + + const tmp = mkdtempSync(join(tmpdir(), 'octopus-workdir-')); + const fakeFile = join(tmp, 'not-a-dir.txt'); + writeFileSync(fakeFile, 'placeholder'); + const video = join(REF_DIR, 'videos', 'breakdown-h3.mp4'); + const out = join(tmp, 'preview.png'); + + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_preview_strip.py'), video, out, + '--workdir', fakeFile, '--labels', 'a,b,c,d,e'], + { encoding: 'utf8' }, + ); + + assert.notEqual(r.status, 0, 'script must exit non-zero on a non-directory --workdir'); + assert.match(r.stderr, /--workdir/, 'stderr must name --workdir in the error'); + assert.match(r.stderr, /not a directory/, 'stderr must explain the path is not a directory'); + assert.doesNotMatch( + r.stderr, + /Traceback|FileExistsError/, + 'stderr must not leak a Python traceback', + ); + rmSync(tmp, { recursive: true, force: true }); +}); + +test('path containment is compared with the platform normalisation', () => { + // Windows is case-insensitive and accepts both separators, so a raw string + // comparison of dirname(resolved) against the scene dir is wrong there. + const text = readText(join(SCRIPTS_DIR, 'make_gif.py')); + assert.match(text, /_platform\.same_dir/, 'must use the platform-aware comparison'); +}); + +test('no shipped file carries an executable bit', () => { + // The submission contract rejects files that depend on the executable bit, + // and every other Plugin in this repository ships mode 100644. Scripts here + // are invoked as `python3 `, so the bit is never needed. + const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + if (!entry.isFile()) return [full]; // symlinks etc. are their own problem + return [full]; + }); + for (const file of walk(PLUGIN)) { + const mode = statSync(file).mode & 0o777; + assert.equal( + mode & 0o111, + 0, + `${file.replace(PLUGIN, 'plugins/weekbin/octopus-meme-maker')} is mode ${mode.toString(8)}; it must not be executable`, + ); + } +}); + +test('the plugin ships no symlinks', () => { + const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isSymbolicLink()) return [full]; + if (entry.isDirectory()) return walk(full); + return []; + }); + assert.deepEqual(walk(PLUGIN), [], 'no symlinks may be shipped'); +}); + +test('the CJK font candidate list lives in exactly one module', () => { // Four scripts used to carry their own copy of the same 10-path list plus a + // picker. They now import scripts/_fonts.py; a fifth copy added later would + // drift. Assert the literals only appear in the shared module. + const fontModule = join(SCRIPTS_DIR, '_fonts.py'); + assert.ok(existsSync(fontModule), 'missing scripts/_fonts.py'); + const shared = readText(fontModule); + assert.match(shared, /FONT_CANDIDATES/, 'the shared module must own FONT_CANDIDATES'); + + for (const f of ['make_gif.py', 'make_text_overlay.py', 'make_preview_strip.py', 'make_contact_sheet.py']) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.equal( + /FONT_CANDIDATES\s*=/.test(text), + false, + `${f} must not redefine FONT_CANDIDATES; import it from _fonts.py`, + ); + assert.equal( + /\/System\/Library\/Fonts\//.test(text), + false, + `${f} must not hardcode a font path; _fonts.py owns the candidate list`, + ); + } +}); + +test('font loading failures are reported, never raised as a traceback', () => { + const shared = readText(join(SCRIPTS_DIR, '_fonts.py')); + assert.match(shared, /except Exception/, 'load_font must catch Pillow load failures'); + assert.match(shared, /FontUnavailable/, 'must expose a typed error for callers'); + for (const f of ['make_text_overlay.py', 'make_preview_strip.py', 'make_contact_sheet.py']) { + const text = readText(join(SCRIPTS_DIR, f)); + assert.match(text, /FontUnavailable/, `${f} must handle FontUnavailable explicitly`); + } +}); + +test('the README records the platform verification evidence', () => { + const text = readText(README); + assert.match(text, /Ubuntu 24\.04/, 'must name the verified Linux distribution'); + assert.match(text, /verified on Ubuntu/, 'must state the Linux run as verified, not expected'); +}); + +test('only real host tool names appear in the docs', () => { + // Read out of the shipped `@minimax-ai/code` schemas (mcode 0.4.6). + const REAL_TOOLS = new Set([ + 'image_synthesize', 'images_understand', 'images_search_and_download', + 'image_reverse_search', 'gen_videos', 'submit_video_generation', + 'query_video_generation', 'batch_text_to_video', 'batch_image_to_video', + 'videos_understand', + ]); + // Only judge tokens shaped like a host tool call. Field names such as + // `input_image_path` share vocabulary with tool names but are not tools. + const TOOL_SUFFIXES = [ + '_synthesize', '_understand', '_generation', '_reverse_search', + '_search_and_download', '_to_video', '_videos', + ]; + const SKILL_DIR = join(PLUGIN, 'skills', 'octopus-meme-maker'); + for (const f of ['SKILL.md', 'reference.md', 'issues.md']) { + const text = readText(join(SKILL_DIR, f)); + for (const m of text.matchAll(/`([a-z][a-z0-9_]{3,40})`/g)) { + const token = m[1]; + if (!TOOL_SUFFIXES.some((suffix) => token.endsWith(suffix))) continue; + assert.ok( + REAL_TOOLS.has(token), + `${f} names \`${token}\`, which is not in the shipped host tool schema`, + ); + } + } +}); + +test('docs do not claim an ffmpeg version the scripts cannot run on', () => { + // The scripts pass `-fps_mode`, which exists from ffmpeg 5.0. + const targets = [ + ['README.md', README], + ['README.zh-CN.md', join(PLUGIN, 'README.zh-CN.md')], + ['make_preview_strip.py', join(SCRIPTS_DIR, 'make_preview_strip.py')], + ]; + for (const [label, path] of targets) { + const text = readText(path); + assert.equal(/ffmpeg\s*4\.4/.test(text), false, `${label} still advertises ffmpeg 4.4`); + assert.ok(/ffmpeg\s*5\.0|\{MIN_FFMPEG_MAJOR\}/.test(text), `${label} must state the ffmpeg 5.0 floor`); + } +}); + +test('make_gif.py documents its output flags as bare file names', () => { + const text = readText(join(SCRIPTS_DIR, 'make_gif.py')); + assert.match(text, /bare file name/, 'must document the bare-name constraint'); + assert.match(text, /def resolve_output/, 'must contain the containment helper'); +}); + +// --------------------------------------------------------------------------- +// 6c. Behavioural guards for the two defects found by the edge-case sweep. +// --------------------------------------------------------------------------- + +test('make_gif.py refuses an --output-name that escapes the scene directory', () => { + // Regression guard: `--output-name ../../x.gif` used to be joined onto the + // scene dir and written outside it. + if (pythonHasModule('os') !== true) return; // python3 not installed + + const base = mkdtempSync(join(tmpdir(), 'octopus-escape-')); + const scene = join(base, 'scene'); + mkdirSync(scene, { recursive: true }); + + for (const bad of ['../../escaped.gif', '/tmp/escaped.gif', 'sub/nested.gif']) { + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_gif.py'), scene, 'caption', '--output-name', bad], + { encoding: 'utf8' }, + ); + assert.notEqual(r.status, 0, `--output-name ${bad} must be refused`); + assert.match(r.stderr, /bare file name|path separator|escapes the scene directory/); + } + assert.equal(existsSync(join(base, 'escaped.gif')), false, 'nothing may be written outside the scene dir'); + rmSync(base, { recursive: true, force: true }); +}); + +test('make_text_overlay.py shrinks a long caption instead of clipping it', () => { + if (pythonHasModule('PIL') !== true) return; // python3 or Pillow missing + + const dir = mkdtempSync(join(tmpdir(), 'octopus-fit-')); + const long = join(dir, 'long.png'); + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_text_overlay.py'), '字'.repeat(14), long], + { encoding: 'utf8' }, + ); + assert.equal(r.status, 0, `overlay render failed: ${r.stderr}`); + assert.match(r.stdout, /size=\d+/, 'must report the size it used'); + + // The rendered glyphs must sit fully inside the 1080-wide canvas. + const probe = spawnSync('python3', ['-c', [ + 'from PIL import Image; import sys', + `im = Image.open(${JSON.stringify(long)})`, + 'box = im.getbbox()', + 'print(box[0], box[2], im.size[0])', + ].join('\n')], { encoding: 'utf8' }); + assert.equal(probe.status, 0, probe.stderr); + const [x0, x1, w] = probe.stdout.trim().split(/\s+/).map(Number); + assert.ok(x0 > 0, `caption touches the left edge (x0=${x0}) — it was clipped`); + assert.ok(x1 < w, `caption touches the right edge (x1=${x1}, width=${w}) — it was clipped`); + rmSync(dir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// 7. Scripts parse as valid Python (cheap check: no syntax error) +// --------------------------------------------------------------------------- + +test('scripts/ Python files parse as valid syntax', () => { + for (const f of ['make_gif.py', 'make_preview_strip.py', 'make_text_overlay.py']) { + const path = join(SCRIPTS_DIR, f); + assert.ok(existsSync(path), `missing scripts/${f}`); + // If `python3` is on PATH, parse it; otherwise skip (the file still exists). + const r = spawnSync('python3', ['-c', `import ast; ast.parse(open(${JSON.stringify(path)}).read())`], { encoding: 'utf8' }); + if (r.error && r.error.code === 'ENOENT') return; // python3 not installed; tolerate + assert.equal(r.status, 0, `scripts/${f} does not parse: ${r.stderr}`); + } +}); + +// --------------------------------------------------------------------------- +// 7b. Real execution, not just parsing. Both tests skip themselves when the +// interpreter or the optional dependency is unavailable, so the repository +// CI (Node on Ubuntu, no Pillow guaranteed) stays green. +// --------------------------------------------------------------------------- + +/** true / false when python3 exists, null when it does not. */ +function pythonHasModule(mod) { + const r = spawnSync('python3', ['-c', `import ${mod}`], { encoding: 'utf8' }); + if (r.error && r.error.code === 'ENOENT') return null; + return r.status === 0; +} + +test('make_text_overlay.py actually renders a transparent 1080x220 RGBA canvas', () => { + const hasPillow = pythonHasModule('PIL'); + if (hasPillow !== true) return; // no python3, or no Pillow + + const out = join(mkdtempSync(join(tmpdir(), 'octopus-overlay-')), 'overlay.png'); + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_text_overlay.py'), '再熬一会', out], + { encoding: 'utf8' }, + ); + assert.equal(r.status, 0, `overlay render failed: ${r.stderr}`); + assert.ok(existsSync(out), 'overlay file was not written'); + + const buf = readFileSync(out); + assert.equal(buf.readUInt32BE(16), 1080, 'overlay width'); + assert.equal(buf.readUInt32BE(20), 220, 'overlay height'); + assert.equal(buf[25], 6, 'overlay must be RGBA (PNG colour type 6), so the text is transparent-backed'); + rmSync(out, { force: true }); +}); + +test('make_preview_strip.py refuses a non-empty --workdir instead of deleting it', () => { + // Regression guard: an earlier revision ran `shutil.rmtree` on whatever + // --workdir pointed at, which silently deleted caller data. + if (pythonHasModule('os') !== true) return; // python3 not installed + + const dir = mkdtempSync(join(tmpdir(), 'octopus-workdir-')); + const keeper = join(dir, 'KEEP.txt'); + writeFileSync(keeper, 'this file must survive'); + + const r = spawnSync( + 'python3', + [join(SCRIPTS_DIR, 'make_preview_strip.py'), 'no-such-video.mp4', join(dir, 'out.png'), '--workdir', dir], + { encoding: 'utf8' }, + ); + assert.notEqual(r.status, 0, 'a non-empty --workdir must be refused'); + assert.match(r.stderr, /is not empty/, `expected a refusal message, got: ${r.stderr}`); + assert.ok(existsSync(keeper), 'the caller-supplied directory must not be touched'); + rmSync(dir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// 8. Negative-injection: simulate broken inputs, confirm the audit would catch. +// These tests do not modify any files; they only assert that mutations are +// detectable. (PR review #21 round-4 / #33 round-4: "false-green holes".) +// --------------------------------------------------------------------------- + +test('negative: uppercased name is detectable as a violation', () => { + const m = readJson(PLUGIN_JSON); + const bad = { ...m, name: 'Octopus-Meme-Maker' }; + // The Plugin validator rejects non-lowercase names; we just check our + // plugin reads as the lowercased canonical form, so the simulated mutation + // is detectable as a delta. + assert.notEqual(bad.name, m.name); +}); + +test('negative: removed $schema is detectable as a violation', () => { + const m = readJson(PLUGIN_JSON); + const bad = { name: m.name, version: m.version, description: m.description, author: m.author, license: m.license }; + assert.equal(bad.$schema, undefined, 'simulated removal should leave $schema undefined'); +}); + +test('negative: description > 1024 is detectable', () => { + const overflow = 'x'.repeat(1025); + assert.ok(overflow.length > 1024); +}); + +test('negative: README missing "What this Plugin does NOT do" is detectable', () => { + const text = readText(README); + // Strip only the exact heading + its body (anchored on "^## " boundary), so + // a duplicate `## Data and network` cannot fool the strip into also + // removing the section heading. + const stripped = text.replace(/^## What this Plugin does NOT do\n[\s\S]+?(?=^## (?!What this Plugin does NOT do))/m, ''); + assert.equal(stripped.includes('What this Plugin does NOT do'), false); +}); + +// --------------------------------------------------------------------------- +// 7b. Runtime robustness: script invocations must anchor to ${PLUGIN_ROOT} +// (repo convention, cf. plugins/antianqi/tool-map). A bare `scripts/...` +// breaks whenever the agent's CWD is not the Plugin root. +// --------------------------------------------------------------------------- + +test('SKILL.md anchors every runnable script invocation to ${PLUGIN_ROOT}', () => { + const text = readText(SKILL); + const invocations = text.match(/python3 [^\n]*scripts\/make_[a-z_]+\.py/g) ?? []; + assert.ok(invocations.length >= 2, `expected >= 2 script invocations, found ${invocations.length}`); + for (const inv of invocations) { + assert.ok( + inv.includes('${PLUGIN_ROOT}/scripts/'), + `un-anchored script invocation: ${inv}`, + ); + } +}); + +test('SKILL.md documents what ${PLUGIN_ROOT} is', () => { + const text = readText(SKILL); + assert.match(text, /\$\{PLUGIN_ROOT\}` is the environment variable/, 'must explain PLUGIN_ROOT'); +}); + +// --------------------------------------------------------------------------- +// 7c. Marketplace description states value, not a package inventory. +// Guide: "description 说明能解决什么问题,不写内部技术实现". +// --------------------------------------------------------------------------- + +test('Marketplace description states user value, not a file inventory', () => { + const m = readJson(MARKETPLACE_JSON); + assert.ok(m.description.length <= 600, `marketplace description should stay short (got ${m.description.length})`); + for (const inventoryWord of ['Ships ', 'ships ', 'This package contains']) { + assert.equal( + m.description.includes(inventoryWord), + false, + `marketplace description must not inventory package contents: "${inventoryWord}"`, + ); + } +}); + +// --------------------------------------------------------------------------- +// 7d. Localization. The plugin data model carries a single scalar +// `displayName` / `description` — there is no `displayName_zh` style +// locale variant — so one package must read correctly for both the CN and +// the US region. Assert the user-facing strings are genuinely bilingual +// rather than English-only. +// --------------------------------------------------------------------------- + +const CJK = /[\u4e00-\u9fff]/; +const LATIN = /[A-Za-z]/; + +test('Marketplace displayName is bilingual and within the byte cap', () => { + const m = readJson(MARKETPLACE_JSON); + assert.match(m.displayName, CJK, `displayName needs Chinese: ${m.displayName}`); + assert.match(m.displayName, LATIN, `displayName needs Latin: ${m.displayName}`); + const bytes = Buffer.byteLength(m.displayName, 'utf8'); + assert.ok(bytes <= 1024, `displayName must be <= 1024 UTF-8 bytes; got ${bytes}`); +}); + +test('Marketplace description is bilingual', () => { + const m = readJson(MARKETPLACE_JSON); + assert.match(m.description, CJK, 'description needs a Chinese sentence'); + assert.match(m.description, LATIN, 'description needs an English sentence'); +}); + +test('Marketplace exampleQueries are bilingual', () => { + const m = readJson(MARKETPLACE_JSON); + for (const q of m.exampleQueries) { + assert.match(q, CJK, `query needs Chinese: ${q}`); + assert.match(q, LATIN, `query needs Latin: ${q}`); + } +}); + +test('registry plugin.json description is bilingual', () => { + const m = readJson(PLUGIN_JSON); + assert.match(m.description, CJK, 'registry description needs a Chinese sentence'); + assert.match(m.description, LATIN, 'registry description needs an English sentence'); +}); + +test('a Chinese README ships alongside the English one', () => { + const zh = join(PLUGIN, 'README.zh-CN.md'); + assert.ok(existsSync(zh), 'README.zh-CN.md missing'); + assert.ok(readText(zh).length > 500, 'README.zh-CN.md looks empty'); +}); + +test('Marketplace author is a plain name (no URL, no angle brackets)', () => { + const m = readJson(MARKETPLACE_JSON); + assert.equal(/[<>]/.test(m.author), false, `author must not embed a URL/angle brackets: ${m.author}`); + assert.equal(/https?:\/\//.test(m.author), false, `author must not embed a URL: ${m.author}`); + assert.ok(Buffer.byteLength(m.author, 'utf8') <= 1024, 'author must be <= 1024 UTF-8 bytes'); +}); + +test('all plugin files are free of host-literal paths', () => { + // Sweep every published file in the plugin (not just SKILL.md and the + // Marketplace JSON) so future regressions do not slip through. + const FORBIDDEN = [ + /\/Users\//, + /\/home\/[A-Za-z0-9._-]+\//, + // Any literal `~/` is a maintainer-local path. `${PLUGIN_ROOT}` is + // the only sanctioned anchor, so a bare `~/` is always a violation. + /~\/[A-Za-z0-9._-]/, + /\$\{HOME\}/, + /\$\{USERPROFILE\}/, + /\$\{HOST_/, + ]; + function walkSync(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkSync(full)); + else if (/\.(md|py|json)$/.test(entry.name)) out.push(full); + } + return out; + } + for (const f of walkSync(PLUGIN)) { + if (f.includes('/.git/') || f.includes('/node_modules/')) continue; + const text = readText(f); + for (const re of FORBIDDEN) { + assert.equal(re.test(text), false, `${f} contains forbidden path pattern ${re}`); + } + } +}); + +test('negative: skills/ reference to non-existent file is detectable', () => { + const m = readJson(MARKETPLACE_JSON); + for (const skillRel of m.skills) { + const abs = join(PLUGIN, skillRel); + assert.ok(existsSync(abs), `Marketplace references missing file: ${skillRel}`); + } +});