diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f7097d50..66ca663878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Pixel Avatars can be an elf, dwarf, orc or talking flower with beards, medieval - Drawn parts can be shared. An exported Bot carries the parts it wears, and importing it adds them to your Part Library marked **From an imported Bot**. A single part exports as a PNG that previews anywhere and imports back losslessly. The whole library exports as a zip of those PNGs. Imports merge identical parts, recompute each part's identity from its content, and refuse malformed or oversized files with a message. The library can be filtered by origin ([#1215](https://github.com/BotHarness/DeepSeekBot/issues/1215)). - The part editor gains line and rectangle (hold Shift for straight angles and squares), a tone gradient with 4×4 or 2×2 dithering, noise with an amount and **Re-roll**, an eyedropper (or Alt-click / long-press), and a **Shade** pencil that makes cells lighter or darker once per stroke. Every tool mirrors and is one undo step. Results stay appearance colors at a tone, so the part still recolors. On touch, a two-finger tap undoes, a three-finger tap redoes, and an **Offset cursor** with a **Hold to draw** button keeps your finger off the pixel ([#1216](https://github.com/BotHarness/DeepSeekBot/issues/1216)). - Every hair piece can have its own color: bangs, back hair and a new single strand (ahoge, curl or double) join the side hair colors, and each follows the hair color until you pick one. **Headpiece** now offers built-in cat, bunny and horse ears, little horns, a halo and new little wings, worn together with any accessory; the base tucks behind the hair and the far side hides when the head turns. Saved Avatars that wear these as accessories look the same until you edit them, when they move into the headpiece slot ([#1217](https://github.com/BotHarness/DeepSeekBot/issues/1217)). +- Pixel art from other tools can become a part: **Import part files** also takes a plain PNG of a part's size (32×32, 32×16, or a whole-number scale). You see its size and color count first, choose how many colors to keep (at most 32, nearest color, no dithering), and its pixels arrive as fixed colors that **Replace color** turns into appearance colors and tones. A built-in headpiece opens as a flattened starting canvas, and a part saved from someone else's part records its parent and keeps the original author's name. The library filter adds **Based on another part** and **From an imported image** ([#1219](https://github.com/BotHarness/DeepSeekBot/issues/1219)). - Hair pieces can be drawn too: open bangs, left or right side hair or back hair and start from the style you wear, flattened to pixels. Reshape it with the same tools, save it to the Part Library and wear it in that slot. The drawn hair is shaded and recolored like built-in hair, turns with the head and hides under helmets, hoods and on flowers. **Back to the built-in style** restores your original choice ([#1238](https://github.com/BotHarness/DeepSeekBot/issues/1238)). - You can draw your own headpiece for a pixel Avatar: pencil, eraser, fill, mirroring across the face, a front layer over the hair and a back layer behind it, undo and redo, and a 1× preview on the real Avatar. Colors can follow the Avatar's hair, skin, eye or outfit color in five tones, or be fixed. Saved parts go into a Part Library in your Profile and can be worn by any PersonaBot. The worn copy turns and speaks with the Window Companion and travels with an exported Bot, and editing a library part saves a new part without changing Avatars that already wear the old one ([#1211](https://github.com/BotHarness/DeepSeekBot/issues/1211)). - Pixel Avatars can be an elf, dwarf, orc or talking flower. Beards always leave the speaking mouth visible. Armor, robe, tunic and cloak outfits are available, and a helmet or hood hides the hair underneath. Flowers have bead eyes, five petal styles and a stem or pot. Choices a species or headwear doesn't show stay saved, with a note, and return when switched back ([#1212](https://github.com/BotHarness/DeepSeekBot/issues/1212), [#1213](https://github.com/BotHarness/DeepSeekBot/issues/1213)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 24e38d8e7f..a66f1eb0f4 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -17,6 +17,7 @@ - 自绘部件可以分享了:导出的 Bot 会带上它戴着的部件,导入后这些部件会进入你的部件库,标记为“来自导入的 Bot”;单个部件可导出为任何地方都能预览的 PNG,并能无损导回;整个部件库可导出为这些 PNG 的 zip。导入时相同内容的部件会合并,并按内容重新计算部件身份,格式错误或过大的文件会被拒绝并给出提示;部件库可按来源筛选([#1215](https://github.com/BotHarness/DeepSeekBot/issues/1215))。 - 部件编辑器新增直线和矩形(按住 Shift 锁定直角和正方形)、带 4×4 或 2×2 抖动的明暗渐变、可调强度并可 **重新随机** 的杂色、吸管(也可按住 Alt 点击或长按),以及每笔只变亮或变暗一次的 **明暗笔**。所有工具都支持镜像,每次操作是一步撤销;结果仍是外形颜色加明暗,部件照样跟着变色。触屏上双指轻点撤销、三指轻点重做,**偏移光标** 配合 **按住绘制** 按钮,手指不会挡住要画的像素([#1216](https://github.com/BotHarness/DeepSeekBot/issues/1216))。 - 每片头发都能单独设颜色:前发、后发和新的单根呆毛(呆毛、卷翘或双呆毛)加入左右侧发颜色的行列,没选颜色时跟随发色。**头饰** 现在提供内置的猫耳、兔耳、马耳、小角、光环和新的小翅膀,可以和任意配饰一起戴;根部藏在头发后面,转头时远侧会隐藏。已保存的头像如果把这些当作配饰戴着,在你编辑前外观不变,编辑时会移到头饰位([#1217](https://github.com/BotHarness/DeepSeekBot/issues/1217))。 +- 其他工具画的像素画也能变成部件:**导入部件文件** 现在也接受部件尺寸的普通 PNG(32×32、32×16 或其整数倍放大)。会先显示尺寸和颜色数,再选择保留几种颜色(最多 32 种,取最接近的颜色,不抖动);导入的像素是固定颜色,可以用 **替换颜色** 换成外形颜色和明暗。内置头饰可以作为压平的起点打开;基于别人部件保存的新部件会记录父部件并保留原作者名字。部件库筛选新增 **基于其他部件修改** 和 **来自导入的图片**([#1219](https://github.com/BotHarness/DeepSeekBot/issues/1219))。 - 头发也能自己画了:在前发、左右侧发或后发里,从当前戴的样式开始(压平成像素),用同样的工具改形状,存进部件库并戴在这个位置。自绘头发会像内置头发一样上阴影、跟着发色变色、随头部转动,戴头盔/兜帽或变成花朵时会隐藏。点 **换回内置样式** 可恢复原来的选择([#1238](https://github.com/BotHarness/DeepSeekBot/issues/1238))。 - 可以为像素头像自己画头饰:铅笔、橡皮、填充、沿脸部中线镜像,前层盖在头发上、后层在头发后面,支持撤销重做,并在真实头像上显示 1× 预览。颜色可以跟随头像的发色、肤色、眼睛或衣服颜色(五档明暗),也可以是固定色。保存的部件进入 Profile 里的部件库,任何 PersonaBot 都能戴上;戴上的副本会在窗口伙伴里随头部转动和说话,并随导出的 Bot 一起带走;修改库里的部件会另存为新部件,不会改变已经戴着旧部件的头像([#1211](https://github.com/BotHarness/DeepSeekBot/issues/1211))。 - 像素头像新增精灵、矮人、兽人和会说话的花。胡子始终露出说话的嘴,新增盔甲、法袍、束腰外衣和斗篷,戴上头盔或兜帽会遮住下面的头发。花有豆豆眼、五种花瓣样式,以及茎叶或花盆。当前物种或头饰不显示的选择会保留并给出提示,换回后恢复([#1212](https://github.com/BotHarness/DeepSeekBot/issues/1212)、[#1213](https://github.com/BotHarness/DeepSeekBot/issues/1213))。 diff --git a/docs/architecture/botharness-architecture.en.md b/docs/architecture/botharness-architecture.en.md index e2440b4f55..1efa7dff55 100644 --- a/docs/architecture/botharness-architecture.en.md +++ b/docs/architecture/botharness-architecture.en.md @@ -416,7 +416,7 @@ Custom Parts are bounded pixel grids whose cells reference appearance colors or New PersonaBots record a full-domain name-seed version; PersonaBots without it keep the human-only seed. Recipes using species, new slots or Custom Parts raise the asset/schema version and fall back to the saved snapshot on older Clients; existing recipes render unchanged. The first slice proves goblin, split side hair and one drawn headpiece from editor to Part Library to a Window Companion and through export/import. -Implemented so far: goblin, elf, dwarf, orc and flower species, split side hair, beards, medieval outfits and headwear, the full-domain seed (#1210, #1212–#1214), the first Custom Part path (#1211), drawn hair pieces (#1238), every other drawn part (#1240), part files (#1215), the full drawing tool set (#1216), and per-piece hair colors with built-in headpieces (#1217). Asset version 4 adds bangs, back hair and strand colors, a single strand, and a headpiece slot that holds a built-in headpiece or a Custom Part beside the accessory; the editor raises a recipe to version 4 only when the Human edits these, moving a headpiece accessory into the slot. A drawn bangs, side or back piece replaces the built-in piece while that choice stays saved; its live `hairColor` cells are shaded like built-in hair, so editing starts from the worn style flattened to cells. A drawn headpiece is an asset version 3 recipe. The Part Library is the `avatar-part-library` module's `avatar_part_library` table (schema generation 73), keyed by the part's content hash and holding name, origins and parent. The `partLibraryList`, `partLibraryAdd`, `partLibraryExport` and `partLibraryImport` bridge methods expose it to the Client editor (pencil with a lighter/darker shade modifier, eraser, 4-connected fill, line and rectangle with Shift snapping, a region-bounded tone gradient with 4×4 or 2×2 Bayer dithering, seeded ±1 tone noise with re-roll, an eyedropper by Alt-click or long-press, centerline mirror for every tool, front and back layers, one undo step per action with two- and three-finger tap undo and redo, an offset touch cursor, and a 1× preview; BotPixel computes every tool's cells, which stay appearance colors at a tone). The appearance embeds a copy of the part, so it travels in `.botharness/bot.json` and Bot Zips. `part-file` encodes one part as a ×8 preview PNG with the part data in a `botharness-part` tEXt chunk, and the library as a zip of such PNGs; import checks every chunk CRC and deduplicates by content. Importing a Bot adds the parts its appearance wears to the library with the `imported-bot` origin and the Bot's display name as author. Animal species and plain PNG import are still to come. +Implemented so far: goblin, elf, dwarf, orc and flower species, split side hair, beards, medieval outfits and headwear, the full-domain seed (#1210, #1212–#1214), the first Custom Part path (#1211), drawn hair pieces (#1238), every other drawn part (#1240), part files (#1215), the full drawing tool set (#1216), per-piece hair colors with built-in headpieces (#1217), and plain PNG import with derived editing (#1219). Asset version 4 adds bangs, back hair and strand colors, a single strand, and a headpiece slot that holds a built-in headpiece or a Custom Part beside the accessory; the editor raises a recipe to version 4 only when the Human edits these, moving a headpiece accessory into the slot. A drawn bangs, side or back piece replaces the built-in piece while that choice stays saved; its live `hairColor` cells are shaded like built-in hair, so editing starts from the worn style flattened to cells. A drawn headpiece is an asset version 3 recipe. The Part Library is the `avatar-part-library` module's `avatar_part_library` table (schema generation 73), keyed by the part's content hash and holding name, origins and parent. The `partLibraryList`, `partLibraryAdd`, `partLibraryExport` and `partLibraryImport` bridge methods expose it to the Client editor (pencil with a lighter/darker shade modifier, eraser, 4-connected fill, line and rectangle with Shift snapping, a region-bounded tone gradient with 4×4 or 2×2 Bayer dithering, seeded ±1 tone noise with re-roll, an eyedropper by Alt-click or long-press, centerline mirror for every tool, front and back layers, one undo step per action with two- and three-finger tap undo and redo, an offset touch cursor, and a 1× preview; BotPixel computes every tool's cells, which stay appearance colors at a tone). The appearance embeds a copy of the part, so it travels in `.botharness/bot.json` and Bot Zips. `part-file` encodes one part as a ×8 preview PNG with the part data in a `botharness-part` tEXt chunk, and the library as a zip of such PNGs; import checks every chunk CRC and deduplicates by content. Importing a Bot adds the parts its appearance wears to the library with the `imported-bot` origin and the Bot's display name as author. `part-image` decodes a plain PNG of a slot's size (or a whole-number scale), reports its color count through `partLibraryImport`, and `partLibraryImportImage` keeps the most frequent colors (nearest color, no dithering) as fixed-color cells with the `imported-image` origin. A part saved with a library parent gets the `derived` origin and the parent's author as `parentAuthor`. Animal species are still to come. ## 6 · Persistence, export, and restore boundaries diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index fd9affb6f2..d8cb6c91fa 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -522,7 +522,7 @@ Custom Part 是有界像素网格,每格引用外形颜色或固定色并带 新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 -目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),以及每片头发单独配色与内置头饰(#1217)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。动物物种和普通 PNG 导入尚未实现。 +目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),每片头发单独配色与内置头饰(#1217),以及普通 PNG 导入和派生编辑(#1219)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。`part-image` 解码部件尺寸(或整数倍放大)的普通 PNG,经 `partLibraryImport` 报告颜色数,`partLibraryImportImage` 保留最常见的颜色(最近色、不抖动),以 `imported-image` 来源存为固定颜色的格子;带部件库父部件保存的部件来源为 `derived`,并以 `parentAuthor` 记录父部件作者。动物物种尚未实现。 ## 6 · 持久化、导出与恢复边界 diff --git a/docs/assets/pr/1219-png-import/README.md b/docs/assets/pr/1219-png-import/README.md new file mode 100644 index 0000000000..c303d595e7 --- /dev/null +++ b/docs/assets/pr/1219-png-import/README.md @@ -0,0 +1,25 @@ +# #1219 Plain PNG import and derived editing + +Captured on an isolated DSH instance built from this branch. The source image (`source-tiara.png`, 32×16 pixel art scaled ×8 with 57 colors) stands in for art drawn in another tool. + +## Plain PNG import + +| Size and color count shown before importing | Keeping 6 colors (nearest color, no dithering) | +| ------------------------------------------- | ---------------------------------------------- | +| ![count](image-count-en-light.webp) | ![reduce](image-reduce-en-light.webp) | + +| Imported as fixed colors and worn | Replace color remaps two colors to hair color tones | The saved copy follows a hair color change | +| --------------------------------- | --------------------------------------------------- | ------------------------------------------ | +| ![worn](image-worn-en-light.webp) | ![remap](image-remap-en-light.webp) | ![recolor](image-recolor-en-light.webp) | + +## Derived parts + +| Mika's wings edited and saved: "Based on another part · based on Mika's part" | A built-in halo opened as a flattened starting canvas | +| ----------------------------------------------------------------------------- | ----------------------------------------------------- | +| ![derived](derived-en-light.webp) | ![built-in](builtin-flatten-en-light.webp) | + +## Full flow + +![full flow](png-import-flow.gif) + +The same recording as video: [png-import-flow.mp4](png-import-flow.mp4) diff --git a/docs/assets/pr/1219-png-import/builtin-flatten-en-light.webp b/docs/assets/pr/1219-png-import/builtin-flatten-en-light.webp new file mode 100644 index 0000000000..2504476a16 Binary files /dev/null and b/docs/assets/pr/1219-png-import/builtin-flatten-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/derived-en-light.webp b/docs/assets/pr/1219-png-import/derived-en-light.webp new file mode 100644 index 0000000000..e2c4a4fa58 Binary files /dev/null and b/docs/assets/pr/1219-png-import/derived-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/image-count-en-light.webp b/docs/assets/pr/1219-png-import/image-count-en-light.webp new file mode 100644 index 0000000000..30db0867b2 Binary files /dev/null and b/docs/assets/pr/1219-png-import/image-count-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/image-recolor-en-light.webp b/docs/assets/pr/1219-png-import/image-recolor-en-light.webp new file mode 100644 index 0000000000..09e10d4a4b Binary files /dev/null and b/docs/assets/pr/1219-png-import/image-recolor-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/image-reduce-en-light.webp b/docs/assets/pr/1219-png-import/image-reduce-en-light.webp new file mode 100644 index 0000000000..e5ee1affc8 Binary files /dev/null and b/docs/assets/pr/1219-png-import/image-reduce-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/image-remap-en-light.webp b/docs/assets/pr/1219-png-import/image-remap-en-light.webp new file mode 100644 index 0000000000..a453f9dd56 Binary files /dev/null and b/docs/assets/pr/1219-png-import/image-remap-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/image-worn-en-light.webp b/docs/assets/pr/1219-png-import/image-worn-en-light.webp new file mode 100644 index 0000000000..4cd80742e8 Binary files /dev/null and b/docs/assets/pr/1219-png-import/image-worn-en-light.webp differ diff --git a/docs/assets/pr/1219-png-import/png-import-flow.gif b/docs/assets/pr/1219-png-import/png-import-flow.gif new file mode 100644 index 0000000000..1c0dbc125b Binary files /dev/null and b/docs/assets/pr/1219-png-import/png-import-flow.gif differ diff --git a/docs/assets/pr/1219-png-import/png-import-flow.mp4 b/docs/assets/pr/1219-png-import/png-import-flow.mp4 new file mode 100644 index 0000000000..c74c38980e Binary files /dev/null and b/docs/assets/pr/1219-png-import/png-import-flow.mp4 differ diff --git a/docs/assets/pr/1219-png-import/source-tiara.png b/docs/assets/pr/1219-png-import/source-tiara.png new file mode 100644 index 0000000000..e8cfd28ab3 Binary files /dev/null and b/docs/assets/pr/1219-png-import/source-tiara.png differ diff --git a/packages/client/src/client/actions.ts b/packages/client/src/client/actions.ts index b24f93210c..463e603561 100644 --- a/packages/client/src/client/actions.ts +++ b/packages/client/src/client/actions.ts @@ -99,6 +99,8 @@ import { addLibraryPart as addLibraryPartViaBridge, exportLibraryParts as exportLibraryPartsViaBridge, importLibraryParts as importLibraryPartsViaBridge, + importLibraryImage as importLibraryImageViaBridge, + type PartImageInfo, cancelGroupInvitation, decideGroupJoin, removeGroupMember, @@ -602,9 +604,16 @@ export interface BridgeActions { | { added: import('../../../core/src/bots/part-library.js').PartLibraryEntry[]; refused: number; + image?: PartImageInfo; } | { error: string } >; + importLibraryImage( + data: string, + slot: string, + colors: number, + name: string, + ): Promise; loadPartLibrary(): Promise< import('../../../core/src/bots/part-library.js').PartLibraryEntry[] | undefined >; @@ -2309,6 +2318,13 @@ export function createActions( return { error: error instanceof Error ? error.message : String(error) }; } }, + async importLibraryImage(data, slot, colors, name) { + try { + return await importLibraryImageViaBridge(call, data, slot, colors, name); + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, async addLibraryPart(part, name, parent) { try { return await addLibraryPartViaBridge(call, part, name, parent); diff --git a/packages/client/src/client/avatar-appearance-editor.tsx b/packages/client/src/client/avatar-appearance-editor.tsx index b0159cb848..36b7c77d7e 100644 --- a/packages/client/src/client/avatar-appearance-editor.tsx +++ b/packages/client/src/client/avatar-appearance-editor.tsx @@ -25,6 +25,7 @@ import { withAvatarCustomPart, wornAvatarPart, hairPieceStart, + headpieceStart, isHairPartSlot, isReplacePartSlot, replacePartStart, @@ -258,15 +259,54 @@ export interface PartLibraryActions { id?: string, part?: PixelCustomPart, ) => Promise<{ fileName: string; data: string } | undefined>; - importParts?: ( + importParts?: (data: string) => Promise< + | { + added: PartLibraryEntry[]; + refused: number; + image?: { width: number; height: number; colors: number; slots: string[] }; + } + | { error: string } + >; + importImage?: ( data: string, - ) => Promise<{ added: PartLibraryEntry[]; refused: number } | { error: string }>; + slot: string, + colors: number, + name: string, + ) => Promise; +} + +interface PendingImage { + data: string; + name: string; + width: number; + height: number; + colors: number; + slots: PartSlot[]; + slot: PartSlot; + keep: number; } -const ORIGIN_FILTERS = ['all', 'drawn', 'imported-bot', 'imported-file'] as const; +const ORIGIN_FILTERS = [ + 'all', + 'drawn', + 'derived', + 'imported-bot', + 'imported-file', + 'imported-image', +] as const; type OriginFilter = (typeof ORIGIN_FILTERS)[number]; -const originKey = (origin: Exclude) => - origin === 'imported-bot' ? 'importedBot' : origin === 'imported-file' ? 'importedFile' : 'drawn'; +const ORIGIN_KEYS = { + drawn: 'drawn', + derived: 'derived', + 'imported-bot': 'importedBot', + 'imported-file': 'importedFile', + 'imported-image': 'importedImage', +} as const; +const originKey = (origin: Exclude) => ORIGIN_KEYS[origin]; +const SLOT_CATEGORY = Object.fromEntries( + Object.entries(PART_CATEGORY).map(([category, slot]) => [slot, category]), +) as Record; +const MAX_IMAGE_KEEP = 32; const MAX_IMPORT_BYTES = 8 * 1024 * 1024; @@ -354,6 +394,7 @@ export function AvatarAppearanceEditor({ const [parts, setParts] = useState(); const [originFilter, setOriginFilter] = useState('all'); const [libraryNote, setLibraryNote] = useState(); + const [pendingImage, setPendingImage] = useState(); const [drawing, setDrawing] = useState<{ slot: PartSlot; base: IllustratedAvatarRecipe; @@ -424,13 +465,16 @@ export function AvatarAppearanceEditor({ : next; }; const originLabel = (entry: PartLibraryEntry) => - `${entry.name || t('profile.avatar.part.untitled')} · ${entry.origins - .map((origin) => - t( - `profile.avatar.part.origin.${origin === 'imported-bot' ? 'importedBot' : origin === 'imported-file' ? 'importedFile' : 'drawn'}`, - ), - ) - .join(', ')}`; + [ + entry.name || t('profile.avatar.part.untitled'), + entry.origins + .map((origin) => t(`profile.avatar.part.origin.${ORIGIN_KEYS[origin]}`)) + .join(', '), + ...(entry.author ? [t('profile.avatar.part.byAuthor', { author: entry.author })] : []), + ...(entry.parentAuthor + ? [t('profile.avatar.part.basedOn', { author: entry.parentAuthor })] + : []), + ].join(' · '); const partEditor = () => drawing ? ( + Object.hasOwn(SLOT_CATEGORY, value), + ); + setLibraryNote(undefined); + setPendingImage({ + data, + name: file.name.replace(/\.png$/iu, '').slice(0, 60), + width: result.image.width, + height: result.image.height, + colors: result.image.colors, + slots, + slot: slots.includes(slot) ? slot : slots[0]!, + keep: Math.min(result.image.colors, MAX_IMAGE_KEEP), + }); + return; + } setParts((current) => [ ...result.added, ...(current ?? []).filter((item) => !result.added.some((added) => added.id === item.id)), @@ -499,32 +560,59 @@ export function AvatarAppearanceEditor({ if (file) download(file.fileName, file.data, part ? 'image/png' : 'application/zip'); else setLibraryNote(t('profile.avatar.part.exportFailed')); }; + const builtInHead = slot === 'headpiece' ? builtInAvatarHeadpiece(recipe) : undefined; + const acceptImage = async (pending: PendingImage) => { + if (!library?.importImage) return; + const result = await library.importImage( + pending.data, + pending.slot, + pending.keep, + pending.name, + ); + if ('error' in result) { + setLibraryNote(result.error); + return; + } + setPendingImage(undefined); + setParts((current) => [result, ...(current ?? []).filter((item) => item.id !== result.id)]); + if (result.part.slot === slot) update(withAvatarCustomPart(recipe, slot, result.part)); + setLibraryNote(t('profile.avatar.part.imported', { count: 1, refused: 0 })); + }; const draw = () => setDrawing( - hair + builtInHead && !worn ? { slot, - base: withAvatarCustomPart(recipe, slot, undefined), + base: recipe, restore: recipe, - backdrop: withAvatarCustomPart(recipe, slot, { - slot, - front: [], - back: [], - }) as IllustratedAvatarRecipe, - initial: isHairPartSlot(slot) - ? hairPieceStart(recipe, slot) - : replacePartStart(recipe, slot as Parameters[1]), - ...(worn - ? { parent: customPartId(worn) } - : { - note: t( - isHairPartSlot(slot) - ? 'profile.avatar.part.flattenNote' - : 'profile.avatar.part.flattenPartNote', - ), - }), + backdrop: withAvatarCustomPart(recipe, slot, undefined) as IllustratedAvatarRecipe, + initial: headpieceStart(recipe), + note: t('profile.avatar.part.flattenPartNote'), } - : { slot, base: recipe, restore: recipe }, + : hair + ? { + slot, + base: withAvatarCustomPart(recipe, slot, undefined), + restore: recipe, + backdrop: withAvatarCustomPart(recipe, slot, { + slot, + front: [], + back: [], + }) as IllustratedAvatarRecipe, + initial: isHairPartSlot(slot) + ? hairPieceStart(recipe, slot) + : replacePartStart(recipe, slot as Parameters[1]), + ...(worn + ? { parent: customPartId(worn) } + : { + note: t( + isHairPartSlot(slot) + ? 'profile.avatar.part.flattenNote' + : 'profile.avatar.part.flattenPartNote', + ), + }), + } + : { slot, base: recipe, restore: recipe }, ); return ( <> @@ -625,6 +713,84 @@ export function AvatarAppearanceEditor({ ) : null} ) : null} + {pendingImage ? ( +
+

+ {t('profile.avatar.part.imageInfo', { + width: pendingImage.width, + height: pendingImage.height, + colors: pendingImage.colors, + })} +

+ {pendingImage.slots.length > 1 ? ( + + ) : null} + + {pendingImage.colors > MAX_IMAGE_KEEP ? ( +

+ {t('profile.avatar.part.imageReduce', { max: MAX_IMAGE_KEEP })} +

+ ) : null} +

{t('profile.avatar.part.imageRemap')}

+
+ + +
+
+ ) : null} {hair ? null : ( { +): Promise<{ added: PartLibraryEntry[]; refused: number; image?: PartImageInfo }> { const value = asRecord(await unwrap(call, 'partLibraryImport', { data })); const added = value?.['added']; const refused = value?.['refused']; if (!Array.isArray(added) || !Array.isArray(refused)) throw new Error('invalid partLibraryImport response'); - return { added: added.filter(isPartLibraryEntry), refused: refused.length }; + const image = asRecord(value?.['image']); + const info = + image && + typeof image['width'] === 'number' && + typeof image['height'] === 'number' && + typeof image['colors'] === 'number' && + Array.isArray(image['slots']) && + image['slots'].every((slot) => typeof slot === 'string') + ? { + width: image['width'], + height: image['height'], + colors: image['colors'], + slots: image['slots'] as string[], + } + : undefined; + return { + added: added.filter(isPartLibraryEntry), + refused: refused.length, + ...(info ? { image: info } : {}), + }; +} + +export interface PartImageInfo { + width: number; + height: number; + colors: number; + slots: string[]; +} + +export async function importLibraryImage( + call: BridgeCall, + data: string, + slot: string, + colors: number, + name: string, +): Promise { + const entry = asRecord( + await unwrap(call, 'partLibraryImportImage', { data, slot, colors, name }), + )?.['entry']; + if (!isPartLibraryEntry(entry)) throw new Error('invalid partLibraryImportImage response'); + return entry; } export async function addLibraryPart( diff --git a/packages/client/src/client/custom-part-editor.tsx b/packages/client/src/client/custom-part-editor.tsx index 0d4a00729d..b271bc5d1f 100644 --- a/packages/client/src/client/custom-part-editor.tsx +++ b/packages/client/src/client/custom-part-editor.tsx @@ -35,7 +35,16 @@ import { import type { BotHarnessTranslate } from './locale.js'; type Key = Parameters[0]; -type Tool = 'pencil' | 'eraser' | 'fill' | 'line' | 'rect' | 'gradient' | 'noise' | 'eyedropper'; +type Tool = + | 'pencil' + | 'eraser' + | 'fill' + | 'replace' + | 'line' + | 'rect' + | 'gradient' + | 'noise' + | 'eyedropper'; type Layers = Record; type Point = readonly [number, number]; interface Gesture { @@ -66,6 +75,7 @@ const TOOLS: readonly Tool[] = [ 'pencil', 'eraser', 'fill', + 'replace', 'line', 'rect', 'gradient', @@ -84,6 +94,13 @@ const OFFSET_ROWS = 3; const MIN_ZOOM = 6; const MAX_ZOOM = 16; +function replaced(layer: PartLayer, from: PartInk | null, to: PartInk | null): PartLayer { + if (!from) return layer; + return layer.map((row) => + row.map((ink) => (ink && ink.color === from.color && ink.tone === from.tone ? to : ink)), + ); +} + function inkColor(recipe: IllustratedAvatarRecipe, ink: PartInk): string { const base = ink.color.startsWith('#') ? ink.color : recipe[ink.color as 'hairColor']; return partToneColor(base, ink.tone); @@ -167,18 +184,20 @@ export function CustomPartEditor({ const next = tool === 'fill' ? fillPartLayer(slot, target, from[0], from[1], value, mirror) - : tool === 'line' - ? paintPartLayer(slot, target, partLinePoints(from, to, snap), value, mirror) - : tool === 'rect' - ? paintPartLayer(slot, target, partRectPoints(from, to, snap), value, mirror) - : tool === 'gradient' - ? gradientPartLayer(slot, target, from, to, ink.color, ink.tone, gradientTo, { - dither, - mirror, - }) - : tool === 'pencil' && shade !== 0 - ? shadePartLayer(slot, target, points, shade, mirror) - : paintPartLayer(slot, target, points, value, mirror); + : tool === 'replace' + ? replaced(target, target[from[1]]![from[0]] ?? null, value) + : tool === 'line' + ? paintPartLayer(slot, target, partLinePoints(from, to, snap), value, mirror) + : tool === 'rect' + ? paintPartLayer(slot, target, partRectPoints(from, to, snap), value, mirror) + : tool === 'gradient' + ? gradientPartLayer(slot, target, from, to, ink.color, ink.tone, gradientTo, { + dither, + mirror, + }) + : tool === 'pencil' && shade !== 0 + ? shadePartLayer(slot, target, points, shade, mirror) + : paintPartLayer(slot, target, points, value, mirror); return { ...base, [layer]: next }; }; const pick = ([x, y]: Point) => { @@ -193,7 +212,7 @@ export function CustomPartEditor({ else pick(point); return; } - const fixed = tool === 'fill' || tool === 'noise'; + const fixed = tool === 'fill' || tool === 'noise' || tool === 'replace'; const current = { base: layers, future, from: point, points: [point], fixed }; record(); if (tool === 'noise') { diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index e9534e5948..7327e16711 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -1780,6 +1780,19 @@ export const zh = { 'profile.avatar.part.origin.drawn': '在这里绘制', 'profile.avatar.part.origin.importedBot': '来自导入的 Bot', 'profile.avatar.part.origin.importedFile': '来自导入的文件', + 'profile.avatar.part.origin.derived': '基于其他部件修改', + 'profile.avatar.part.origin.importedImage': '来自导入的图片', + 'profile.avatar.part.byAuthor': '作者 {author}', + 'profile.avatar.part.basedOn': '基于 {author} 的部件', + 'profile.avatar.part.imageInfo': '这张图片是 {width}×{height},有 {colors} 种颜色。', + 'profile.avatar.part.imageSlot': '导入为', + 'profile.avatar.part.imageKeep': '保留颜色数', + 'profile.avatar.part.imageReduce': + '部件最多保留 {max} 种颜色,导入时每个像素会换成最接近的保留颜色(不抖动)。', + 'profile.avatar.part.imageRemap': + '导入后都是固定颜色;之后可以在编辑器里用「替换颜色」把它们换成外形颜色和明暗。', + 'profile.avatar.part.imageAccept': '导入到部件库', + 'profile.avatar.part.tool.replace': '替换颜色', 'profile.avatar.part.tools': '绘制工具', 'profile.avatar.part.tool.pencil': '铅笔', 'profile.avatar.part.tool.eraser': '橡皮', @@ -4423,6 +4436,19 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'profile.avatar.part.origin.drawn': 'Drawn here', 'profile.avatar.part.origin.importedBot': 'From an imported Bot', 'profile.avatar.part.origin.importedFile': 'From an imported file', + 'profile.avatar.part.origin.derived': 'Based on another part', + 'profile.avatar.part.origin.importedImage': 'From an imported image', + 'profile.avatar.part.byAuthor': 'by {author}', + 'profile.avatar.part.basedOn': "based on {author}'s part", + 'profile.avatar.part.imageInfo': 'This image is {width}×{height} with {colors} colors.', + 'profile.avatar.part.imageSlot': 'Import as', + 'profile.avatar.part.imageKeep': 'Colors to keep', + 'profile.avatar.part.imageReduce': + 'A part keeps at most {max} colors. Each pixel becomes the nearest kept color, without dithering.', + 'profile.avatar.part.imageRemap': + 'Imported colors are fixed. Use Replace color in the editor to turn them into appearance colors and tones.', + 'profile.avatar.part.imageAccept': 'Import to the library', + 'profile.avatar.part.tool.replace': 'Replace color', 'profile.avatar.part.tools': 'Drawing tools', 'profile.avatar.part.tool.pencil': 'Pencil', 'profile.avatar.part.tool.eraser': 'Eraser', diff --git a/packages/client/src/client/profile-header.tsx b/packages/client/src/client/profile-header.tsx index 3dfb3b21eb..be9da9ffc3 100644 --- a/packages/client/src/client/profile-header.tsx +++ b/packages/client/src/client/profile-header.tsx @@ -197,6 +197,7 @@ function AvatarModal({ | 'addLibraryPart' | 'exportLibraryParts' | 'importLibraryParts' + | 'importLibraryImage' >; t: BotHarnessTranslate; onClose(): void; @@ -229,6 +230,7 @@ function AvatarModal({ add: actions.addLibraryPart, exportParts: actions.exportLibraryParts, importParts: actions.importLibraryParts, + importImage: actions.importLibraryImage, }} onUpload={() => input.current?.click()} onRemoveImage={() => void removeImage()} diff --git a/packages/client/src/client/styles.ts b/packages/client/src/client/styles.ts index 0a1f131e06..cb7d880b7b 100644 --- a/packages/client/src/client/styles.ts +++ b/packages/client/src/client/styles.ts @@ -2454,6 +2454,11 @@ html[data-botharness-motion='full'] .bh-skeleton-line { .bh-part-name { display: flex; align-items: center; gap: 8px; font-size: 13px; } .bh-part-name input { flex: 1; min-height: 30px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: inherit; font: inherit; } .bh-part-library-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; } +.bh-part-image { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; font-size: 13px; } +.bh-part-image p { margin: 0; } +.bh-part-image label { display: flex; align-items: center; gap: 8px; } +.bh-part-image input, .bh-part-image select { min-height: 28px; padding: 0 6px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: inherit; font: inherit; } +.bh-part-image input { width: 72px; } .bh-avatar-color-reset { min-height: 26px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; cursor: pointer; } .bh-avatar-colors { display: flex; flex-direction: column; gap: 10px; } .bh-avatar-color-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; } diff --git a/packages/client/test/avatar-appearance-editor.test.ts b/packages/client/test/avatar-appearance-editor.test.ts index f7dda186c4..ea7a74a9d0 100644 --- a/packages/client/test/avatar-appearance-editor.test.ts +++ b/packages/client/test/avatar-appearance-editor.test.ts @@ -23,6 +23,8 @@ import { customPartId, hairPieceStart, replacePartStart, + headpieceStart, + withAvatarBuiltInHeadpiece, type PixelCustomPart, } from '../../core/src/bots/avatar-appearance.js'; import type { PartLibraryEntry } from '../../core/src/bots/part-library.js'; @@ -983,6 +985,155 @@ describe('Profile Avatar Appearance editing', () => { container.remove(); } }); + it('imports a plain PNG after showing its colors and labels derived parts', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const band: PixelCustomPart = { slot: 'headpiece', front: [[10, 2, '#efb93f', 0]], back: [] }; + const wings: PixelCustomPart = { slot: 'headpiece', front: [[9, 1, '#f4f1ec', 0]], back: [] }; + const importParts = vi.fn(async () => ({ + added: [], + refused: 0, + image: { width: 32, height: 16, colors: 40, slots: ['headpiece'] }, + })); + const importImage = vi.fn(async () => ({ + id: customPartId(band), + part: canonicalCustomPart(band), + name: 'band', + origins: ['imported-image' as const], + addedAt: '', + })); + const load = vi.fn(async (): Promise => [ + { + id: customPartId(wings), + part: canonicalCustomPart(wings), + name: 'Wider wings', + origins: ['derived'], + parent: 'a'.repeat(64), + parentAuthor: 'Mika', + addedAt: '', + }, + ]); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const $ = (selector: string) => container.querySelector(selector); + const click = async (selector: string) => act(() => $(selector)!.click()); + const bot = { + slug: 'ada', + displayName: 'Ada', + roles: [], + aggregateState: 'idle', + workspaces: [], + createdAt: '', + }; + try { + await act(() => + root.render( + createElement(AvatarAppearanceEditor, { + bot, + channelId: 'dm-ada', + onSave: vi.fn(async () => true), + library: { load, add: vi.fn(async () => undefined), importParts, importImage }, + t: zhTranslate, + }), + ), + ); + await click('[data-avatar-edit]'); + await click('[data-avatar-category="headpiece"]'); + expect( + $(`[data-avatar-option="headpiece:${customPartId(wings)}"]`)?.getAttribute('aria-label'), + ).toContain(zhTranslate('profile.avatar.part.basedOn', { author: 'Mika' })); + const input = $('[data-part-import] input') as HTMLInputElement; + const file = new File([new Uint8Array([1])], 'band.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { value: async () => new Uint8Array([1]).buffer }); + Object.defineProperty(input, 'files', { value: [file] }); + await act(async () => { + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect($('[data-part-image] p')?.textContent).toBe( + zhTranslate('profile.avatar.part.imageInfo', { width: 32, height: 16, colors: 40 }), + ); + expect($('[data-part-image-reduce]')).not.toBeNull(); + expect(($('[data-part-image-keep]') as HTMLInputElement).value).toBe('32'); + await act(() => { + const keep = $('[data-part-image-keep]') as HTMLInputElement; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(keep, '6'); + keep.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + $('[data-part-image-accept]')!.click(); + }); + expect(importImage).toHaveBeenCalledWith( + btoa(String.fromCharCode(1)), + 'headpiece', + 6, + 'band', + ); + expect($('[data-part-image]')).toBeNull(); + expect( + $(`[data-avatar-option="headpiece:${customPartId(band)}"]`)?.getAttribute('aria-pressed'), + ).toBe('true'); + } finally { + await act(() => root.unmount()); + container.remove(); + } + }); + it('opens a built-in headpiece as a flattened starting canvas', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const add = vi.fn(async (part: PixelCustomPart, name: string): Promise => ({ + id: customPartId(part), + part: canonicalCustomPart(part), + name, + origins: ['drawn'], + addedAt: '', + })); + const recipe = withAvatarBuiltInHeadpiece(seededAvatarRecipe('Ada'), 'catears'); + const start = headpieceStart(recipe); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const $ = (selector: string) => container.querySelector(selector); + const click = async (selector: string) => act(() => $(selector)!.click()); + const bot = { + slug: 'ada', + displayName: 'Ada', + roles: [], + aggregateState: 'idle', + workspaces: [], + createdAt: '', + appearance: { recipe, revision: '0'.repeat(64) }, + }; + try { + await act(() => + root.render( + createElement(AvatarAppearanceEditor, { + bot, + channelId: 'dm-ada', + onSave: vi.fn(async () => true), + library: { load: vi.fn(async () => []), add }, + t: zhTranslate, + }), + ), + ); + await click('[data-avatar-edit]'); + await click('[data-avatar-category="headpiece"]'); + expect($('[data-avatar-option="headpiece:catears"]')?.getAttribute('aria-pressed')).toBe( + 'true', + ); + await click('[data-part-draw]'); + expect($('[data-part-note]')?.textContent).toBe( + zhTranslate('profile.avatar.part.flattenPartNote'), + ); + const [x, y, color, tone] = start.front[0]!; + expect($(`[data-part-cell="${x},${y}"]`)?.getAttribute('data-part-ink')).toBe( + `${color}:${tone}`, + ); + await click('[data-part-save]'); + expect(add.mock.calls[0]![0]).toEqual(canonicalCustomPart(start)); + } finally { + await act(() => root.unmount()); + container.remove(); + } + }); }); describe('unsupported saved appearance', () => { diff --git a/packages/client/test/bot-sidebar.test.ts b/packages/client/test/bot-sidebar.test.ts index bb7cb115b6..b6173d3748 100644 --- a/packages/client/test/bot-sidebar.test.ts +++ b/packages/client/test/bot-sidebar.test.ts @@ -302,6 +302,7 @@ function stubActions(): BridgeActions { addLibraryPart: vi.fn(async () => undefined), exportLibraryParts: vi.fn(async () => undefined), importLibraryParts: vi.fn(async () => ({ added: [], refused: 0 })), + importLibraryImage: vi.fn(async () => ({ error: 'unavailable' })), profileUsage: vi.fn(), profileActivity: vi.fn(async () => ({ slug: '', diff --git a/packages/client/test/custom-part-tools.test.ts b/packages/client/test/custom-part-tools.test.ts index 7a8a4f6046..3ab0efca1a 100644 --- a/packages/client/test/custom-part-tools.test.ts +++ b/packages/client/test/custom-part-tools.test.ts @@ -159,6 +159,31 @@ describe('Custom Part drawing tools', () => { } }); + it('replaces every cell of one color and tone with the current ink in one step', async () => { + const two = createCustomPart('headpiece', { + front: partLayer('headpiece', [ + [1, 1, '#123456', 0], + [5, 3, '#123456', 0], + [7, 3, '#654321', 0], + ]), + back: partLayer('headpiece', []), + }); + const e = await mount(two); + try { + await e.click('[data-part-tool="replace"]'); + await e.click('button.bh-part-swatch[data-part-ink="hairColor:-1"]'); + await e.down(5, 3); + await e.up(); + expect(e.ink(1, 1)).toBe('hairColor:-1'); + expect(e.ink(5, 3)).toBe('hairColor:-1'); + expect(e.ink(7, 3)).toBe('#654321:0'); + await e.click('[data-part-undo]'); + expect(e.ink(1, 1)).toBe('#123456:0'); + } finally { + await e.close(); + } + }); + it('shades once per stroke, picks colors with Alt-click and the eyedropper', async () => { const e = await mount(); try { diff --git a/packages/core/src/bots/avatar-appearance.ts b/packages/core/src/bots/avatar-appearance.ts index 2236e31557..1bff20adc6 100644 --- a/packages/core/src/bots/avatar-appearance.ts +++ b/packages/core/src/bots/avatar-appearance.ts @@ -53,6 +53,7 @@ export { type AvatarStrand, type AvatarPieceColorV4, hairPieceStart, + headpieceStart, replacePartStart, REPLACE_PART_SLOTS, isReplacePartSlot, diff --git a/packages/core/src/bots/part-image.ts b/packages/core/src/bots/part-image.ts new file mode 100644 index 0000000000..3d1afded6b --- /dev/null +++ b/packages/core/src/bots/part-image.ts @@ -0,0 +1,206 @@ +import { inflateSync } from 'node:zlib'; +import { + MAX_PART_FIXED_COLORS, + PART_SLOTS, + canonicalCustomPart, + type PartSlot, + type PixelCustomPart, +} from './avatar-appearance.js'; +import { crc32 } from './zip-archive.js'; + +export const MAX_PART_IMAGE_BYTES = 1024 * 1024; +export const MAX_PART_IMAGE_SCALE = 16; +export const MAX_PART_IMAGE_COLORS = 256; +const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export type PartImageError = + | 'not-png' + | 'unsupported' + | 'too-large' + | 'wrong-size' + | 'too-many-colors' + | 'empty'; + +export interface PartImage { + width: number; + height: number; + scale: number; + slots: PartSlot[]; + cells: { x: number; y: number; color: string; count: number }[]; + colors: number; +} + +interface Raster { + width: number; + height: number; + rgba: Uint8Array; +} + +function decodePng(bytes: Buffer): Raster | PartImageError { + if (bytes.length > MAX_PART_IMAGE_BYTES) return 'too-large'; + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(SIGNATURE)) return 'not-png'; + let offset = 8; + let header: Buffer | undefined; + let palette: Buffer | undefined; + let transparency: Buffer | undefined; + const data: Buffer[] = []; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + const end = offset + 12 + length; + if (end > bytes.length) return 'not-png'; + const body = bytes.subarray(offset + 4, offset + 8 + length); + if (crc32(body) >>> 0 !== bytes.readUInt32BE(offset + 8 + length)) return 'not-png'; + const type = body.subarray(0, 4).toString('latin1'); + const chunk = body.subarray(4); + if (type === 'IHDR') header = chunk; + else if (type === 'PLTE') palette = chunk; + else if (type === 'tRNS') transparency = chunk; + else if (type === 'IDAT') data.push(chunk); + else if (type === 'IEND') break; + offset = end; + } + if (!header || header.length !== 13 || data.length === 0) return 'not-png'; + const width = header.readUInt32BE(0); + const height = header.readUInt32BE(4); + const depth = header[8]!; + const colorType = header[9]!; + if (header[12] !== 0) return 'unsupported'; + const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType]; + if (channels === undefined) return 'unsupported'; + if (depth !== 8 && !(colorType === 3 && (depth === 1 || depth === 2 || depth === 4))) + return 'unsupported'; + if (colorType === 3 && !palette) return 'not-png'; + if ( + width === 0 || + height === 0 || + width > 32 * MAX_PART_IMAGE_SCALE || + height > 32 * MAX_PART_IMAGE_SCALE + ) + return 'wrong-size'; + const stride = Math.ceil((width * channels * depth) / 8); + const bpp = Math.max(1, Math.ceil((channels * depth) / 8)); + let raw: Buffer; + try { + raw = inflateSync(Buffer.concat(data), { maxOutputLength: (stride + 1) * height }); + } catch { + return 'not-png'; + } + if (raw.length !== (stride + 1) * height) return 'not-png'; + const lines = Buffer.alloc(stride * height); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]!; + const source = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const line = lines.subarray(y * stride, (y + 1) * stride); + const previous = y > 0 ? lines.subarray((y - 1) * stride, y * stride) : undefined; + for (let i = 0; i < stride; i++) { + const a = i >= bpp ? line[i - bpp]! : 0; + const b = previous ? previous[i]! : 0; + const c = previous && i >= bpp ? previous[i - bpp]! : 0; + const value = source[i]!; + if (filter === 0) line[i] = value; + else if (filter === 1) line[i] = value + a; + else if (filter === 2) line[i] = value + b; + else if (filter === 3) line[i] = value + ((a + b) >> 1); + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + line[i] = value + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + } else return 'not-png'; + } + } + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const line = lines.subarray(y * stride, (y + 1) * stride); + const out = (y * width + x) * 4; + if (colorType === 3) { + const bit = x * depth; + const index = (line[bit >> 3]! >> (8 - depth - (bit & 7))) & ((1 << depth) - 1); + if (index * 3 + 2 >= palette!.length) return 'not-png'; + rgba[out] = palette![index * 3]!; + rgba[out + 1] = palette![index * 3 + 1]!; + rgba[out + 2] = palette![index * 3 + 2]!; + rgba[out + 3] = transparency && index < transparency.length ? transparency[index]! : 255; + } else { + const px = line.subarray(x * channels, x * channels + channels); + const gray = colorType === 0 || colorType === 4; + rgba[out] = px[0]!; + rgba[out + 1] = gray ? px[0]! : px[1]!; + rgba[out + 2] = gray ? px[0]! : px[2]!; + rgba[out + 3] = colorType === 4 ? px[1]! : colorType === 6 ? px[3]! : 255; + } + } + return { width, height, rgba }; +} + +const hex = (r: number, g: number, b: number) => + `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +export function readPartImage(bytes: Buffer): PartImage | PartImageError { + const raster = decodePng(bytes); + if (typeof raster === 'string') return raster; + const { width, height, rgba } = raster; + const scale = width / 32; + if (!Number.isInteger(scale) || scale < 1 || scale > MAX_PART_IMAGE_SCALE) return 'wrong-size'; + const slots = (Object.keys(PART_SLOTS) as PartSlot[]).filter( + (slot) => + PART_SLOTS[slot].width * scale === width && PART_SLOTS[slot].height * scale === height, + ); + if (slots.length === 0) return 'wrong-size'; + const counts = new Map(); + const cells: PartImage['cells'] = []; + const half = Math.floor(scale / 2); + for (let y = 0; y < height / scale; y++) + for (let x = 0; x < 32; x++) { + const at = ((y * scale + half) * width + x * scale + half) * 4; + if (rgba[at + 3]! < 128) continue; + const color = hex(rgba[at]!, rgba[at + 1]!, rgba[at + 2]!); + counts.set(color, (counts.get(color) ?? 0) + 1); + cells.push({ x, y, color, count: 0 }); + } + if (cells.length === 0) return 'empty'; + if (counts.size > MAX_PART_IMAGE_COLORS) return 'too-many-colors'; + for (const cell of cells) cell.count = counts.get(cell.color)!; + return { width, height, scale, slots, cells, colors: counts.size }; +} + +const rgb = (color: string) => [1, 3, 5].map((i) => Number.parseInt(color.slice(i, i + 2), 16)); + +export function quantizedPalette(image: PartImage, colors: number): string[] { + const counts = new Map(image.cells.map((cell) => [cell.color, cell.count])); + return [...counts.entries()] + .sort(([a, x], [b, y]) => y - x || a.localeCompare(b)) + .slice(0, colors) + .map(([color]) => color); +} + +export function partFromImage( + image: PartImage, + slot: PartSlot, + colors: number, +): PixelCustomPart | 'wrong-size' | 'too-many-colors' { + if (!image.slots.includes(slot)) return 'wrong-size'; + if (!Number.isInteger(colors) || colors < 1 || colors > MAX_PART_FIXED_COLORS) + return 'too-many-colors'; + const palette = quantizedPalette(image, colors).map((color) => ({ color, rgb: rgb(color) })); + const nearest = (color: string) => { + const [r, g, b] = rgb(color); + let best = palette[0]!; + let distance = Number.POSITIVE_INFINITY; + for (const entry of palette) { + const d = (entry.rgb[0]! - r!) ** 2 + (entry.rgb[1]! - g!) ** 2 + (entry.rgb[2]! - b!) ** 2; + if (d < distance) { + distance = d; + best = entry; + } + } + return best.color; + }; + return canonicalCustomPart({ + slot, + front: image.cells.map(({ x, y, color }) => [x, y, nearest(color) as `#${string}`, 0]), + back: [], + }); +} diff --git a/packages/core/src/bots/part-library.ts b/packages/core/src/bots/part-library.ts index ffbb70f40d..bb552742f2 100644 --- a/packages/core/src/bots/part-library.ts +++ b/packages/core/src/bots/part-library.ts @@ -6,7 +6,13 @@ import { type PixelCustomPart, } from './avatar-appearance.js'; -export const PART_ORIGINS = ['drawn', 'imported-bot', 'imported-file'] as const; +export const PART_ORIGINS = [ + 'drawn', + 'derived', + 'imported-bot', + 'imported-file', + 'imported-image', +] as const; export type PartOrigin = (typeof PART_ORIGINS)[number]; export const MAX_PART_NAME = 60; @@ -17,6 +23,7 @@ export interface PartLibraryEntry { origins: PartOrigin[]; parent?: string; author?: string; + parentAuthor?: string; addedAt: string; } @@ -26,6 +33,7 @@ export interface PartLibraryInput { origin: PartOrigin; parent?: string | undefined; author?: string | undefined; + parentAuthor?: string | undefined; } export interface PartLibrary { @@ -51,6 +59,8 @@ export function isPartLibraryEntry(value: unknown): value is PartLibraryEntry { (r['parent'] === undefined || (typeof r['parent'] === 'string' && ID.test(r['parent']))) && (r['author'] === undefined || (typeof r['author'] === 'string' && r['author'].length <= MAX_PART_NAME)) && + (r['parentAuthor'] === undefined || + (typeof r['parentAuthor'] === 'string' && r['parentAuthor'].length <= MAX_PART_NAME)) && typeof r['addedAt'] === 'string' ); } @@ -95,6 +105,9 @@ export function createPartLibrary(options: { origins: [input.origin], ...(input.parent !== undefined && input.parent !== id ? { parent: input.parent } : {}), ...(input.author ? { author: input.author.slice(0, MAX_PART_NAME) } : {}), + ...(input.parentAuthor + ? { parentAuthor: input.parentAuthor.slice(0, MAX_PART_NAME) } + : {}), addedAt: now().toISOString(), }; database.transaction( diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 0b3b5d6a6f..167387ddac 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -90,7 +90,20 @@ import { import { botAvatarUrl } from '../bots/avatar-http.js'; import { botBannerSummary, type BotBannerSummary } from '../bots/banner-http.js'; import { isBotBanner, seededBotBanner } from '../bots/bot-banner.js'; -import { customPartId, isPixelCustomPart } from '../bots/avatar-appearance.js'; +import { + PART_SLOTS, + customPartId, + isPixelCustomPart, + type PartSlot, +} from '../bots/avatar-appearance.js'; +import { + MAX_PART_IMAGE_BYTES, + MAX_PART_IMAGE_COLORS, + MAX_PART_IMAGE_SCALE, + partFromImage, + readPartImage, + type PartImageError, +} from '../bots/part-image.js'; import { MAX_PART_NAME, type PartLibrary, type PartLibraryEntry } from '../bots/part-library.js'; import { MAX_PART_LIBRARY_FILES, @@ -529,7 +542,9 @@ export interface BridgeMethods { partLibraryImport(payload: unknown): BridgeResult<{ added: PartLibraryEntry[]; refused: { name: string; reason: string }[]; + image?: { width: number; height: number; colors: number; slots: PartSlot[] }; }>; + partLibraryImportImage(payload: unknown): BridgeResult<{ entry: PartLibraryEntry }>; marketplaceList(payload: unknown): Promise>; marketplaceSubmit(payload: unknown): Promise>; marketplaceTopics(): Promise>; @@ -610,6 +625,23 @@ function unmanagedGitStatus(): GitStatus { : { ...git, installable: false, install: { phase: 'idle' } }; } +function partImageMessage(error: PartImageError): string { + switch (error) { + case 'too-large': + return 'The image is larger than 1 MB'; + case 'wrong-size': + return `The image must be 32×32 or 32×16 pixels, or a whole-number scale of it up to ×${MAX_PART_IMAGE_SCALE}`; + case 'too-many-colors': + return `The image has more than ${MAX_PART_IMAGE_COLORS} colors, or more colors than a part can keep`; + case 'empty': + return 'The image has no opaque pixels'; + case 'unsupported': + return 'Interlaced and 16-bit PNGs are not supported'; + case 'not-png': + return 'The file is not a PNG'; + } +} + type ParsedField = { ok: true; value: T | undefined } | { ok: false }; function asObject(payload: unknown): Record { @@ -2231,7 +2263,15 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { if (parent !== undefined && (typeof parent !== 'string' || !/^[\da-f]{64}$/u.test(parent))) return invalidInput('parent must be a Custom Part id'); try { - const entry = deps.partLibrary.add({ part, name, origin: 'drawn', parent }); + const source = parent === undefined ? undefined : deps.partLibrary.get(parent); + const parentAuthor = source?.author ?? source?.parentAuthor; + const entry = deps.partLibrary.add({ + part, + name, + origin: source ? 'derived' : 'drawn', + parent, + ...(parentAuthor ? { parentAuthor } : {}), + }); return { ok: true, value: { entry } }; } catch (error) { if (error instanceof OperationalDatabaseError) return unavailable(); @@ -2297,16 +2337,32 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { const data = asObject(payload)['data']; if (typeof data !== 'string' || data.length > (MAX_PART_LIBRARY_FILE_BYTES * 4) / 3 + 4) return invalidInput('data must be a base64 PNG or zip of at most 8 MB'); - const decoded = decodePartFiles(Buffer.from(data, 'base64')); + const bytes = Buffer.from(data, 'base64'); + const decoded = decodePartFiles(bytes); + if (decoded === 'no-part-data') { + const image = readPartImage(bytes); + if (typeof image === 'string') return invalidInput(partImageMessage(image)); + return { + ok: true, + value: { + added: [], + refused: [], + image: { + width: image.width, + height: image.height, + colors: image.colors, + slots: image.slots, + }, + }, + }; + } if (typeof decoded === 'string') return invalidInput( decoded === 'too-large' ? 'The file is too large' - : decoded === 'no-part-data' - ? 'This PNG has no part data' - : decoded === 'invalid-part' - ? 'The part data in this file is invalid' - : 'The file is not a part PNG or a zip of part PNGs', + : decoded === 'invalid-part' + ? 'The part data in this file is invalid' + : 'The file is not a part PNG or a zip of part PNGs', ); try { const added = decoded.files.map((file) => @@ -2323,6 +2379,33 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { throw error; } }, + partLibraryImportImage(payload) { + if (!deps.partLibrary) return unavailable(); + const input = asObject(payload); + const data = input['data']; + const slot = input['slot']; + const colors = input['colors']; + const name = input['name'] ?? ''; + if (typeof data !== 'string' || data.length > (MAX_PART_IMAGE_BYTES * 4) / 3 + 4) + return invalidInput('data must be a base64 PNG of at most 1 MB'); + if (typeof slot !== 'string' || !Object.hasOwn(PART_SLOTS, slot)) + return invalidInput('slot must be a Custom Part slot'); + if (typeof name !== 'string' || name.length > MAX_PART_NAME) + return invalidInput(`name must be a string of at most ${MAX_PART_NAME} characters`); + if (typeof colors !== 'number' || !Number.isInteger(colors) || colors < 1) + return invalidInput('colors must be a positive integer'); + const image = readPartImage(Buffer.from(data, 'base64')); + if (typeof image === 'string') return invalidInput(partImageMessage(image)); + const part = partFromImage(image, slot as PartSlot, colors); + if (typeof part === 'string') return invalidInput(partImageMessage(part)); + try { + const entry = deps.partLibrary.add({ part, name, origin: 'imported-image' }); + return { ok: true, value: { entry } }; + } catch (error) { + if (error instanceof OperationalDatabaseError) return unavailable(); + throw error; + } + }, botBannerSet(payload) { const scope = dmMemory(payload); if (!('botSlug' in scope)) return scope; diff --git a/packages/core/src/bridge/rpc.ts b/packages/core/src/bridge/rpc.ts index 5f6c05a25d..5356ae47dc 100644 --- a/packages/core/src/bridge/rpc.ts +++ b/packages/core/src/bridge/rpc.ts @@ -507,6 +507,15 @@ export class BotharnessBridgeService extends TypertRemoteService { return unwrap(this.methods.partLibraryImport({ data })); } + partLibraryImportImage( + data: string, + slot: string, + colors: number, + name: string, + ): { entry: PartLibraryEntry } { + return unwrap(this.methods.partLibraryImportImage({ data, slot, colors, name })); + } + partLibraryAdd(part: unknown, name: string, parent?: string): { entry: PartLibraryEntry } { return unwrap(this.methods.partLibraryAdd({ part, name, parent })); } @@ -1262,6 +1271,7 @@ markRemoteMethods(BotharnessBridgeService.prototype, [ 'partLibraryAdd', 'partLibraryExport', 'partLibraryImport', + 'partLibraryImportImage', 'botBannerSet', 'marketplaceList', 'marketplaceSubmit', diff --git a/packages/core/test/bridge-rpc.test.ts b/packages/core/test/bridge-rpc.test.ts index a61d0992e9..327fd42e2a 100644 --- a/packages/core/test/bridge-rpc.test.ts +++ b/packages/core/test/bridge-rpc.test.ts @@ -217,6 +217,7 @@ describe('bridge typert service', () => { 'partLibraryAdd', 'partLibraryExport', 'partLibraryImport', + 'partLibraryImportImage', 'botBannerSet', 'marketplaceList', 'marketplaceSubmit', diff --git a/packages/core/test/part-image.test.ts b/packages/core/test/part-image.test.ts new file mode 100644 index 0000000000..bfca5bb21c --- /dev/null +++ b/packages/core/test/part-image.test.ts @@ -0,0 +1,195 @@ +import { deflateSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { MAX_PART_IMAGE_BYTES, partFromImage, readPartImage } from '../src/bots/part-image.js'; +import { encodePartFile } from '../src/bots/part-file.js'; +import { crc32 } from '../src/bots/zip-archive.js'; +import { createBridgeMethods } from '../src/bridge/methods.js'; +import { createCore } from '../src/plugin.js'; +import type { BotAgentAdapter } from '../src/runtime/bot-runtime.js'; +import type { PixelCustomPart } from '../src/bots/avatar-appearance.js'; +import { createTempRoot } from './helpers.js'; + +type Pixel = readonly [number, number, number, number]; + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body) >>> 0); + return Buffer.concat([length, body, crc]); +} + +function png( + width: number, + height: number, + pixel: (x: number, y: number) => Pixel, + options: { filter?: number; interlace?: boolean } = {}, +): Buffer { + const rows: Buffer[] = []; + let previous = Buffer.alloc(width * 4); + for (let y = 0; y < height; y++) { + const line = Buffer.alloc(width * 4); + for (let x = 0; x < width; x++) Buffer.from(pixel(x, y)).copy(line, x * 4); + const filter = options.filter ?? 0; + const encoded = Buffer.alloc(width * 4); + for (let i = 0; i < line.length; i++) + encoded[i] = filter === 2 ? (line[i]! - previous[i]!) & 255 : line[i]!; + rows.push(Buffer.from([filter]), encoded); + previous = line; + } + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + header[12] = options.interlace ? 1 : 0; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(Buffer.concat(rows))), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +function palettePng(): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(32, 0); + header.writeUInt32BE(16, 4); + header[8] = 4; + header[9] = 3; + const rows: Buffer[] = []; + for (let y = 0; y < 16; y++) { + const line = Buffer.alloc(16); + for (let x = 0; x < 32; x++) { + const index = y < 4 ? 0 : x < 16 ? 1 : 2; + line[x >> 1]! |= index << (x % 2 === 0 ? 4 : 0); + } + rows.push(Buffer.from([0]), line); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('PLTE', Buffer.from([0, 0, 0, 0xef, 0xb9, 0x3f, 0x5a, 0x7b, 0xe0])), + chunk('tRNS', Buffer.from([0])), + chunk('IDAT', deflateSync(Buffer.concat(rows))), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +const crownPixels = (x: number, y: number): Pixel => + y === 2 && x >= 10 && x <= 21 + ? [0xef, 0xb9, 0x3f, 255] + : y === 3 && x >= 10 && x <= 21 + ? [0xe2, 0x56, 0x5f, 255] + : [0, 0, 0, 0]; + +const agents: BotAgentAdapter = { + async runOrchestrator() {}, + async runAssignment() {}, + requestAssignment() { + throw new Error('Unexpected Assignment'); + }, + async close() {}, +}; + +describe('plain PNG parts', () => { + it('reads slot-sized images, whole-number scales, palettes and transparency', () => { + const full = readPartImage(png(32, 32, crownPixels)); + expect(full).toMatchObject({ width: 32, height: 32, scale: 1, colors: 2 }); + expect(typeof full !== 'string' && full.slots).toContain('outfit'); + expect(typeof full !== 'string' && full.slots).not.toContain('headpiece'); + const scaled = readPartImage( + png(128, 64, (x, y) => crownPixels(Math.floor(x / 4), Math.floor(y / 4)), { filter: 2 }), + ); + expect(scaled).toMatchObject({ scale: 4, slots: ['headpiece'], colors: 2 }); + expect(typeof scaled !== 'string' && scaled.cells).toHaveLength(24); + const palette = readPartImage(palettePng()); + expect(palette).toMatchObject({ slots: ['headpiece'], colors: 2 }); + expect(typeof palette !== 'string' && palette.cells).toHaveLength(32 * 12); + }); + + it('refuses wrong sizes, too many colors, empty and unsupported images', () => { + expect(readPartImage(png(30, 30, crownPixels))).toBe('wrong-size'); + expect(readPartImage(png(32, 20, crownPixels))).toBe('wrong-size'); + expect(readPartImage(png(32, 32, () => [0, 0, 0, 0]))).toBe('empty'); + expect(readPartImage(png(32, 32, (x, y) => [x * 8, y * 8, (x + y) % 2 ? 255 : 0, 255]))).toBe( + 'too-many-colors', + ); + expect(readPartImage(png(32, 32, crownPixels, { interlace: true }))).toBe('unsupported'); + expect(readPartImage(Buffer.from('nope'))).toBe('not-png'); + expect(readPartImage(Buffer.alloc(MAX_PART_IMAGE_BYTES + 1))).toBe('too-large'); + }); + + it('reduces the palette by nearest color without dithering, separately from remapping', () => { + const image = readPartImage( + png(32, 32, (x, y) => (y < 8 ? [x * 4, 100 + (x % 2), 50, 255] : [0, 0, 0, 0])), + ); + if (typeof image === 'string') throw new Error(image); + expect(image.colors).toBe(32); + const part = partFromImage(image, 'outfit', 4); + if (typeof part === 'string') throw new Error(part); + const used = new Set(part.front.map(([, , color]) => color)); + expect(used.size).toBeLessThanOrEqual(4); + expect(part.front.every(([, , color, tone]) => color.startsWith('#') && tone === 0)).toBe(true); + expect(part.front).toHaveLength(32 * 8); + expect(partFromImage(image, 'headpiece', 4)).toBe('wrong-size'); + expect(partFromImage(image, 'outfit', 33)).toBe('too-many-colors'); + const exact = partFromImage(image, 'outfit', 32); + expect(typeof exact !== 'string' && new Set(exact.front.map(([, , c]) => c)).size).toBe(32); + }); +}); + +describe('plain PNG import and derived parts through the public Host bridge', () => { + it('shows the color count first, imports as fixed colors and records derived parents', async () => { + const core = createCore({ dshHome: createTempRoot('bh-part-image-'), agents }); + try { + const bridge = createBridgeMethods({ ...core }); + const data = png(32, 16, crownPixels).toString('base64'); + const inspected = bridge.partLibraryImport({ data }); + expect(inspected).toMatchObject({ + ok: true, + value: { added: [], image: { width: 32, height: 16, colors: 2, slots: ['headpiece'] } }, + }); + const wrong = bridge.partLibraryImport({ data: png(31, 16, crownPixels).toString('base64') }); + expect(wrong).toMatchObject({ ok: false }); + expect(bridge.partLibraryImportImage({ data, slot: 'bangs', colors: 2 }).ok).toBe(false); + const imported = bridge.partLibraryImportImage({ + data, + slot: 'headpiece', + colors: 1, + name: 'Band', + }); + if (!imported.ok) throw new Error('import failed'); + expect(imported.value.entry.origins).toEqual(['imported-image']); + expect(new Set(imported.value.entry.part.front.map(([, , c]) => c)).size).toBe(1); + + const theirs: PixelCustomPart = { + slot: 'headpiece', + front: [[9, 1, '#f4f1ec', 0]], + back: [], + }; + const file = encodePartFile({ part: theirs, name: 'Wings', author: 'Mika' }); + const shared = bridge.partLibraryImport({ data: file.toString('base64') }); + if (!shared.ok) throw new Error('import failed'); + const parent = shared.value.added[0]!; + const derived = bridge.partLibraryAdd({ + part: { ...theirs, front: [...theirs.front, [22, 1, '#f4f1ec', 0]] }, + name: 'Wider wings', + parent: parent.id, + }); + expect(derived).toMatchObject({ + ok: true, + value: { entry: { parent: parent.id, parentAuthor: 'Mika', origins: ['derived'] } }, + }); + const listed = bridge.partLibraryList(); + const original = listed.ok ? listed.value.parts.find((p) => p.id === parent.id) : undefined; + expect(original).toMatchObject({ part: theirs, author: 'Mika', origins: ['imported-file'] }); + const own = bridge.partLibraryAdd({ part: theirs, name: '' }); + expect(own.ok && own.value.entry.origins).toEqual(['imported-file', 'drawn']); + } finally { + await core.runtime.close(); + core.operationalDatabase.close(); + } + }); +}); diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 39f8e76fc4..074dc1ad7e 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -477,6 +477,7 @@ describe('plugin entry', () => { 'partLibraryAdd', 'partLibraryExport', 'partLibraryImport', + 'partLibraryImportImage', 'botBannerSet', 'marketplaceList', 'marketplaceSubmit',