feat(desktop): let a nine-row action sheet become a pet instead of demanding an exact atlas

Importing an animated pet required a file that was exactly 1536x2288, laid
out as 88 seamless cells, with the last two rows holding sixteen distinct
gaze angles. No image model emits that. Whatever a user got back from Jimeng
or ChatGPT was some fixed size like 1024x1536, so the path ended at "the
animation atlas must be exactly 1536x2288 pixels" every time. The third card
was worse: "AI-generate full animation" was hardcoded `disabled`, so the one
entry point named after what people actually wanted to do was dead.

The fix was already in the tree. `scripts/assemble-generated-pet-atlas.py`
landed in the same commit as the four built-in pets, which is to say the
built-ins were produced this way — it takes an action sheet at any size,
slices it on an 8x9 grid, fits each cell to 192x208, mirrors the run row to
make run-left, and reuses rows to reach eleven. That capability was never
wired to anything a user could reach.

`petAtlasNormalize.ts` reimplements it on a canvas in the renderer, so an
author draws nine rows and the app derives the rest. Verified against the
reference assembler by reversing dada-code's atlas into a nine-row sheet and
re-normalizing it: every difference lands on semi-transparent antialiased
edges (2314 pixels, max channel delta 14/255) and opaque regions are
identical. That residue is canvas premultiplied-alpha round-tripping, not a
slicing bug.

Three contract details worth stating. Row frame counts are now derived from
`PET_ANIMATION_DEFINITIONS` rather than typed out a fourth time; they come
out equal to the assembler's `(6,8,8,4,5,8,6,6,6,8,8)`. A sheet already at
1536x2288 passes through byte-for-byte instead of being resliced, because
resampling finished artwork buys nothing. And since the validator never
inspects the alpha channel, a flattened white background used to import
happily and render as a rectangle on the desktop — the renderer now rejects
sheets whose atlas is under 5% transparent (the built-ins sit near 78%) with
a message that names the actual problem.

The copy stops describing the implementation. "Animate one image" and
"Import professional animation atlas / exact 1536x2288 v2 PNG" become "use a
picture you already have" and "I already have an action sheet"; the dead AI
card becomes a three-step walkthrough carrying a copyable prompt, a labelled
8x9 reference grid that can be saved locally, and the checks that catch the
common failures. Reference images are generated by a script rather than hand-
placed, in both languages. All five locales move together.

Caught while reviewing the real dialog in Electron: after finishing the
walkthrough the form heading fell through to the atlas branch and announced
"I already have an action sheet" to someone who had just been walked through
drawing one. Covered by a test now.

Not done: docs/images/desktop_ui/15_pet_create_methods.png still shows the
old dialog and needs a fresh capture from a running app to match the styling
of the shots around it.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-27 07:20:09 +08:00
parent e972a362dc
commit fc9f5d554e
31 changed files with 2099 additions and 282 deletions
+41 -8
View File
@@ -39,14 +39,7 @@ const isSafeUiLabel = (value: unknown) =>
&& value.length <= 120
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value)
const petCreateFromAtlas: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, [
'slug',
'displayName',
'description',
'dialogTitle',
'dialogFilterName',
])) return false
const hasValidPetIdentity = (value: Record<string, unknown>): boolean => {
if (
typeof value.slug !== 'string'
|| value.slug.length === 0
@@ -59,10 +52,48 @@ const petCreateFromAtlas: Validator = value => {
&& typeof value.description === 'string'
&& value.description.trim().length > 0
&& value.description.length <= 500
}
const petCreateFromAtlas: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, [
'slug',
'displayName',
'description',
'dialogTitle',
'dialogFilterName',
])) return false
return hasValidPetIdentity(value)
&& (value.dialogTitle === undefined || isSafeUiLabel(value.dialogTitle))
&& (value.dialogFilterName === undefined || isSafeUiLabel(value.dialogFilterName))
}
const petPickSourceSheet: Validator = value => {
if (value === undefined) return true
if (!isRecord(value) || !hasOnlyKeys(value, ['dialogTitle', 'dialogFilterName'])) return false
return (value.dialogTitle === undefined || isSafeUiLabel(value.dialogTitle))
&& (value.dialogFilterName === undefined || isSafeUiLabel(value.dialogFilterName))
}
/** Matches DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES so oversized atlases are dropped at the boundary. */
const MAX_PET_ATLAS_PAYLOAD_BYTES = 8 * 1024 * 1024
const petCreateFromAtlasBytes: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, [
'slug',
'displayName',
'description',
'atlasData',
'mimeType',
])) return false
if (
!(value.atlasData instanceof Uint8Array)
|| value.atlasData.byteLength === 0
|| value.atlasData.byteLength > MAX_PET_ATLAS_PAYLOAD_BYTES
) return false
if (value.mimeType !== 'image/png' && value.mimeType !== 'image/webp') return false
return hasValidPetIdentity(value)
}
const petContextMenu: Validator = value => {
if (!isRecord(value) || !hasOnlyKeys(value, ['closeLabel'])) return false
if (typeof value.closeLabel !== 'string' || value.closeLabel.length > 80) return false
@@ -186,6 +217,8 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.petsList]: noPayload,
[ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsPickSourceSheet]: petPickSourceSheet,
[ELECTRON_IPC_CHANNELS.petsCreateFromAtlasBytes]: petCreateFromAtlasBytes,
[ELECTRON_IPC_CHANNELS.petsOpenFolder]: noPayload,
[ELECTRON_IPC_CHANNELS.petsShow]: noPayload,
[ELECTRON_IPC_CHANNELS.petsHide]: noPayload,
+2
View File
@@ -12,6 +12,8 @@ export const ELECTRON_IPC_CHANNELS = {
petsList: 'desktop:pets:list',
petsCreateFromImage: 'desktop:pets:create-from-image',
petsCreateFromAtlas: 'desktop:pets:create-from-atlas',
petsPickSourceSheet: 'desktop:pets:pick-source-sheet',
petsCreateFromAtlasBytes: 'desktop:pets:create-from-atlas-bytes',
petsOpenFolder: 'desktop:pets:open-folder',
petsShow: 'desktop:pets:show',
petsHide: 'desktop:pets:hide',
+47
View File
@@ -60,10 +60,12 @@ import {
import {
createCustomPetCatalogLoader,
createCustomPetFromAtlas,
createCustomPetFromAtlasBytes,
createCustomPetFromImage,
ensureCustomPetsRoot,
getPetPackageErrorCode,
loadCustomPets,
readCustomPetSourceImage,
} from './services/pets'
import {
installWindowLifecycle,
@@ -475,6 +477,51 @@ function registerIpcHandlers() {
return { errorCode: getPetPackageErrorCode(error) }
}
})
registerHandler(ELECTRON_IPC_CHANNELS.petsPickSourceSheet, async (event, payload) => {
const input = payload as { dialogTitle?: string, dialogFilterName?: string }
const imagePath = await openDialog(currentWindow(event), {
title: input.dialogTitle || 'Choose a pet action sheet',
filters: [{ name: input.dialogFilterName || 'Pet action sheet', extensions: ['png', 'webp'] }],
})
if (typeof imagePath !== 'string') return null
try {
const source = await readCustomPetSourceImage(imagePath)
// The renderer needs the pixels to assemble the atlas on a canvas; the path
// itself is deliberately never handed over.
return {
bytes: source.data,
mimeType: source.mimeType,
width: source.width,
height: source.height,
}
} catch (error) {
return { errorCode: getPetPackageErrorCode(error) }
}
})
registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromAtlasBytes, async (_event, payload) => {
const input = payload as {
slug: string
displayName: string
description: string
atlasData: Uint8Array
mimeType: 'image/png' | 'image/webp'
}
try {
const pet = await loadCustomPetCatalog.invalidateAfter(() =>
createCustomPetFromAtlasBytes({
slug: input.slug,
displayName: input.displayName,
description: input.description,
atlasData: input.atlasData,
mimeType: input.mimeType,
}, {
inspectImageSize: ({ data }) => nativeImage.createFromBuffer(data).getSize(),
}))
return { id: pet.id }
} catch (error) {
return { errorCode: getPetPackageErrorCode(error) }
}
})
registerHandler(ELECTRON_IPC_CHANNELS.petsOpenFolder, async () => {
const root = await ensureCustomPetsRoot()
await openSystemPath(root)
+132 -18
View File
@@ -159,6 +159,19 @@ export type CreateCustomPetFromAtlasInput = {
atlasPath: string
}
/**
* Atlas bytes assembled in the renderer (see petAtlasNormalize) rather than read
* from a file the user picked. The package is validated exactly like a file
* import — nothing is trusted just because it was produced in-process.
*/
export type CreateCustomPetFromAtlasBytesInput = {
slug: string
displayName: string
description: string
atlasData: Uint8Array
mimeType: CustomPetImageMimeType
}
export type CreateCustomPetFromAtlasOptions = CustomPetsRootOptions & {
inspectImageSize?: ImageSizeInspector
}
@@ -1007,10 +1020,51 @@ async function assertCustomPetTargetAvailable(targetPath: string): Promise<void>
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
}
export async function createCustomPetFromAtlas(
input: CreateCustomPetFromAtlasInput,
options: CreateCustomPetFromAtlasOptions = {},
): Promise<LoadedCustomAtlasPet> {
export type CustomPetSourceImage = {
data: Buffer
mimeType: CustomPetImageMimeType
width: number
height: number
}
/**
* Reads an image the user picked so the renderer can normalize it into an atlas.
* Applies the same size, signature and static-image checks as a direct import, so
* a malformed file is rejected before it ever reaches a canvas.
*/
export async function readCustomPetSourceImage(
imagePath: string,
): Promise<CustomPetSourceImage> {
const resolved = path.resolve(imagePath)
const mimeType = mimeTypeForPath(resolved, 'imagePath')
const data = await readBoundedRegularFile({
filePath: resolved,
maxBytes: DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES,
missingCode: 'missing-image',
symlinkCode: 'symlink-image',
tooLargeCode: 'image-too-large',
invalidCode: 'invalid-image',
missingMessage: 'The selected pet image does not exist.',
symlinkMessage: 'The selected pet image cannot be a symlink.',
tooLargeMessage: 'The selected pet image exceeds the allowed size.',
invalidMessage: 'The selected pet image must be a regular file.',
})
if (!hasExpectedImageSignature(data, mimeType)) {
throw new PetPackageError('invalid-image', 'The pet image header is invalid.')
}
if (hasEmbeddedImageAnimation(data, mimeType)) {
throw new PetPackageError('invalid-image', 'The pet image must be a static PNG or WebP.')
}
const { width, height } = inspectPetImageSize({ data, mimeType })
return { data, mimeType, width, height }
}
/** Shared identity checks for every custom pet creation path. */
function validatedCustomPetIdentity(input: {
slug: string
displayName: string
description: string
}): { slug: string, displayName: string, description: string } {
const slug = input.slug.trim()
if (
slug !== input.slug
@@ -1020,22 +1074,30 @@ export async function createCustomPetFromAtlas(
) {
throw new PetPackageError('invalid-id', 'Custom pet ID must be a lowercase kebab-case slug.')
}
const displayName = sanitizedTextField(
input.displayName,
MAX_DISPLAY_NAME_LENGTH,
'invalid-display-name',
'displayName',
)
const description = sanitizedTextField(
input.description,
MAX_DESCRIPTION_LENGTH,
'invalid-description',
'description',
)
return {
slug,
displayName: sanitizedTextField(
input.displayName,
MAX_DISPLAY_NAME_LENGTH,
'invalid-display-name',
'displayName',
),
description: sanitizedTextField(
input.description,
MAX_DESCRIPTION_LENGTH,
'invalid-description',
'description',
),
}
}
export async function createCustomPetFromAtlas(
input: CreateCustomPetFromAtlasInput,
options: CreateCustomPetFromAtlasOptions = {},
): Promise<LoadedCustomAtlasPet> {
const { slug, displayName, description } = validatedCustomPetIdentity(input)
const atlasPath = path.resolve(input.atlasPath)
const mimeType = mimeTypeForPath(atlasPath, 'spritesheetPath')
const extension = mimeType === 'image/png' ? 'png' : 'webp'
const spritesheetPath = `spritesheet.${extension}`
const atlasData = await readBoundedRegularFile({
filePath: atlasPath,
maxBytes: DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES,
@@ -1048,6 +1110,58 @@ export async function createCustomPetFromAtlas(
tooLargeMessage: 'The selected spritesheet image exceeds the allowed size.',
invalidMessage: 'The selected spritesheet image must be a regular file.',
})
return installCustomAtlasPet({ slug, displayName, description, atlasData, mimeType }, options)
}
/**
* Installs an atlas the renderer assembled in memory. Used by the guided flow that
* normalizes a nine-row action sheet into the v2 grid, so authors never have to
* hit 1536x2288 by hand.
*/
export async function createCustomPetFromAtlasBytes(
input: CreateCustomPetFromAtlasBytesInput,
options: CreateCustomPetFromAtlasOptions = {},
): Promise<LoadedCustomAtlasPet> {
const { slug, displayName, description } = validatedCustomPetIdentity(input)
if (input.mimeType !== 'image/png' && input.mimeType !== 'image/webp') {
throw new PetPackageError(
'unsupported-image-format',
'The assembled spritesheet must be a PNG or WebP image.',
)
}
const atlasData = input.atlasData
if (!(atlasData instanceof Uint8Array) || atlasData.byteLength < MIN_CUSTOM_PET_IMAGE_BYTES) {
throw new PetPackageError('invalid-image', 'The assembled spritesheet image is not readable.')
}
if (atlasData.byteLength > DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES) {
throw new PetPackageError(
'image-too-large',
'The assembled spritesheet image exceeds the allowed size.',
)
}
return installCustomAtlasPet(
{ slug, displayName, description, atlasData, mimeType: input.mimeType },
options,
)
}
/**
* Writes an already-validated identity plus atlas bytes into a staging directory,
* re-validates the whole package from disk, then atomically moves it into place.
*/
async function installCustomAtlasPet(
request: {
slug: string
displayName: string
description: string
atlasData: Uint8Array
mimeType: CustomPetImageMimeType
},
options: CreateCustomPetFromAtlasOptions,
): Promise<LoadedCustomAtlasPet> {
const { slug, displayName, description, atlasData } = request
const extension = request.mimeType === 'image/png' ? 'png' : 'webp'
const spritesheetPath = `spritesheet.${extension}`
const root = await ensureCustomPetsRoot(options)
const rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Render the action-sheet reference image shown in the custom pet wizard.
The grid mirrors PET_SOURCE_SHEET_LAYOUT in
desktop/src/features/pets/petAtlasNormalize.ts: authors draw nine rows and the app
expands them into the eleven-row v2 atlas (mirroring the run row, reusing the work
row, and filling the neutral look frame).
Usage:
python3 desktop/scripts/build-pet-sheet-guide.py
"""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
CELL_WIDTH = 192
CELL_HEIGHT = 208
COLUMNS = 8
ROWS = 9
LABEL_WIDTH = 316
HEADER_HEIGHT = 196
FOOTER_HEIGHT = 168
MARGIN_RIGHT = 40
CANVAS_BG = (250, 247, 242, 255)
GRID_LINE = (216, 206, 193, 255)
CELL_ACTIVE = (255, 255, 255, 255)
CELL_UNUSED = (240, 235, 227, 255)
TEXT_PRIMARY = (46, 42, 38, 255)
TEXT_SECONDARY = (107, 99, 90, 255)
TEXT_FAINT = (168, 158, 146, 255)
ACCENT = (181, 86, 58, 255)
SAFE_AREA = (204, 190, 174, 255)
FONT_CANDIDATES = (
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/Library/Fonts/Arial Unicode.ttf",
)
# (row label, frame count, what to draw)
ROWS_ZH = [
("待机", 6, "站着不动,轻微呼吸起伏"),
("向右跑", 8, "完整跑步循环,必须朝右(朝左由程序镜像生成)"),
("挥手", 4, "抬手打招呼"),
("跳跃", 5, "下蹲 → 起跳 → 落地"),
("失败", 8, "沮丧、垂头、叹气"),
("等待", 6, "东张西望、原地踱步"),
("工作", 6, "低头忙碌,敲键盘或摆弄东西"),
("视线 上半圈", 8, "头朝正上方开始,顺时针每格转 22.5°"),
("视线 下半圈", 8, "接着从正下方继续,转回接近正上方"),
]
ROWS_EN = [
("Idle", 6, "Standing still with a gentle breathing bob"),
("Run right", 8, "Full run cycle, must face right (left is mirrored for you)"),
("Wave", 4, "Raise a hand and greet"),
("Jump", 5, "Crouch, leap, land"),
("Fail", 8, "Discouraged, head down, sighing"),
("Wait", 6, "Looking around, shifting in place"),
("Work", 6, "Head down, busy typing or tinkering"),
("Look upper half", 8, "Start facing straight up, turn 22.5° clockwise per cell"),
("Look lower half", 8, "Continue from straight down back toward straight up"),
]
TEXT_ZH = {
"title": "宠物动作表 · 8 列 × 9 行",
"subtitle": "每格 192 × 208 像素,整张 1536 × 1872。透明背景 PNG。尺寸不用完全一样,比例接近即可,导入时会自动缩放对齐。",
"column_hint": "← 同一行内从左到右是连续的动作帧 →",
"footer": [
"· 必须透明背景:白底或彩底会变成桌面上的一个方块。",
"· 角色在每格里居中,脚底大约落在格子偏下位置,四周留一点空隙。",
"· 九行必须是同一个角色、同一种画风、同一个体型,只有动作变化。",
"· 最后两行懒得画就重复待机第一帧:宠物只是不会跟着鼠标转头,其他动作照常。",
],
"unused": "留空",
"safe": "角色画在这里",
"frames": "{count} 帧",
}
TEXT_EN = {
"title": "Pet action sheet - 8 columns x 9 rows",
"subtitle": "192 x 208 px per cell, 1536 x 1872 overall. Transparent PNG. The size does not have to match exactly; a close ratio is fine and the app rescales on import.",
"column_hint": "<- frames run left to right within each row ->",
"footer": [
"- Transparent background is required: a white or coloured backdrop becomes a square on your desktop.",
"- Centre the character in each cell, feet a little below the middle, with a small margin all round.",
"- All nine rows must be the same character, same art style, same proportions - only the pose changes.",
"- Too much work? Repeat the first idle frame for the last two rows: the pet simply will not track your cursor.",
],
"unused": "leave empty",
"safe": "draw here",
"frames": "{count} frames",
}
def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
for candidate in FONT_CANDIDATES:
path = Path(candidate)
if not path.exists():
continue
try:
# Hiragino Sans GB ships W3 (index 0) and W6 (index 1).
return ImageFont.truetype(candidate, size, index=1 if bold else 0)
except OSError:
try:
return ImageFont.truetype(candidate, size)
except OSError:
continue
return ImageFont.load_default()
def draw_dashed_rect(
draw: ImageDraw.ImageDraw,
box: tuple[int, int, int, int],
colour: tuple[int, int, int, int],
dash: int = 8,
gap: int = 7,
) -> None:
left, top, right, bottom = box
for x in range(left, right, dash + gap):
draw.line([(x, top), (min(x + dash, right), top)], fill=colour)
draw.line([(x, bottom), (min(x + dash, right), bottom)], fill=colour)
for y in range(top, bottom, dash + gap):
draw.line([(left, y), (left, min(y + dash, bottom))], fill=colour)
draw.line([(right, y), (right, min(y + dash, bottom))], fill=colour)
def render(rows: list[tuple[str, int, str]], text: dict, output: Path) -> None:
width = LABEL_WIDTH + COLUMNS * CELL_WIDTH + MARGIN_RIGHT
height = HEADER_HEIGHT + ROWS * CELL_HEIGHT + FOOTER_HEIGHT
image = Image.new("RGBA", (width, height), CANVAS_BG)
draw = ImageDraw.Draw(image)
font_title = load_font(46, bold=True)
font_subtitle = load_font(23)
font_row = load_font(31, bold=True)
font_row_note = load_font(20)
font_badge = load_font(21, bold=True)
font_cell = load_font(19)
font_footer = load_font(22)
draw.text((48, 44), text["title"], font=font_title, fill=TEXT_PRIMARY)
subtitle_y = 108
for line in wrap_text(draw, text["subtitle"], font_subtitle, width - 96):
draw.text((48, subtitle_y), line, font=font_subtitle, fill=TEXT_SECONDARY)
subtitle_y += 30
grid_left = LABEL_WIDTH
grid_top = HEADER_HEIGHT
draw.text(
(grid_left, grid_top - 34),
text["column_hint"],
font=font_row_note,
fill=TEXT_FAINT,
)
for row_index, (name, frame_count, note) in enumerate(rows):
top = grid_top + row_index * CELL_HEIGHT
badge_size = 34
draw.ellipse(
[(48, top + 22), (48 + badge_size, top + 22 + badge_size)],
fill=ACCENT,
)
badge_text = str(row_index + 1)
badge_box = draw.textbbox((0, 0), badge_text, font=font_badge)
draw.text(
(
48 + (badge_size - (badge_box[2] - badge_box[0])) / 2 - badge_box[0],
top + 22 + (badge_size - (badge_box[3] - badge_box[1])) / 2 - badge_box[1],
),
badge_text,
font=font_badge,
fill=(255, 255, 255, 255),
)
draw.text((96, top + 20), name, font=font_row, fill=TEXT_PRIMARY)
draw.text(
(96, top + 58),
text["frames"].format(count=frame_count),
font=font_row_note,
fill=ACCENT,
)
note_y = top + 88
for line in wrap_text(draw, note, font_row_note, LABEL_WIDTH - 112):
draw.text((96, note_y), line, font=font_row_note, fill=TEXT_SECONDARY)
note_y += 26
for column in range(COLUMNS):
left = grid_left + column * CELL_WIDTH
box = [(left, top), (left + CELL_WIDTH, top + CELL_HEIGHT)]
used = column < frame_count
draw.rectangle(box, fill=CELL_ACTIVE if used else CELL_UNUSED, outline=GRID_LINE)
if used:
label = f"{column + 1}"
draw.text((left + 10, top + 8), label, font=font_cell, fill=TEXT_FAINT)
if row_index == 0 and column == 0:
inset_x, inset_y = 26, 18
draw_dashed_rect(
draw,
(
left + inset_x,
top + inset_y,
left + CELL_WIDTH - inset_x,
top + CELL_HEIGHT - inset_y,
),
SAFE_AREA,
)
hint_box = draw.textbbox((0, 0), text["safe"], font=font_cell)
draw.text(
(
left + (CELL_WIDTH - (hint_box[2] - hint_box[0])) / 2,
top + CELL_HEIGHT / 2 - 12,
),
text["safe"],
font=font_cell,
fill=SAFE_AREA,
)
else:
hint_box = draw.textbbox((0, 0), text["unused"], font=font_cell)
draw.text(
(
left + (CELL_WIDTH - (hint_box[2] - hint_box[0])) / 2,
top + CELL_HEIGHT / 2 - 12,
),
text["unused"],
font=font_cell,
fill=TEXT_FAINT,
)
footer_y = grid_top + ROWS * CELL_HEIGHT + 28
for line in text["footer"]:
draw.text((48, footer_y), line, font=font_footer, fill=TEXT_SECONDARY)
footer_y += 33
output.parent.mkdir(parents=True, exist_ok=True)
image.convert("RGB").save(output, "PNG", optimize=True)
print(f"wrote {output} ({image.width}x{image.height})")
def wrap_text(
draw: ImageDraw.ImageDraw,
content: str,
font: ImageFont.FreeTypeFont,
max_width: int,
) -> list[str]:
"""Wraps on spaces for latin text and per character for CJK."""
if not content:
return []
words = content.split(" ")
use_words = len(words) > 3
tokens = words if use_words else list(content)
joiner = " " if use_words else ""
lines: list[str] = []
current = ""
for token in tokens:
candidate = f"{current}{joiner}{token}" if current else token
if draw.textlength(candidate, font=font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = token
if current:
lines.append(current)
return lines
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out-dir",
type=Path,
default=Path(__file__).resolve().parent.parent / "src" / "assets" / "pets",
)
args = parser.parse_args()
render(ROWS_ZH, TEXT_ZH, args.out_dir / "action-sheet-guide.zh.png")
render(ROWS_EN, TEXT_EN, args.out_dir / "action-sheet-guide.en.png")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+90 -31
View File
@@ -11,6 +11,8 @@ const {
listPetsMock,
createFromImageMock,
createFromAtlasMock,
importSheetMock,
writeTextMock,
openFolderMock,
showPetMock,
hidePetMock,
@@ -21,12 +23,20 @@ const {
listPetsMock: vi.fn(),
createFromImageMock: vi.fn(),
createFromAtlasMock: vi.fn(),
importSheetMock: vi.fn(),
writeTextMock: vi.fn(),
openFolderMock: vi.fn(),
showPetMock: vi.fn(),
hidePetMock: vi.fn(),
onVisibilityChangedMock: vi.fn(),
}))
// The guided import normalizes the sheet on a real canvas, which jsdom does not
// provide. Canvas behaviour is covered by petAtlasNormalize.test.ts instead.
vi.mock('./petSheetImport', () => ({
importPetFromActionSheet: importSheetMock,
}))
vi.mock('../../api/desktopUiPreferences', async () => {
const actual = await vi.importActual<typeof import('../../api/desktopUiPreferences')>('../../api/desktopUiPreferences')
return {
@@ -42,6 +52,7 @@ vi.mock('../../api/desktopUiPreferences', async () => {
vi.mock('../../lib/desktopHost', () => ({
getDesktopHost: () => ({
isDesktop: true,
clipboard: { writeText: writeTextMock, readText: vi.fn() },
pets: {
list: listPetsMock,
createFromImage: createFromImageMock,
@@ -96,6 +107,9 @@ describe('PetSettings', () => {
listPetsMock.mockReset()
createFromImageMock.mockReset()
createFromAtlasMock.mockReset()
importSheetMock.mockReset()
writeTextMock.mockReset()
writeTextMock.mockResolvedValue(undefined)
openFolderMock.mockReset()
showPetMock.mockReset()
hidePetMock.mockReset()
@@ -297,17 +311,60 @@ describe('PetSettings', () => {
await waitFor(() => expect(openFolderMock).toHaveBeenCalledTimes(1))
})
it('explains the three creation paths without depending on the current chat model', async () => {
it('offers three usable creation paths with no disabled dead end', async () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
expect(screen.getByRole('button', { name: /Animate one image/ })).toBeEnabled()
expect(screen.getByText('Local only · No image model or video generation required')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Import professional animation atlas/ })).toBeEnabled()
expect(screen.getByRole('button', { name: /Generate full animation with AI/ })).toBeDisabled()
expect(screen.getByText(/configure a separate image-generation service first/i)).toBeInTheDocument()
expect(screen.getByText(/current chat model is never used/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Use a picture you already have/ })).toBeEnabled()
expect(screen.getByRole('button', { name: /Draw one with AI that runs and jumps/ })).toBeEnabled()
expect(screen.getByRole('button', { name: /I already have an action sheet/ })).toBeEnabled()
expect(screen.getByText(/none of your chat quota is used/i)).toBeInTheDocument()
})
it('walks the AI path through the prompt and template before asking for a file', async () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /Draw one with AI that runs and jumps/ }))
// Step 1 hands over a prompt the user can paste straight into an image model.
expect(screen.getByText(/Draw me a game character action sheet/)).toBeInTheDocument()
expect(screen.getByText(/8 columns x 9 rows/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Copy this prompt/ }))
await waitFor(() => expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining('Row 2, all 8 cells'),
))
// Step 2 shows the reference grid rather than describing pixel maths.
expect(screen.getByRole('img', { name: /Action sheet template/ })).toBeInTheDocument()
expect(screen.getByText(/a white backdrop becomes a square/i)).toBeInTheDocument()
// The form only appears once the walkthrough is acknowledged.
expect(screen.queryByRole('textbox', { name: 'Pet ID' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Done drawing, next' }))
expect(screen.getByRole('textbox', { name: 'Pet ID' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Back to the steps' })).toBeInTheDocument()
// The form must stay badged as the AI path, not fall through to the "already
// have a sheet" heading.
expect(screen.getByRole('heading', { name: /Draw one with AI that runs and jumps/ }))
.toBeInTheDocument()
})
it('tells the user plainly when the sheet has no transparent background', async () => {
importSheetMock.mockResolvedValueOnce({ status: 'error', errorCode: 'opaque-background' })
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /I already have an action sheet/ }))
fireEvent.change(screen.getByRole('textbox', { name: 'Pet ID' }), { target: { value: 'flat-cat' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Flat Cat' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'Needs alpha.' } })
fireEvent.click(screen.getByRole('button', { name: 'Pick an action sheet' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
/no transparent background, so the pet would show up as a square/i,
)
})
it('creates a lightweight animated pet from one local image and selects it', async () => {
@@ -329,29 +386,29 @@ describe('PetSettings', () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /Animate one image/ }))
expect(screen.getByText(/lightweight breathing, floating, and task-state motion/i)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Use a picture you already have/ }))
expect(screen.getByText(/only sways gently/i)).toBeInTheDocument()
fireEvent.change(screen.getByRole('textbox', { name: 'Pet ID' }), { target: { value: 'orbit-fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Orbit Fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), {
target: { value: 'A bright local companion.' },
})
fireEvent.click(screen.getByRole('button', { name: 'Choose image and create' }))
fireEvent.click(screen.getByRole('button', { name: 'Pick a picture' }))
await waitFor(() => expect(createFromImageMock).toHaveBeenCalledWith({
slug: 'orbit-fox',
displayName: 'Orbit Fox',
description: 'A bright local companion.',
dialogTitle: 'Choose a transparent pet image',
dialogTitle: 'Choose a pet image with a transparent background',
dialogFilterName: 'Pet image',
}))
expect(createFromAtlasMock).not.toHaveBeenCalled()
expect(importSheetMock).not.toHaveBeenCalled()
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ selectedPetId: 'custom:orbit-fox' })
expect(await screen.findByText('Orbit Fox')).toBeInTheDocument()
})
it('creates a validated atlas package in app storage and selects it', async () => {
createFromAtlasMock.mockResolvedValueOnce({ id: 'custom:orbit-fox' })
importSheetMock.mockResolvedValueOnce({ status: 'created', id: 'custom:orbit-fox' })
listPetsMock.mockResolvedValueOnce({
pets: [{
id: 'custom:moon-cat',
@@ -378,45 +435,47 @@ describe('PetSettings', () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /Import professional animation atlas/ }))
fireEvent.click(screen.getByRole('button', { name: /I already have an action sheet/ }))
fireEvent.change(screen.getByRole('textbox', { name: 'Pet ID' }), { target: { value: 'orbit-fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Orbit Fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), {
target: { value: 'A bright local companion.' },
})
fireEvent.click(screen.getByRole('button', { name: 'Choose atlas and create' }))
fireEvent.click(screen.getByRole('button', { name: 'Pick an action sheet' }))
await waitFor(() => expect(createFromAtlasMock).toHaveBeenCalledWith({
await waitFor(() => expect(importSheetMock).toHaveBeenCalledWith(expect.anything(), {
slug: 'orbit-fox',
displayName: 'Orbit Fox',
description: 'A bright local companion.',
dialogTitle: 'Choose a v2 pet animation atlas',
dialogFilterName: 'Pet animation atlas',
dialogTitle: 'Choose a pet action sheet',
dialogFilterName: 'Pet action sheet',
}))
expect(updatePetPreferencesMock).toHaveBeenCalledWith({ selectedPetId: 'custom:orbit-fox' })
expect(await screen.findByText('Orbit Fox')).toBeInTheDocument()
})
it('keeps the create dialog open and localizes validation failures', async () => {
createFromAtlasMock.mockResolvedValueOnce({ errorCode: 'invalid-image-dimensions' })
importSheetMock.mockResolvedValueOnce({ status: 'error', errorCode: 'invalid-image-dimensions' })
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /Import professional animation atlas/ }))
fireEvent.click(screen.getByRole('button', { name: /I already have an action sheet/ }))
fireEvent.change(screen.getByRole('textbox', { name: 'Pet ID' }), { target: { value: 'bad-atlas' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Bad Atlas' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'Needs repair.' } })
fireEvent.click(screen.getByRole('button', { name: 'Choose atlas and create' }))
fireEvent.click(screen.getByRole('button', { name: 'Pick an action sheet' }))
expect(await screen.findByRole('alert')).toHaveTextContent('The animation atlas must be exactly 1536×2288 pixels.')
expect(screen.getByRole('dialog', { name: 'Create a custom pet' })).toBeInTheDocument()
expect(await screen.findByRole('alert')).toHaveTextContent(
/cannot be sliced into 8 columns by 9 rows/i,
)
expect(screen.getByRole('dialog', { name: 'Make your own pet' })).toBeInTheDocument()
expect(updatePetPreferencesMock).not.toHaveBeenCalledWith(expect.objectContaining({
selectedPetId: 'custom:bad-atlas',
}))
})
it('keeps an installed pet visible when selecting it cannot be persisted', async () => {
createFromAtlasMock.mockResolvedValueOnce({ id: 'custom:orbit-fox' })
importSheetMock.mockResolvedValueOnce({ status: 'created', id: 'custom:orbit-fox' })
listPetsMock.mockResolvedValueOnce({ pets: [], errors: [] }).mockResolvedValueOnce({
pets: [{
id: 'custom:orbit-fox',
@@ -433,16 +492,16 @@ describe('PetSettings', () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Add pet' }))
fireEvent.click(screen.getByRole('button', { name: /Import professional animation atlas/ }))
fireEvent.click(screen.getByRole('button', { name: /I already have an action sheet/ }))
fireEvent.change(screen.getByRole('textbox', { name: 'Pet ID' }), { target: { value: 'orbit-fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Display name' }), { target: { value: 'Orbit Fox' } })
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), {
target: { value: 'A bright local companion.' },
})
fireEvent.click(screen.getByRole('button', { name: 'Choose atlas and create' }))
fireEvent.click(screen.getByRole('button', { name: 'Pick an action sheet' }))
expect(await screen.findByText('Orbit Fox')).toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Create a custom pet' })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'Make your own pet' })).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('Pet preferences could not be saved.')
})
@@ -463,18 +522,18 @@ describe('PetSettings', () => {
render(<PetSettings />)
fireEvent.click(await screen.findByRole('button', { name: '添加宠物' }))
fireEvent.click(screen.getByRole('button', { name: /用一张图片制作轻动画/ }))
fireEvent.click(screen.getByRole('button', { name: /用一张现成的图/ }))
fireEvent.change(screen.getByRole('textbox', { name: '宠物 ID' }), { target: { value: 'bad-image' } })
fireEvent.change(screen.getByRole('textbox', { name: '显示名称' }), { target: { value: 'Bad Image' } })
fireEvent.change(screen.getByRole('textbox', { name: '宠物描述' }), { target: { value: 'Needs repair.' } })
fireEvent.click(screen.getByRole('button', { name: '选择图片并创建' }))
fireEvent.click(screen.getByRole('button', { name: '选图片,做宠物' }))
await waitFor(() => expect(createFromImageMock).toHaveBeenCalledWith(expect.objectContaining({
dialogTitle: '选择透明背景的宠物图片',
dialogTitle: '选一张背景透明的宠物图片',
dialogFilterName: '宠物图片',
})))
expect(await screen.findByRole('alert')).toHaveTextContent(
'宠物图片每边必须为 32–4096 像素,且总像素数不能超过 16,777,216。',
'图片长宽都要在 32 到 4096 像素之间,总像素数不超过 16,777,216。',
)
})
})
+227 -19
View File
@@ -1,9 +1,23 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { ArrowLeft, FolderOpen, Grid3X3, ImageIcon, Plus, RefreshCw, Sparkles } from 'lucide-react'
import {
ArrowLeft,
Check,
Copy,
Download,
FolderOpen,
Grid3X3,
ImageIcon,
Plus,
RefreshCw,
Sparkles,
} from 'lucide-react'
import {
desktopUiPreferencesApi,
type DesktopPetPreferences,
} from '../../api/desktopUiPreferences'
import actionSheetGuideEn from '../../assets/pets/action-sheet-guide.en.png'
import actionSheetGuideZh from '../../assets/pets/action-sheet-guide.zh.png'
import { useSettingsStore } from '../../stores/settingsStore'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
@@ -16,12 +30,18 @@ import { useTranslation, type TranslationKey } from '../../i18n'
import { getDesktopHost } from '../../lib/desktopHost'
import { BUILTIN_PETS } from './builtinPets'
import { PetRenderer } from './PetRenderer'
import { importPetFromActionSheet } from './petSheetImport'
import type { CustomPet, PetDescriptor } from './types'
const PET_SIZE_MIN = 96
const PET_SIZE_MAX = 192
const PET_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
type PetCreationMethod = 'image' | 'atlas'
/**
* `guided` and `atlas` share the same import pipeline; they differ only in whether
* the walkthrough is shown first.
*/
type PetCreationMethod = 'image' | 'guided' | 'atlas'
const PET_CREATE_ERROR_KEYS: Record<string, TranslationKey> = {
'invalid-id': 'settings.pets.createError.invalidId',
@@ -31,6 +51,7 @@ const PET_CREATE_ERROR_KEYS: Record<string, TranslationKey> = {
'total-image-bytes-exceeded': 'settings.pets.createError.imageTooLarge',
'decode-budget-exceeded': 'settings.pets.createError.imageTooLarge',
'invalid-image': 'settings.pets.createError.invalidImage',
'opaque-background': 'settings.pets.createError.opaqueBackground',
'missing-image': 'settings.pets.createError.invalidImage',
'symlink-image': 'settings.pets.createError.invalidImage',
'invalid-renderer': 'settings.pets.createError.invalidImage',
@@ -67,6 +88,13 @@ export function PetSettings() {
const [createError, setCreateError] = useState<string | null>(null)
const [createMethod, setCreateMethod] = useState<PetCreationMethod | null>(null)
const [createForm, setCreateForm] = useState({ slug: '', displayName: '', description: '' })
const [walkthroughVisible, setWalkthroughVisible] = useState(false)
const [promptCopied, setPromptCopied] = useState(false)
const [guideExpanded, setGuideExpanded] = useState(false)
const locale = useSettingsStore((s) => s.locale)
const actionSheetGuide = locale === 'zh' || locale === 'zh-TW'
? actionSheetGuideZh
: actionSheetGuideEn
const load = useCallback(async () => {
setLoading(true)
@@ -208,6 +236,40 @@ export function PetSettings() {
setCreateMethod(null)
setCreateError(null)
setCreateForm({ slug: '', displayName: '', description: '' })
setWalkthroughVisible(false)
setPromptCopied(false)
setGuideExpanded(false)
}
const selectCreateMethod = (method: PetCreationMethod) => {
setCreateMethod(method)
setCreateError(null)
setWalkthroughVisible(method === 'guided')
}
const handleCopyPrompt = async () => {
try {
await getDesktopHost().clipboard.writeText(t('settings.pets.guide.prompt'))
setPromptCopied(true)
window.setTimeout(() => setPromptCopied(false), 2000)
} catch {
setCreateError(t('settings.pets.guide.copyFailed'))
}
}
const handleSaveGuide = async () => {
try {
const response = await fetch(actionSheetGuide)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'pet-action-sheet-guide.png'
link.click()
URL.revokeObjectURL(url)
} catch {
setCreateError(t('settings.pets.guide.saveFailed'))
}
}
const handleCreate = async () => {
@@ -230,9 +292,17 @@ export function PetSettings() {
? t('settings.pets.dialog.imageFilter')
: t('settings.pets.dialog.atlasFilter'),
}
created = createMethod === 'image'
? await host.pets.createFromImage(input)
: await host.pets.createFromAtlas(input)
if (createMethod === 'image') {
created = await host.pets.createFromImage(input)
} else {
// Normalizes an action sheet of any size into the v2 grid before install.
const outcome = await importPetFromActionSheet(host, input)
created = outcome.status === 'cancelled'
? null
: outcome.status === 'created'
? { id: outcome.id }
: { errorCode: outcome.errorCode }
}
} catch {
setCreateError(t('settings.pets.createError'))
setCreateBusy(false)
@@ -420,6 +490,7 @@ export function PetSettings() {
<Modal
open={createOpen}
width={640}
title={t('settings.pets.createTitle')}
onClose={() => {
if (!createBusy) resetCreateDialog()
@@ -429,7 +500,11 @@ export function PetSettings() {
<Button variant="secondary" disabled={createBusy} onClick={resetCreateDialog}>
{t('settings.pets.createCancel')}
</Button>
{createMethod && (
{walkthroughVisible ? (
<Button onClick={() => setWalkthroughVisible(false)}>
{t('settings.pets.guide.continue')}
</Button>
) : createMethod && (
<Button
loading={createBusy}
disabled={!createFormValid}
@@ -453,24 +528,123 @@ export function PetSettings() {
title={t('settings.pets.createImageTitle')}
description={t('settings.pets.createImageDescription')}
detail={t('settings.pets.createImageDetail')}
badge={t('settings.pets.createImageBadge')}
onClick={() => selectCreateMethod('image')}
/>
<CreationMethodCard
icon={<Sparkles size={20} aria-hidden="true" />}
title={t('settings.pets.createAiTitle')}
description={t('settings.pets.createAiDescription')}
detail={t('settings.pets.createAiDetail')}
badge={t('settings.pets.createRecommended')}
onClick={() => setCreateMethod('image')}
onClick={() => selectCreateMethod('guided')}
/>
<CreationMethodCard
icon={<Grid3X3 size={20} aria-hidden="true" />}
title={t('settings.pets.createAtlasTitle')}
description={t('settings.pets.createAtlasDescription')}
detail={t('settings.pets.createAtlasDetail')}
onClick={() => setCreateMethod('atlas')}
/>
<CreationMethodCard
icon={<Sparkles size={20} aria-hidden="true" />}
title={t('settings.pets.createAiTitle')}
description={t('settings.pets.createAiDescription')}
detail={t('settings.pets.createAiUnavailable')}
disabled
onClick={() => selectCreateMethod('atlas')}
/>
</div>
) : walkthroughVisible ? (
<div className="space-y-4">
<Button
variant="link"
size="sm"
icon={<ArrowLeft size={15} aria-hidden="true" />}
onClick={() => {
setCreateMethod(null)
setCreateError(null)
setWalkthroughVisible(false)
}}
>
{t('settings.pets.createBack')}
</Button>
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.pets.guide.intro')}
</p>
<GuideStep index={1} title={t('settings.pets.guide.step1Title')}>
<p className="text-xs leading-5 text-[var(--color-text-secondary)]">
{t('settings.pets.guide.step1Body')}
</p>
<div className="mt-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap px-3 py-2.5 text-[11.5px] leading-5 text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-mono, ui-monospace, monospace)' }}>
{t('settings.pets.guide.prompt')}
</pre>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
icon={promptCopied
? <Check size={14} aria-hidden="true" />
: <Copy size={14} aria-hidden="true" />}
onClick={() => void handleCopyPrompt()}
>
{promptCopied
? t('settings.pets.guide.promptCopied')
: t('settings.pets.guide.promptCopy')}
</Button>
</div>
<p className="mt-2 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{t('settings.pets.guide.step1Tools')}
</p>
</GuideStep>
<GuideStep index={2} title={t('settings.pets.guide.step2Title')}>
<p className="text-xs leading-5 text-[var(--color-text-secondary)]">
{t('settings.pets.guide.step2Body')}
</p>
<ul className="mt-2 space-y-1">
{([
'settings.pets.guide.check1',
'settings.pets.guide.check2',
'settings.pets.guide.check3',
] as const).map((key) => (
<li key={key} className="flex items-start gap-1.5 text-xs leading-5 text-[var(--color-text-secondary)]">
<Check size={13} className="mt-0.5 flex-none text-[var(--color-brand)]" aria-hidden="true" />
<span>{t(key)}</span>
</li>
))}
</ul>
<button
type="button"
className="mt-2.5 block w-full overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)]"
onClick={() => setGuideExpanded((current) => !current)}
aria-label={t('settings.pets.guide.templateAlt')}
>
<img
src={actionSheetGuide}
alt={t('settings.pets.guide.templateAlt')}
className={guideExpanded ? 'w-full' : 'h-40 w-full object-cover object-top'}
/>
</button>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
icon={<Download size={14} aria-hidden="true" />}
onClick={() => void handleSaveGuide()}
>
{t('settings.pets.guide.saveTemplate')}
</Button>
<span className="text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{guideExpanded
? t('settings.pets.guide.templateCollapse')
: t('settings.pets.guide.templateExpand')}
</span>
</div>
</GuideStep>
<GuideStep index={3} title={t('settings.pets.guide.step3Title')}>
<p className="text-xs leading-5 text-[var(--color-text-secondary)]">
{t('settings.pets.guide.step3Body')}
</p>
</GuideStep>
</div>
) : (
<div className="space-y-4">
<Button
@@ -479,17 +653,24 @@ export function PetSettings() {
icon={<ArrowLeft size={15} aria-hidden="true" />}
disabled={createBusy}
onClick={() => {
if (createMethod === 'guided') {
setWalkthroughVisible(true)
setCreateError(null)
return
}
setCreateMethod(null)
setCreateError(null)
}}
>
{t('settings.pets.createBack')}
{createMethod === 'guided'
? t('settings.pets.guide.backToSteps')
: t('settings.pets.createBack')}
</Button>
<div className="rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] px-3.5 py-3">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{createMethod === 'image'
? t('settings.pets.createImageTitle')
: t('settings.pets.createAtlasTitle')}
{createMethod === 'image' ? t('settings.pets.createImageTitle')
: createMethod === 'guided' ? t('settings.pets.createAiTitle')
: t('settings.pets.createAtlasTitle')}
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)]">
{createMethod === 'image'
@@ -537,6 +718,33 @@ export function PetSettings() {
)
}
function GuideStep({
index,
title,
children,
}: {
index: number
title: string
children: ReactNode
}) {
return (
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3.5">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[var(--color-brand)] text-[11px] font-semibold text-[var(--color-on-primary)]">
{index}
</span>
<h4
className="text-sm font-semibold text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{title}
</h4>
</div>
<div className="mt-2">{children}</div>
</section>
)
}
function CreationMethodCard({
icon,
title,
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import { PET_ATLAS_V2 } from './petAnimation'
import {
PET_ATLAS_ROW_FRAME_COUNTS,
PET_SOURCE_SHEET_COLUMNS,
PET_SOURCE_SHEET_LAYOUT,
PET_SOURCE_SHEET_ROWS,
PET_SOURCE_SHEET_SIZE,
detectPetSheetShape,
getAlphaBounds,
getPetAtlasRowPlan,
getPetCellPlacement,
getPetSilhouetteShiftX,
getPetSourceCellRect,
} from './petAtlasNormalize'
/**
* The reference values below come from desktop/scripts/assemble-generated-pet-atlas.py,
* which produced the four built-in pets. Keeping them in a test means a change to the
* animation definitions surfaces here instead of silently breaking imported pets.
*/
const PYTHON_FRAME_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]
const PYTHON_NINE_ROW_MAPPING = [0, 1, 1, 2, 3, 4, 5, 6, 6, 7, 8]
describe('pet atlas normalization', () => {
it('derives row frame counts that match the reference assembler', () => {
expect([...PET_ATLAS_ROW_FRAME_COUNTS]).toEqual(PYTHON_FRAME_COUNTS)
expect(PET_ATLAS_ROW_FRAME_COUNTS).toHaveLength(PET_ATLAS_V2.rows)
})
it('expands nine authored rows into the eleven atlas rows, mirroring only the left run', () => {
const plan = getPetAtlasRowPlan(PET_SOURCE_SHEET_ROWS)
expect(plan.map(row => row.sourceRow)).toEqual(PYTHON_NINE_ROW_MAPPING)
expect(plan.filter(row => row.mirrored).map(row => row.atlasRow)).toEqual([2])
expect(plan.map(row => row.frameCount)).toEqual(PYTHON_FRAME_COUNTS)
})
it('passes an eleven-row sheet straight through without mirroring', () => {
const plan = getPetAtlasRowPlan(PET_ATLAS_V2.rows)
expect(plan.map(row => row.sourceRow)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
expect(plan.some(row => row.mirrored)).toBe(false)
})
it('rejects sheets that are neither authored nor finished', () => {
expect(() => getPetAtlasRowPlan(10)).toThrow(RangeError)
expect(() => getPetAtlasRowPlan(0)).toThrow(RangeError)
})
it('describes the nine rows an author has to draw', () => {
expect(PET_SOURCE_SHEET_LAYOUT.map(row => row.key)).toEqual([
'idle', 'run', 'wave', 'jump', 'fail', 'wait', 'work', 'look-upper', 'look-lower',
])
// The run row feeds both run-right and run-left, so it needs the wider count.
expect(PET_SOURCE_SHEET_LAYOUT.map(row => row.frameCount)).toEqual([6, 8, 4, 5, 8, 6, 6, 8, 8])
expect(PET_SOURCE_SHEET_SIZE).toEqual({ width: 1536, height: 1872 })
})
describe('slicing the source grid', () => {
it('produces seamless cells that cover the whole sheet', () => {
const width = 1024
const height = 1152
for (let row = 0; row < PET_SOURCE_SHEET_ROWS; row += 1) {
let cursor = 0
for (let column = 0; column < PET_SOURCE_SHEET_COLUMNS; column += 1) {
const rect = getPetSourceCellRect(width, height, PET_SOURCE_SHEET_ROWS, row, column)
expect(rect.x).toBe(cursor)
cursor += rect.width
}
expect(cursor).toBe(width)
}
const lastRow = getPetSourceCellRect(width, height, PET_SOURCE_SHEET_ROWS, 8, 0)
expect(lastRow.y + lastRow.height).toBe(height)
})
it('handles sheets whose size is not a multiple of the grid', () => {
const rect = getPetSourceCellRect(1000, 1000, PET_SOURCE_SHEET_ROWS, 4, 3)
expect(rect).toEqual({ x: 375, y: 444, width: 125, height: 112 })
})
})
describe('fitting a cell', () => {
it('scales to fit and centres inside the 192x208 cell', () => {
expect(getPetCellPlacement(500, 500, 0)).toEqual({ width: 192, height: 192, x: 0, y: 8 })
})
it('keeps the artwork inside the gutter', () => {
expect(getPetCellPlacement(500, 500, 8)).toEqual({ width: 176, height: 176, x: 8, y: 16 })
})
it('never collapses a sliver to zero', () => {
const placement = getPetCellPlacement(4000, 1, 0)
expect(placement.width).toBeGreaterThanOrEqual(1)
expect(placement.height).toBeGreaterThanOrEqual(1)
})
})
describe('recentring the silhouette', () => {
it('pushes an off-centre silhouette toward the middle', () => {
expect(getPetSilhouetteShiftX({ left: 0, right: 50 }, 0)).toBe(71)
expect(getPetSilhouetteShiftX({ left: 150, right: 192 }, 0)).toBe(-75)
})
it('leaves a centred silhouette alone', () => {
expect(getPetSilhouetteShiftX({ left: 71, right: 121 }, 0)).toBe(0)
})
it('never shifts artwork out past the gutter', () => {
const shift = getPetSilhouetteShiftX({ left: 0, right: 20 }, 8)
expect(shift).toBeLessThanOrEqual(PET_ATLAS_V2.cellWidth - 8 - 20)
expect(0 + shift).toBeGreaterThanOrEqual(8)
})
it('ignores a fully transparent frame', () => {
expect(getPetSilhouetteShiftX(null, 0)).toBe(0)
})
})
describe('alpha bounds', () => {
it('finds the box around visible pixels with an exclusive far edge', () => {
const width = 4
const height = 3
const data = new Uint8ClampedArray(width * height * 4)
const setOpaque = (x: number, y: number) => {
data[(y * width + x) * 4 + 3] = 255
}
setOpaque(1, 1)
setOpaque(2, 2)
expect(getAlphaBounds(data, width, height)).toEqual({ left: 1, top: 1, right: 3, bottom: 3 })
})
it('returns null when nothing is visible', () => {
expect(getAlphaBounds(new Uint8ClampedArray(4 * 4), 2, 2)).toBeNull()
})
})
describe('detecting the sheet shape', () => {
it('treats an exact v2 atlas as finished artwork', () => {
expect(detectPetSheetShape(PET_ATLAS_V2.width, PET_ATLAS_V2.height)).toEqual({
sourceRows: PET_ATLAS_V2.rows,
passthrough: true,
})
})
it('treats everything else as a nine-row action sheet', () => {
for (const [width, height] of [[1024, 1152], [1536, 1872], [800, 900], [1536, 2287]]) {
expect(detectPetSheetShape(width!, height!)).toEqual({
sourceRows: PET_SOURCE_SHEET_ROWS,
passthrough: false,
})
}
})
})
})
@@ -0,0 +1,310 @@
import {
PET_ANIMATION_DEFINITIONS,
PET_ANIMATION_STATES,
PET_ATLAS_V2,
PET_LOOK_DIRECTIONS,
PET_NEUTRAL_LOOK_FRAME,
} from './petAnimation'
/**
* Turns a hand-drawn or image-generated action sheet into the exact v2 atlas the
* renderer expects. Authors only have to supply the nine distinct action rows;
* the mirrored run row, the reused review row and the neutral look frame are all
* derived here. Mirrors desktop/scripts/assemble-generated-pet-atlas.py.
*/
/** Frames the renderer actually samples per atlas row, derived from the playback definitions. */
export const PET_ATLAS_ROW_FRAME_COUNTS: readonly number[] = (() => {
const counts = new Array<number>(PET_ATLAS_V2.rows).fill(0)
for (const state of PET_ANIMATION_STATES) {
const definition = PET_ANIMATION_DEFINITIONS[state]
counts[definition.rowIndex] = definition.frameDurationsMs.length
}
const lookRowCount = PET_LOOK_DIRECTIONS.length / PET_ATLAS_V2.columns
for (let offset = 0; offset < lookRowCount; offset += 1) {
counts[PET_ATLAS_V2.rows - lookRowCount + offset] = PET_ATLAS_V2.columns
}
return counts
})()
export type PetSourceSheetRow = Readonly<{
/** Row index inside the author's source sheet. */
sourceRow: number
/** Stable key used for i18n lookups and template labels. */
key: 'idle' | 'run' | 'wave' | 'jump' | 'fail' | 'wait' | 'work' | 'look-upper' | 'look-lower'
/** Frames the author must draw for this row. */
frameCount: number
}>
/**
* Atlas row -> source row. Row 2 re-uses the run row mirrored, row 8 (review)
* re-uses the work row, so the author draws nine rows instead of eleven.
*/
const NINE_ROW_MAPPING = [0, 1, 1, 2, 3, 4, 5, 6, 6, 7, 8] as const
const SOURCE_ROW_KEYS = [
'idle',
'run',
'wave',
'jump',
'fail',
'wait',
'work',
'look-upper',
'look-lower',
] as const satisfies readonly PetSourceSheetRow['key'][]
export const PET_SOURCE_SHEET_ROWS = SOURCE_ROW_KEYS.length
export const PET_SOURCE_SHEET_COLUMNS = PET_ATLAS_V2.columns
/** Per-row drawing brief handed to the author (and rendered into the template image). */
export const PET_SOURCE_SHEET_LAYOUT: readonly PetSourceSheetRow[] = SOURCE_ROW_KEYS.map(
(key, sourceRow) => ({
sourceRow,
key,
frameCount: NINE_ROW_MAPPING.reduce<number>(
(widest, mappedSourceRow, atlasRow) => (
mappedSourceRow === sourceRow
? Math.max(widest, PET_ATLAS_ROW_FRAME_COUNTS[atlasRow] ?? 0)
: widest
),
0,
),
}),
)
export type PetAtlasRowPlan = Readonly<{
atlasRow: number
sourceRow: number
mirrored: boolean
frameCount: number
}>
/**
* Builds the atlas assembly plan. An eleven-row sheet is copied straight through;
* a nine-row sheet is expanded via NINE_ROW_MAPPING with the left-run row mirrored.
*/
export function getPetAtlasRowPlan(sourceRows: number): readonly PetAtlasRowPlan[] {
if (sourceRows !== PET_SOURCE_SHEET_ROWS && sourceRows !== PET_ATLAS_V2.rows) {
throw new RangeError(`sourceRows must be ${PET_SOURCE_SHEET_ROWS} or ${PET_ATLAS_V2.rows}`)
}
const passthrough = sourceRows === PET_ATLAS_V2.rows
return NINE_ROW_MAPPING.map((mappedSourceRow, atlasRow) => ({
atlasRow,
sourceRow: passthrough ? atlasRow : mappedSourceRow,
mirrored: !passthrough && atlasRow === PET_ANIMATION_DEFINITIONS['running-left'].rowIndex,
frameCount: PET_ATLAS_ROW_FRAME_COUNTS[atlasRow] ?? 0,
}))
}
export type PetSourceCellRect = Readonly<{
x: number
y: number
width: number
height: number
}>
/**
* Slices the source sheet on an even grid. Rounding each edge independently (rather
* than multiplying a rounded cell size) keeps cells seamless on sheets whose
* dimensions are not a clean multiple of the grid.
*/
export function getPetSourceCellRect(
sourceWidth: number,
sourceHeight: number,
sourceRows: number,
row: number,
column: number,
): PetSourceCellRect {
const left = Math.round((column * sourceWidth) / PET_SOURCE_SHEET_COLUMNS)
const right = Math.round(((column + 1) * sourceWidth) / PET_SOURCE_SHEET_COLUMNS)
const top = Math.round((row * sourceHeight) / sourceRows)
const bottom = Math.round(((row + 1) * sourceHeight) / sourceRows)
return { x: left, y: top, width: right - left, height: bottom - top }
}
export type PetCellPlacement = Readonly<{
width: number
height: number
x: number
y: number
}>
/** Fits a source cell inside a 192x208 atlas cell, preserving aspect ratio and centring it. */
export function getPetCellPlacement(
cellWidth: number,
cellHeight: number,
gutter: number,
): PetCellPlacement {
const availableWidth = PET_ATLAS_V2.cellWidth - gutter * 2
const availableHeight = PET_ATLAS_V2.cellHeight - gutter * 2
const scale = Math.min(availableWidth / cellWidth, availableHeight / cellHeight)
const width = Math.max(1, Math.round(cellWidth * scale))
const height = Math.max(1, Math.round(cellHeight * scale))
return {
width,
height,
x: Math.floor((PET_ATLAS_V2.cellWidth - width) / 2),
y: Math.floor((PET_ATLAS_V2.cellHeight - height) / 2),
}
}
/**
* Horizontal shift that centres the visible silhouette, clamped so the artwork can
* never leave the gutter. Generated art rarely sits on the cell centre, and an
* off-centre silhouette makes the sprite jitter as frames advance.
*/
export function getPetSilhouetteShiftX(
alphaBounds: Readonly<{ left: number, right: number }> | null,
gutter: number,
): number {
if (!alphaBounds) return 0
const { left, right } = alphaBounds
const desiredShift = Math.round(PET_ATLAS_V2.cellWidth / 2 - (left + right) / 2)
const minShift = gutter - left
const maxShift = PET_ATLAS_V2.cellWidth - gutter - right
return Math.max(minShift, Math.min(maxShift, desiredShift))
}
/** Bounding box of non-transparent pixels; `right`/`bottom` are exclusive. */
export function getAlphaBounds(
data: Uint8ClampedArray,
width: number,
height: number,
): { left: number, top: number, right: number, bottom: number } | null {
let left = width
let top = height
let right = 0
let bottom = 0
let found = false
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (data[(y * width + x) * 4 + 3] === 0) continue
found = true
if (x < left) left = x
if (x >= right) right = x + 1
if (y < top) top = y
if (y >= bottom) bottom = y + 1
}
}
return found ? { left, top, right, bottom } : null
}
export type PetAtlasNormalizeOptions = Readonly<{
/** Rows in the supplied sheet: 9 (authored) or 11 (already expanded). */
sourceRows?: number
/** Inset every cell by this many pixels so strokes near the edge are not clipped. */
gutter?: number
/** Re-centre each silhouette horizontally after fitting. */
recenterX?: boolean
}>
export const PET_ATLAS_MAX_GUTTER = 32
function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas
}
function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) throw new Error('Could not acquire a 2D canvas context')
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
return ctx
}
/**
* Assembles the finished 1536x2288 atlas. The caller supplies an already-decoded
* image so this stays synchronous and testable.
*/
export function normalizePetSheetToAtlas(
source: CanvasImageSource,
sourceWidth: number,
sourceHeight: number,
options: PetAtlasNormalizeOptions = {},
): HTMLCanvasElement {
const sourceRows = options.sourceRows ?? PET_SOURCE_SHEET_ROWS
const gutter = Math.max(0, Math.min(PET_ATLAS_MAX_GUTTER, Math.trunc(options.gutter ?? 0)))
const recenterX = options.recenterX ?? true
const atlas = createCanvas(PET_ATLAS_V2.width, PET_ATLAS_V2.height)
const atlasCtx = context2d(atlas)
const cell = createCanvas(PET_ATLAS_V2.cellWidth, PET_ATLAS_V2.cellHeight)
const cellCtx = context2d(cell)
const renderCell = (row: number, column: number, mirrored: boolean): number => {
cellCtx.clearRect(0, 0, PET_ATLAS_V2.cellWidth, PET_ATLAS_V2.cellHeight)
const rect = getPetSourceCellRect(sourceWidth, sourceHeight, sourceRows, row, column)
if (rect.width <= 0 || rect.height <= 0) return 0
const placement = getPetCellPlacement(rect.width, rect.height, gutter)
cellCtx.save()
if (mirrored) {
cellCtx.translate(placement.x * 2 + placement.width, 0)
cellCtx.scale(-1, 1)
}
cellCtx.drawImage(
source,
rect.x, rect.y, rect.width, rect.height,
placement.x, placement.y, placement.width, placement.height,
)
cellCtx.restore()
if (!recenterX) return 0
const { data } = cellCtx.getImageData(0, 0, PET_ATLAS_V2.cellWidth, PET_ATLAS_V2.cellHeight)
const bounds = getAlphaBounds(data, PET_ATLAS_V2.cellWidth, PET_ATLAS_V2.cellHeight)
return getPetSilhouetteShiftX(bounds, gutter)
}
for (const plan of getPetAtlasRowPlan(sourceRows)) {
for (let column = 0; column < plan.frameCount; column += 1) {
const shiftX = renderCell(plan.sourceRow, column, plan.mirrored)
atlasCtx.drawImage(
cell,
column * PET_ATLAS_V2.cellWidth + shiftX,
plan.atlasRow * PET_ATLAS_V2.cellHeight,
)
}
}
// The renderer parks the pet on row 0 / column 6 whenever the pointer is away.
const neutralShiftX = renderCell(0, 0, false)
atlasCtx.drawImage(cell, PET_NEUTRAL_LOOK_FRAME.x + neutralShiftX, PET_NEUTRAL_LOOK_FRAME.y)
return atlas
}
export type PetAtlasSourceShape = Readonly<{
sourceRows: number
/** True when the sheet already carries all eleven atlas rows. */
passthrough: boolean
}>
/**
* Guesses whether a sheet holds nine authored rows or eleven finished ones.
* An exact 1536x2288 sheet is already a valid atlas, so it is passed through
* untouched — re-slicing it would resample finished artwork for nothing.
* Everything else is treated as a nine-row action sheet, which is what the
* documented workflow produces.
*/
export function detectPetSheetShape(width: number, height: number): PetAtlasSourceShape {
if (width === PET_ATLAS_V2.width && height === PET_ATLAS_V2.height) {
return { sourceRows: PET_ATLAS_V2.rows, passthrough: true }
}
return { sourceRows: PET_SOURCE_SHEET_ROWS, passthrough: false }
}
/** Aspect ratio the authored nine-row sheet should aim for (8x192 by 9x208). */
export const PET_SOURCE_SHEET_ASPECT_RATIO =
(PET_SOURCE_SHEET_COLUMNS * PET_ATLAS_V2.cellWidth)
/ (PET_SOURCE_SHEET_ROWS * PET_ATLAS_V2.cellHeight)
/** Suggested pixel size for the authored sheet, matching the atlas cell grid exactly. */
export const PET_SOURCE_SHEET_SIZE = {
width: PET_SOURCE_SHEET_COLUMNS * PET_ATLAS_V2.cellWidth,
height: PET_SOURCE_SHEET_ROWS * PET_ATLAS_V2.cellHeight,
} as const
+148
View File
@@ -0,0 +1,148 @@
import type { DesktopHost } from '../../lib/desktopHost/types'
import { detectPetSheetShape, normalizePetSheetToAtlas } from './petAtlasNormalize'
/**
* Guided import: the user picks a plain action sheet, we normalize it into the v2
* atlas grid, then hand the finished bytes back to the main process. Authors never
* have to produce a pixel-exact 1536x2288 file themselves.
*/
/** Mirrors the main process cap so an oversized atlas fails before crossing IPC. */
const MAX_ATLAS_BYTES = 8 * 1024 * 1024
/**
* A sheet exported without an alpha channel renders as a solid rectangle on the
* desktop. Real transparent artwork leaves far more than this share of the atlas
* empty (the built-in pets sit near 78%), so anything below it is a flattened
* background rather than a deliberately dense drawing.
*/
const MIN_TRANSPARENT_RATIO = 0.05
export type PetSheetImportInput = {
slug: string
displayName: string
description: string
dialogTitle?: string
dialogFilterName?: string
}
export type PetSheetImportOutcome =
| { status: 'cancelled' }
| { status: 'created', id: string }
| { status: 'error', errorCode: string }
function transparentPixelRatio(canvas: HTMLCanvasElement): number {
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) return 1
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)
let transparent = 0
for (let index = 3; index < data.length; index += 4) {
if (data[index] === 0) transparent += 1
}
return transparent / (data.length / 4)
}
function canvasToBytes(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Uint8Array | null> {
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
if (!blob) {
resolve(null)
return
}
blob.arrayBuffer().then(
buffer => resolve(new Uint8Array(buffer)),
() => resolve(null),
)
},
type,
quality,
)
})
}
/**
* Encodes the atlas, falling back to WebP when a lossless PNG would exceed the
* package size cap. Detailed artwork can push a 1536x2288 PNG past 8 MB.
*/
async function encodeAtlas(
canvas: HTMLCanvasElement,
): Promise<{ atlasData: Uint8Array, mimeType: 'image/png' | 'image/webp' } | null> {
const png = await canvasToBytes(canvas, 'image/png')
if (png && png.byteLength <= MAX_ATLAS_BYTES) return { atlasData: png, mimeType: 'image/png' }
const webp = await canvasToBytes(canvas, 'image/webp', 0.95)
if (webp && webp.byteLength <= MAX_ATLAS_BYTES) return { atlasData: webp, mimeType: 'image/webp' }
return null
}
export async function importPetFromActionSheet(
host: DesktopHost,
input: PetSheetImportInput,
): Promise<PetSheetImportOutcome> {
const picked = await host.pets.pickSourceSheet({
dialogTitle: input.dialogTitle,
dialogFilterName: input.dialogFilterName,
})
if (!picked) return { status: 'cancelled' }
if ('errorCode' in picked) return { status: 'error', errorCode: picked.errorCode }
const shape = detectPetSheetShape(picked.width, picked.height)
// An already-exact atlas is installed byte-for-byte; re-slicing it would only
// resample finished artwork.
if (shape.passthrough) {
const result = await host.pets.createFromAtlasBytes({
slug: input.slug,
displayName: input.displayName,
description: input.description,
atlasData: picked.bytes,
mimeType: picked.mimeType,
})
if (!result) return { status: 'cancelled' }
return 'id' in result
? { status: 'created', id: result.id }
: { status: 'error', errorCode: result.errorCode }
}
// Copy into a plain ArrayBuffer: the bytes arrive over IPC and their backing
// buffer is not guaranteed to be a transferable BlobPart.
const sourceBuffer = new ArrayBuffer(picked.bytes.byteLength)
new Uint8Array(sourceBuffer).set(picked.bytes)
let bitmap: ImageBitmap
try {
bitmap = await createImageBitmap(new Blob([sourceBuffer], { type: picked.mimeType }))
} catch {
return { status: 'error', errorCode: 'invalid-image' }
}
let atlas: HTMLCanvasElement
try {
atlas = normalizePetSheetToAtlas(bitmap, bitmap.width, bitmap.height, {
sourceRows: shape.sourceRows,
})
} catch {
return { status: 'error', errorCode: 'invalid-image' }
} finally {
bitmap.close()
}
if (transparentPixelRatio(atlas) < MIN_TRANSPARENT_RATIO) {
return { status: 'error', errorCode: 'opaque-background' }
}
const encoded = await encodeAtlas(atlas)
if (!encoded) return { status: 'error', errorCode: 'image-too-large' }
const result = await host.pets.createFromAtlasBytes({
slug: input.slug,
displayName: input.displayName,
description: input.description,
atlasData: encoded.atlasData,
mimeType: encoded.mimeType,
})
if (!result) return { status: 'cancelled' }
return 'id' in result
? { status: 'created', id: result.id }
: { status: 'error', errorCode: result.errorCode }
}
+75 -27
View File
@@ -279,42 +279,90 @@ export const en = {
'settings.pets.retry': 'Retry',
'settings.pets.refresh': 'Refresh',
'settings.pets.create': 'Add pet',
'settings.pets.createTitle': 'Create a custom pet',
'settings.pets.createMethodIntro': 'Choose how to make this pet. Imports stay local, and your current chat model is never used to generate images.',
'settings.pets.createTitle': 'Make your own pet',
'settings.pets.createMethodIntro': 'Pick how you want to make it. Everything happens on your own computer — nothing is uploaded, and none of your chat quota is used.',
'settings.pets.createRecommended': 'Recommended',
'settings.pets.createImageTitle': 'Animate one image',
'settings.pets.createImageDescription': 'Turn one transparent PNG or WebP into a gently moving pet.',
'settings.pets.createImageDetail': 'Local only · No image model or video generation required',
'settings.pets.createImageHint': 'Choose a static transparent PNG or WebP (32–4096px per side, up to 8MB). The app keeps it local and adds lightweight breathing, floating, and task-state motion; it is not frame-by-frame generated animation.',
'settings.pets.createImageSubmit': 'Choose image and create',
'settings.pets.createAtlasTitle': 'Import professional animation atlas',
'settings.pets.createAtlasDescription': 'Use a prepared frame-by-frame sprite atlas for full character motion.',
'settings.pets.createAtlasDetail': 'Advanced · Exact 1536×2288 v2 PNG or WebP',
'settings.pets.createAtlasSubmit': 'Choose atlas and create',
'settings.pets.createAiTitle': 'Generate full animation with AI',
'settings.pets.createAiDescription': 'Create original artwork and action frames with an image-generation service.',
'settings.pets.createAiUnavailable': 'Unavailable: configure a separate image-generation service first. The current chat model is not used.',
'settings.pets.createBack': 'Choose another method',
'settings.pets.createImageTitle': 'Use a picture you already have',
'settings.pets.createImageDescription': 'One character image is enough. It will breathe gently, float, and stir while you are busy.',
'settings.pets.createImageDetail': 'About a minute · But it will not run or wave',
'settings.pets.createImageBadge': 'Easiest',
'settings.pets.createImageHint': 'You will pick a PNG or WebP with a transparent background (32–4096 pixels per side, under 8 MB). This kind of pet only sways gently — for running, jumping and waving, go back and choose "Draw one with AI".',
'settings.pets.createImageSubmit': 'Pick a picture',
'settings.pets.createAtlasTitle': 'I already have an action sheet',
'settings.pets.createAtlasDescription': 'Import a multi-frame action sheet you have already drawn and skip the walkthrough.',
'settings.pets.createAtlasDetail': 'No maths needed · Sizes are aligned for you',
'settings.pets.createAtlasSubmit': 'Pick an action sheet',
'settings.pets.createAtlasHint': 'You will pick an action sheet 8 cells wide and 9 rows tall. The size does not have to be exact — it is sliced, rescaled and the "run left" row is mirrored for you. A finished 1536×2288 atlas also works and is kept as-is.',
'settings.pets.createAiTitle': 'Draw one with AI that runs and jumps',
'settings.pets.createAiDescription': 'Three steps: copy a prompt, let an AI draw it, pick the picture. The prompt and a reference template are ready for you.',
'settings.pets.createAiDetail': 'Around ten minutes · Needs an AI that can draw',
'settings.pets.createBack': 'Pick a different way',
'settings.pets.createId': 'Pet ID',
'settings.pets.createIdHint': 'Use lowercase letters, numbers, and single hyphens.',
'settings.pets.createIdHint': 'A short English handle: lowercase letters, numbers and single hyphens, like moon-cat.',
'settings.pets.createName': 'Display name',
'settings.pets.createDescription': 'Description',
'settings.pets.createAtlasHint': 'Choose a transparent, gapless 1536×2288 PNG/WebP: 8 columns × 11 rows, with 192×208 cells. Rows are idle, run right, run left, wave, jump, fail, wait, work, review, then 16 gaze directions across the last two rows.',
'settings.pets.createCancel': 'Cancel',
'settings.pets.createSubmit': 'Create',
'settings.pets.createError': 'The custom pet could not be created.',
'settings.pets.guide.intro': 'Three steps, about ten minutes, all on your own computer.',
'settings.pets.guide.step1Title': 'Have an AI draw an action sheet',
'settings.pets.guide.step1Body': 'Open any AI that can draw and send it everything below. Replace the two "Character" lines with whatever you want, for example "a shiba inu in a yellow hat".',
'settings.pets.guide.step1Tools': 'Nano Banana, ChatGPT image generation, Midjourney, Stable Diffusion and Jimeng all work (examples, not endorsements). If it comes out wrong, ask for a redraw, or say "keep the character, redraw row 2 only".',
'settings.pets.guide.prompt': `Draw me a game character action sheet (sprite sheet).
[Character]
A round-headed orange kitten wearing a small blue scarf, chibi
three-heads-tall proportions, 3D cartoon render, soft glossy
surface, bright cheerful colours.
(Replace these lines with your own character — the more specific the better)
[Whole image]
- Fully transparent background: no backdrop colour, no grid lines, no text, no drop shadow
- Divide the image evenly into 8 columns x 9 rows, 72 equally sized cells
- One action frame per cell, character centred with a little margin around it
- Every cell must show the same character with identical proportions, colours and art style
- Leave unused cells fully transparent
[What to draw in each row]
Row 1, first 6 cells: standing still with a gentle breathing bob
Row 2, all 8 cells: a full run cycle facing right, always facing right
Row 3, first 4 cells: raising a hand and waving hello
Row 4, first 5 cells: crouch, leap, land
Row 5, all 8 cells: dejected and downcast, head lowered, sighing
Row 6, first 6 cells: waiting in place, glancing around
Row 7, first 6 cells: head down, busy working
Row 8, all 8 cells: head and gaze starting straight up, turning slowly to the right through upper-right, right and lower-right, ending near straight down
Row 9, all 8 cells: continuing from straight down, turning left through lower-left, left and upper-left, back to near straight up`,
'settings.pets.guide.promptCopy': 'Copy this prompt',
'settings.pets.guide.promptCopied': 'Copied',
'settings.pets.guide.copyFailed': 'Could not copy automatically — select the text above and copy it manually.',
'settings.pets.guide.step2Title': 'Check it against the template',
'settings.pets.guide.step2Body': 'When the picture is ready, check these three things against the template below. If something is off, ask for a redraw before importing.',
'settings.pets.guide.check1': 'The background is see-through, not white — a white backdrop becomes a square on your desktop',
'settings.pets.guide.check2': '8 cells across, 9 rows down, one action per cell',
'settings.pets.guide.check3': 'The same character all the way through, with no change of face or colours',
'settings.pets.guide.templateAlt': 'Action sheet template: 8 columns by 9 rows, labelled with what each row should contain',
'settings.pets.guide.templateExpand': 'Tap the image for the full template',
'settings.pets.guide.templateCollapse': 'Tap the image to collapse',
'settings.pets.guide.saveTemplate': 'Save the template',
'settings.pets.guide.saveFailed': 'Could not save the template image. Try again shortly.',
'settings.pets.guide.step3Title': 'Just pick the file',
'settings.pets.guide.step3Body': 'Give it a name on the next page, then pick the picture you just made. You do not need to resize anything, and the "run left" row is mirrored for you.',
'settings.pets.guide.continue': 'Done drawing, next',
'settings.pets.guide.backToSteps': 'Back to the steps',
'settings.pets.createError': 'This pet could not be created.',
'settings.pets.createError.invalidId': 'Use a lowercase pet ID containing only letters, numbers, and single hyphens.',
'settings.pets.createError.duplicateId': 'A custom pet with this ID already exists.',
'settings.pets.createError.duplicateId': 'A pet with this ID already exists — pick another one.',
'settings.pets.createError.unsupportedFormat': 'Choose a PNG or WebP image.',
'settings.pets.createError.imageTooLarge': 'The selected image must be no larger than 8 MB.',
'settings.pets.createError.imageDimensions': 'The pet image must be 32–4096 pixels per side and contain no more than 16,777,216 pixels.',
'settings.pets.createError.atlasDimensions': 'The animation atlas must be exactly 1536×2288 pixels.',
'settings.pets.createError.invalidImage': 'The selected image could not be read. Choose a static PNG or WebP image.',
'settings.pets.createError.storage': 'The pet could not be saved in the custom pet folder.',
'settings.pets.dialog.imageTitle': 'Choose a transparent pet image',
'settings.pets.createError.imageTooLarge': 'That image is too big. Keep it under 8 MB.',
'settings.pets.createError.imageDimensions': 'The image must be 32–4096 pixels per side and contain no more than 16,777,216 pixels.',
'settings.pets.createError.atlasDimensions': 'This image cannot be sliced into 8 columns by 9 rows. Check that it is 8 cells across and 9 rows down, or use a finished 1536×2288 atlas.',
'settings.pets.createError.opaqueBackground': 'This image has no transparent background, so the pet would show up as a square on your desktop. Ask the AI to export a PNG with a transparent background (not a white one).',
'settings.pets.createError.invalidImage': 'That image could not be read. Choose a static PNG or WebP (animated files will not work).',
'settings.pets.createError.storage': 'The pet could not be saved to the local folder.',
'settings.pets.dialog.imageTitle': 'Choose a pet image with a transparent background',
'settings.pets.dialog.imageFilter': 'Pet image',
'settings.pets.dialog.atlasTitle': 'Choose a v2 pet animation atlas',
'settings.pets.dialog.atlasFilter': 'Pet animation atlas',
'settings.pets.dialog.atlasTitle': 'Choose a pet action sheet',
'settings.pets.dialog.atlasFilter': 'Pet action sheet',
'settings.pets.builtInTitle': 'Built-in pets',
'settings.pets.customTitle': 'Your pets',
'settings.pets.customEmpty': 'No custom pets yet.',
+77 -29
View File
@@ -281,42 +281,90 @@ export const jp: Record<TranslationKey, string> = {
'settings.pets.retry': '再試行',
'settings.pets.refresh': '更新',
'settings.pets.create': 'ペットを追加',
'settings.pets.createTitle': 'カスタムペットを作成',
'settings.pets.createMethodIntro': '作成方法を選択してください。インポートはローカルで処理され、現在のチャットモデルで画像を生成することはありません。',
'settings.pets.createTitle': '自分だけのペットを作る',
'settings.pets.createMethodIntro': '作り方を選んでください。画像はすべて自分のパソコンの中で処理されます。アップロードされず、チャットの利用量も消費しません。',
'settings.pets.createRecommended': 'おすすめ',
'settings.pets.createImageTitle': '1 枚の画像に軽い動きを追加',
'settings.pets.createImageDescription': '背景が透明な PNG または WebP を、やさしく動くペットにします。',
'settings.pets.createImageDetail': 'ローカル処理 · 画像モデルや動画生成は不要',
'settings.pets.createImageHint': '背景が透明な静止 PNG または WebP(各辺 32~4096px、最大 8MB)を選択します。呼吸、浮遊、タスク状態に応じた軽い動きをローカルで追加します。フレーム単位で生成する完全なアニメーションではありません。',
'settings.pets.createImageSubmit': '画像を選択して作成',
'settings.pets.createAtlasTitle': 'プロ向けアニメーションアトラスを読み込む',
'settings.pets.createAtlasDescription': '作成済みのフレームアニメーション用スプライトアトラスで、完全なキャラクター動作を使います。',
'settings.pets.createAtlasDetail': '上級 · 1536×2288 の v2 PNG または WebP が必須',
'settings.pets.createAtlasSubmit': 'アトラスを選択して作成',
'settings.pets.createAiTitle': 'AI で完全なアニメーションを生成',
'settings.pets.createAiDescription': '画像生成サービスでキャラクターとアクションフレームを作成します。',
'settings.pets.createAiUnavailable': '現在利用できません。別の画像生成サービスを設定する必要があります。現在のチャットモデルは使用しません。',
'settings.pets.createBack': '作成方法を選び直す',
'settings.pets.createImageTitle': '手元にある画像を使う',
'settings.pets.createImageDescription': 'キャラクターの画像が 1 枚あれば始められます。そっと呼吸して上下に浮かび、作業中は一緒に動きます。',
'settings.pets.createImageDetail': '1 分ほど · ただし走ったり手を振ったりはしません',
'settings.pets.createImageBadge': 'いちばん手軽',
'settings.pets.createImageHint': 'このあと背景が透明な PNG または WebP を選びます(縦横とも 32〜4096 ピクセル、8MB 以下)。このタイプは軽く揺れるだけで、実際に走ることはありません。走る・跳ぶ・手を振る動きが欲しいときは、前の画面で「AI に走って跳ぶペットを描いてもらう」を選んでください。',
'settings.pets.createImageSubmit': '画像を選んで作る',
'settings.pets.createAtlasTitle': 'アクションシートはもう持っている',
'settings.pets.createAtlasDescription': '描き上がったコマ割りのアクションシートをそのまま読み込み、手順をとばします。',
'settings.pets.createAtlasDetail': 'サイズ計算は不要 · 自動でぴったり合わせます',
'settings.pets.createAtlasSubmit': 'アクションシートを選んで作る',
'settings.pets.createAtlasHint': 'このあと横 8 コマ・縦 9 行のアクションシートを選びます。サイズはぴったりでなくて大丈夫です。自動で切り分けて拡大縮小し、「左に走る」の行はミラーで作られます。完成済みの 1536×2288 のアトラスもそのまま使えます。',
'settings.pets.createAiTitle': 'AI に走って跳ぶペットを描いてもらう',
'settings.pets.createAiDescription': '3 ステップです。プロンプトをコピーし、AI に描いてもらい、その画像を選ぶだけ。プロンプトと見本のテンプレートは用意してあります。',
'settings.pets.createAiDetail': '10 分ほど · 画像を描ける AI が必要です',
'settings.pets.createBack': '別の作り方にする',
'settings.pets.createId': 'ペット ID',
'settings.pets.createIdHint': '小文字、数字、単一のハイフンのみ使用できます。',
'settings.pets.createIdHint': '英数字の短い名前です。小文字・数字・間のハイフンのみ(例: moon-cat)。',
'settings.pets.createName': '表示名',
'settings.pets.createDescription': '説明',
'settings.pets.createAtlasHint': '透明背景・隙間なしの 1536×2288 PNG/WebP(8列×11行、各セル 192×208)を選択します。行は idle、右走り、左走り、wave、jump、fail、wait、work、review、最後の2行に16方向の視線です。',
'settings.pets.createCancel': 'キャンセル',
'settings.pets.createSubmit': '作成',
'settings.pets.createError': 'カスタムペットを作成できませんでした。',
'settings.pets.createError.invalidId': 'ペット ID には小文字、数字、単一のハイフンのみ使用できます。',
'settings.pets.createError.duplicateId': 'この ID のカスタムペットはすでに存在します。',
'settings.pets.createError.unsupportedFormat': 'PNG または WebP 画像を選択してください。',
'settings.pets.createError.imageTooLarge': '選択する画像は 8 MB 以下にしてください。',
'settings.pets.createError.imageDimensions': 'ペット画像は各辺 32~4096 ピクセル、総画素数 16,777,216 以下である必要があります。',
'settings.pets.createError.atlasDimensions': 'アニメーションアトラスは正確に 1536×2288 ピクセルである必要があります。',
'settings.pets.createError.invalidImage': '選択した画像を読み込めません。静止 PNG または WebP 画像を選択してください。',
'settings.pets.createError.storage': 'カスタムペットフォルダーにペットを保存できませんでした。',
'settings.pets.dialog.imageTitle': '透明背景のペット画像を選択',
'settings.pets.guide.intro': '3 ステップ、10 分ほど。すべて自分のパソコンの中で完結します。',
'settings.pets.guide.step1Title': 'AI にアクションシートを描いてもらう',
'settings.pets.guide.step1Body': '画像を描ける AI を開き、下の文章をまるごと送ってください。冒頭の「キャラクター」の 2 行を好きな内容に置き換えます(例:「黄色い帽子をかぶった柴犬」)。',
'settings.pets.guide.step1Tools': 'Nano Banana、ChatGPT の画像生成、Midjourney、Stable Diffusion、Jimeng などが使えます(推奨ではなく一例です)。うまくいかないときは描き直しを頼むか、「キャラクターはそのままで 2 行目だけ描き直して」と伝えてください。',
'settings.pets.guide.prompt': `ゲームキャラクターのアクションシート(スプライトシート)を描いてください。
【キャラクター】
まるい頭のオレンジ色の子猫。青い小さなマフラーを巻いた
三頭身のデフォルメ、3D カートゥーン調、やわらかい光沢、
明るい色づかい。
(この 2 行を好きなキャラクターに置き換えてください。具体的なほど良いです)
【全体の条件】
・背景は完全に透明。背景色、グリッド線、文字、影は入れない
・画像全体を 8 列 × 9 行、合計 72 個の同じ大きさのマスに均等に分割する
・1 マスに 1 コマ。キャラクターはマスの中央に置き、周囲に少し余白を残す
・すべてのマスで同じキャラクター。体型・配色・画風を完全に統一する
・使わないマスは完全に透明のままにする
【各行に描く内容】
1 行目 左から 6 マス:立ち止まり、呼吸でわずかに上下する
2 行目 8 マスすべて:右に走る一連の動き。つねに右を向く
3 行目 左から 4 マス:手を上げて挨拶する
4 行目 左から 5 マス:しゃがむ、跳ぶ、着地する
5 行目 8 マスすべて:がっかりしてうつむき、ため息をつく
6 行目 左から 6 マス:その場で待ちながら、あたりを見回す
7 行目 左から 6 マス:うつむいて作業に集中する
8 行目 8 マスすべて:頭と視線が真上から右へゆっくり回り、右上・右・右下を通って真下の手前まで
9 行目 8 マスすべて:前の行に続き、真下から左へ回り、左下・左・左上を通って真上の手前まで`,
'settings.pets.guide.promptCopy': 'このプロンプトをコピー',
'settings.pets.guide.promptCopied': 'コピーしました',
'settings.pets.guide.copyFailed': '自動でコピーできませんでした。上の文章を選んで手動でコピーしてください。',
'settings.pets.guide.step2Title': 'テンプレートと見比べる',
'settings.pets.guide.step2Body': '画像ができたら、下のテンプレートと見比べて 3 点を確認してください。違っていたら読み込む前に描き直してもらいましょう。',
'settings.pets.guide.check1': '背景が透けている(白ではない)。白い背景はデスクトップ上で四角い板になります',
'settings.pets.guide.check2': '横に 8 コマ、縦に 9 行。1 マスにつき 1 コマ',
'settings.pets.guide.check3': '9 行を通して同じキャラクター。途中で顔や色が変わっていない',
'settings.pets.guide.templateAlt': 'アクションシートのテンプレート: 8 列 9 行、各行に描く内容を注記',
'settings.pets.guide.templateExpand': '画像をタップして全体を表示',
'settings.pets.guide.templateCollapse': '画像をタップして閉じる',
'settings.pets.guide.saveTemplate': 'テンプレートを保存',
'settings.pets.guide.saveFailed': 'テンプレート画像を保存できませんでした。少し待ってからお試しください。',
'settings.pets.guide.step3Title': '選ぶだけ',
'settings.pets.guide.step3Body': '次の画面で名前を入れ、いま作った画像を選びます。サイズを自分で調整する必要はありません。「左に走る」の行も自動でミラー生成されます。',
'settings.pets.guide.continue': '描けた、次へ',
'settings.pets.guide.backToSteps': '手順に戻る',
'settings.pets.createError': 'このペットを作れませんでした。',
'settings.pets.createError.invalidId': 'ペット ID には小文字、数字、間のハイフンのみ使用できます。',
'settings.pets.createError.duplicateId': 'この ID のペットはすでにあります。別の ID にしてください。',
'settings.pets.createError.unsupportedFormat': 'PNG または WebP 形式の画像を選んでください。',
'settings.pets.createError.imageTooLarge': '画像が大きすぎます。8 MB 以下にしてください。',
'settings.pets.createError.imageDimensions': '画像は縦横とも 32〜4096 ピクセル、総画素数 16,777,216 以下にしてください。',
'settings.pets.createError.atlasDimensions': 'この画像は 8 列 × 9 行に分割できません。横 8 コマ・縦 9 行になっているか確認するか、完成済みの 1536×2288 のアトラスを使ってください。',
'settings.pets.createError.opaqueBackground': 'この画像には透明な背景がないため、デスクトップ上で四角い板になってしまいます。背景が透明な PNG(白背景ではないもの)を書き出し直してもらってください。',
'settings.pets.createError.invalidImage': 'この画像を読み込めません。静止画の PNG または WebP を選んでください(アニメーション画像は使えません)。',
'settings.pets.createError.storage': 'ペットをローカルのフォルダーに保存できませんでした。',
'settings.pets.dialog.imageTitle': '背景が透明なペット画像を選択',
'settings.pets.dialog.imageFilter': 'ペット画像',
'settings.pets.dialog.atlasTitle': 'v2 ペットアニメーションアトラスを選択',
'settings.pets.dialog.atlasFilter': 'ペットアニメーションアトラス',
'settings.pets.dialog.atlasTitle': 'ペットのアクションシートを選択',
'settings.pets.dialog.atlasFilter': 'ペットのアクションシート',
'settings.pets.builtInTitle': '内蔵ペット',
'settings.pets.customTitle': 'あなたのペット',
'settings.pets.customEmpty': 'カスタムペットはまだありません。',
+77 -29
View File
@@ -281,42 +281,90 @@ export const kr: Record<TranslationKey, string> = {
'settings.pets.retry': '다시 시도',
'settings.pets.refresh': '새로 고침',
'settings.pets.create': '펫 추가',
'settings.pets.createTitle': '사용자 지정 펫 만들기',
'settings.pets.createMethodIntro': '펫을 만드는 방법을 선택하세요. 가져오기는 로컬에서 처리되며 현재 채팅 모델로 이미지를 생성하지 않습니다.',
'settings.pets.createTitle': '나만의 펫 만들기',
'settings.pets.createMethodIntro': '만드는 방법을 골라 주세요. 이미지는 모두 내 컴퓨터 안에서 처리되며, 업로드되지 않고 대화 사용량도 쓰지 않습니다.',
'settings.pets.createRecommended': '추천',
'settings.pets.createImageTitle': '이미지 한 장에 가벼운 애니메이션 추가',
'settings.pets.createImageDescription': '투명 배경 PNG 또는 WebP를 부드럽게 움직이는 펫으로 만듭니다.',
'settings.pets.createImageDetail': '로컬 처리 · 이미지 모델이나 비디오 생성 불필요',
'settings.pets.createImageHint': '투명 배경의 정적 PNG 또는 WebP(각 변 32~4096px, 최대 8MB)를 선택하세요. 앱이 로컬에서 호흡, 떠오름, 작업 상태에 따른 가벼운 움직임을 추가합니다. 프레임별로 생성된 완전한 애니메이션은 아닙니다.',
'settings.pets.createImageSubmit': '이미지를 선택해 만들기',
'settings.pets.createAtlasTitle': '전문 애니메이션 아틀라스 가져오기',
'settings.pets.createAtlasDescription': '미리 제작한 프레임별 스프라이트 아틀라스로 완전한 캐릭터 동작을 사용합니다.',
'settings.pets.createAtlasDetail': '고급 · 정확히 1536×2288인 v2 PNG 또는 WebP',
'settings.pets.createAtlasSubmit': '아틀라스를 선택해 만들기',
'settings.pets.createAiTitle': 'AI로 전체 애니메이션 생성',
'settings.pets.createAiDescription': '이미지 생성 서비스로 캐릭터와 동작 프레임을 만듭니다.',
'settings.pets.createAiUnavailable': '현재 사용할 수 없습니다. 별도의 이미지 생성 서비스를 설정해야 하며 현재 채팅 모델은 사용하지 않습니다.',
'settings.pets.createBack': '다른 제작 방법 선택',
'settings.pets.createImageTitle': '가지고 있는 그림 사용하기',
'settings.pets.createImageDescription': '캐릭터 그림 한 장이면 충분합니다. 가볍게 숨을 쉬듯 떠오르고, 바쁠 때는 함께 움직입니다.',
'settings.pets.createImageDetail': '1분이면 완료 · 다만 달리거나 손을 흔들지는 않습니다',
'settings.pets.createImageBadge': '가장 간단',
'settings.pets.createImageHint': '잠시 후 배경이 투명한 PNG 또는 WebP를 고릅니다(가로세로 32~4096픽셀, 8MB 이하). 이 방식은 살짝 흔들리기만 하고 실제로 달리지는 않습니다. 달리고 뛰고 손을 흔들게 하려면 이전 화면에서 "AI로 달리고 뛰는 펫 그리기"를 선택하세요.',
'settings.pets.createImageSubmit': '그림 골라서 만들기',
'settings.pets.createAtlasTitle': '이미 동작 시트가 있어요',
'settings.pets.createAtlasDescription': '이미 그려 둔 여러 칸짜리 동작 시트를 바로 가져오고 안내는 건너뜁니다.',
'settings.pets.createAtlasDetail': '크기 계산 불필요 · 알아서 맞춰 줍니다',
'settings.pets.createAtlasSubmit': '동작 시트 골라서 만들기',
'settings.pets.createAtlasHint': '잠시 후 가로 8칸, 세로 9줄짜리 동작 시트를 고릅니다. 크기가 딱 맞지 않아도 됩니다. 알아서 칸을 나누고 크기를 조정하며, "왼쪽으로 달리기" 줄은 좌우 반전으로 채워 줍니다. 이미 완성된 1536×2288 아틀라스도 그대로 쓸 수 있습니다.',
'settings.pets.createAiTitle': 'AI로 달리고 뛰는 펫 그리기',
'settings.pets.createAiDescription': '세 단계입니다. 프롬프트를 복사하고, AI에게 그리게 하고, 그 그림을 고르면 끝. 프롬프트와 참고용 템플릿을 준비해 두었습니다.',
'settings.pets.createAiDetail': '10분쯤 · 그림을 그릴 수 있는 AI가 필요합니다',
'settings.pets.createBack': '다른 방법 고르기',
'settings.pets.createId': '펫 ID',
'settings.pets.createIdHint': '소문자, 숫자, 단일 하이픈만 사용하세요.',
'settings.pets.createIdHint': '짧은 영문 이름입니다. 소문자, 숫자, 가운데 하이픈만 사용하세요(예: moon-cat).',
'settings.pets.createName': '표시 이름',
'settings.pets.createDescription': '설명',
'settings.pets.createAtlasHint': '투명 배경과 여백 없는 1536×2288 PNG/WebP(8열×11행, 각 셀 192×208)를 선택하세요. 행 순서는 idle, 오른쪽 달리기, 왼쪽 달리기, wave, jump, fail, wait, work, review, 마지막 두 행의 16개 시선 방향입니다.',
'settings.pets.createCancel': '취소',
'settings.pets.createSubmit': '만들기',
'settings.pets.createError': '사용자 지정 펫을 만들 수 없습니다.',
'settings.pets.createError.invalidId': '펫 ID에는 소문자, 숫자, 단일 하이픈만 사용할 수 있습니다.',
'settings.pets.createError.duplicateId': '이 ID를 사용하는 사용자 지정 펫이 이미 있습니다.',
'settings.pets.createError.unsupportedFormat': 'PNG 또는 WebP 이미지를 선택하세요.',
'settings.pets.createError.imageTooLarge': '선택한 이미지는 8MB 이하여야 합니다.',
'settings.pets.createError.imageDimensions': '펫 이미지는 각 변이 32~4096픽셀이고 전체 픽셀 수가 16,777,216 이하여야 합니다.',
'settings.pets.createError.atlasDimensions': '애니메이션 아틀라스는 정확히 1536×2288픽셀이어야 합니다.',
'settings.pets.createError.invalidImage': '선택한 이미지를 읽을 수 없습니다. 정적 PNG 또는 WebP 이미지를 선택하세요.',
'settings.pets.createError.storage': '사용자 지정 펫 폴더에 펫을 저장할 수 없습니다.',
'settings.pets.dialog.imageTitle': '투명 배경 펫 이미지 선택',
'settings.pets.guide.intro': '세 단계, 10분쯤 걸립니다. 전부 내 컴퓨터 안에서 끝납니다.',
'settings.pets.guide.step1Title': 'AI에게 동작 시트를 그리게 하기',
'settings.pets.guide.step1Body': '그림을 그릴 수 있는 AI를 열고 아래 글을 통째로 보내세요. 맨 앞 "캐릭터" 두 줄을 원하는 모습으로 바꾸면 됩니다(예: "노란 모자를 쓴 시바견").',
'settings.pets.guide.step1Tools': 'Nano Banana, ChatGPT 이미지 생성, Midjourney, Stable Diffusion, Jimeng 모두 됩니다(추천이 아니라 예시입니다). 잘 안 나오면 다시 그려 달라고 하거나 "캐릭터는 그대로 두고 2번째 줄만 다시 그려 줘"라고 하세요.',
'settings.pets.guide.prompt': `게임 캐릭터 동작 시트(스프라이트 시트)를 그려 주세요.
[캐릭터]
동그란 머리의 주황색 새끼 고양이, 파란 목도리를 두른
3등신 SD 비율, 3D 카툰 렌더링, 부드럽고 윤기 있는 표면,
밝고 산뜻한 색감.
(이 두 줄을 원하는 캐릭터로 바꾸세요. 구체적일수록 좋습니다)
[전체 조건]
· 배경은 완전히 투명하게. 배경색, 격자선, 글자, 그림자 없이
· 전체를 가로 8열 × 세로 9행, 총 72개의 같은 크기 칸으로 고르게 나눌 것
· 한 칸에 한 동작. 캐릭터는 칸 가운데에 두고 주변에 여백을 조금 남길 것
· 모든 칸에 같은 캐릭터. 체형, 색 배합, 그림체를 완전히 통일할 것
· 쓰지 않는 칸은 완전히 투명하게 둘 것
[각 줄에 그릴 내용]
1번째 줄 앞 6칸: 가만히 서서 숨 쉬듯 살짝 위아래로 움직임
2번째 줄 8칸 전부: 오른쪽으로 달리는 한 사이클. 항상 오른쪽을 향할 것
3번째 줄 앞 4칸: 손을 들어 인사하기
4번째 줄 앞 5칸: 웅크리기, 뛰어오르기, 착지하기
5번째 줄 8칸 전부: 실망해서 고개를 떨구고 한숨 쉬기
6번째 줄 앞 6칸: 제자리에서 기다리며 두리번거리기
7번째 줄 앞 6칸: 고개를 숙이고 바쁘게 작업하기
8번째 줄 8칸 전부: 머리와 시선이 정면 위에서 시작해 오른쪽으로 천천히 돌아 오른쪽 위, 오른쪽, 오른쪽 아래를 지나 정면 아래 직전까지
9번째 줄 8칸 전부: 앞 줄에 이어 정면 아래에서 왼쪽으로 돌아 왼쪽 아래, 왼쪽, 왼쪽 위를 지나 정면 위 직전까지`,
'settings.pets.guide.promptCopy': '이 프롬프트 복사',
'settings.pets.guide.promptCopied': '복사했습니다',
'settings.pets.guide.copyFailed': '자동으로 복사하지 못했습니다. 위 글을 직접 선택해 복사하세요.',
'settings.pets.guide.step2Title': '템플릿과 비교해 확인하기',
'settings.pets.guide.step2Body': '그림이 나오면 아래 템플릿과 비교해 세 가지를 확인하세요. 다르면 가져오기 전에 다시 그려 달라고 하세요.',
'settings.pets.guide.check1': '배경이 비쳐 보이고 흰색이 아닌지 — 흰 배경은 바탕화면에서 네모난 판이 됩니다',
'settings.pets.guide.check2': '가로 8칸, 세로 9줄, 한 칸에 한 동작인지',
'settings.pets.guide.check3': '아홉 줄 내내 같은 캐릭터이고 중간에 얼굴이나 색이 바뀌지 않았는지',
'settings.pets.guide.templateAlt': '동작 시트 템플릿: 가로 8열 세로 9행, 각 줄에 그릴 내용 표시',
'settings.pets.guide.templateExpand': '이미지를 눌러 전체 템플릿 보기',
'settings.pets.guide.templateCollapse': '이미지를 눌러 접기',
'settings.pets.guide.saveTemplate': '템플릿 저장하기',
'settings.pets.guide.saveFailed': '템플릿 이미지를 저장하지 못했습니다. 잠시 후 다시 시도하세요.',
'settings.pets.guide.step3Title': '고르기만 하면 끝',
'settings.pets.guide.step3Body': '다음 화면에서 이름을 적고, 방금 만든 그림을 고르세요. 크기를 직접 맞출 필요 없고 "왼쪽으로 달리기" 줄도 알아서 좌우 반전으로 채워집니다.',
'settings.pets.guide.continue': '다 그렸어요, 다음',
'settings.pets.guide.backToSteps': '단계로 돌아가기',
'settings.pets.createError': '이 펫을 만들지 못했습니다.',
'settings.pets.createError.invalidId': '펫 ID에는 소문자, 숫자, 가운데 하이픈만 사용할 수 있습니다.',
'settings.pets.createError.duplicateId': '이 ID를 쓰는 펫이 이미 있습니다. 다른 ID로 바꿔 주세요.',
'settings.pets.createError.unsupportedFormat': 'PNG 또는 WebP 형식의 이미지를 선택하세요.',
'settings.pets.createError.imageTooLarge': '이미지가 너무 큽니다. 8MB 이하로 맞춰 주세요.',
'settings.pets.createError.imageDimensions': '이미지는 가로세로 32~4096픽셀이고 전체 픽셀 수가 16,777,216 이하여야 합니다.',
'settings.pets.createError.atlasDimensions': '이 이미지는 가로 8열 세로 9행으로 나눌 수 없습니다. 가로 8칸 세로 9줄인지 확인하거나, 완성된 1536×2288 아틀라스를 사용하세요.',
'settings.pets.createError.opaqueBackground': '이 이미지에는 투명 배경이 없어 바탕화면에서 네모난 판으로 보이게 됩니다. 배경이 투명한 PNG(흰 배경 말고)로 다시 내보내 달라고 하세요.',
'settings.pets.createError.invalidImage': '이 이미지를 읽을 수 없습니다. 움직이지 않는 PNG 또는 WebP를 선택하세요(움직이는 이미지는 안 됩니다).',
'settings.pets.createError.storage': '펫을 로컬 폴더에 저장하지 못했습니다.',
'settings.pets.dialog.imageTitle': '배경이 투명한 펫 이미지 선택',
'settings.pets.dialog.imageFilter': '펫 이미지',
'settings.pets.dialog.atlasTitle': 'v2 펫 애니메이션 아틀라스 선택',
'settings.pets.dialog.atlasFilter': '펫 애니메이션 아틀라스',
'settings.pets.dialog.atlasTitle': '펫 동작 시트 선택',
'settings.pets.dialog.atlasFilter': '펫 동작 시트',
'settings.pets.builtInTitle': '기본 펫',
'settings.pets.customTitle': '내 펫',
'settings.pets.customEmpty': '아직 사용자 지정 펫이 없습니다.',
+76 -29
View File
@@ -281,42 +281,89 @@ export const zh: Record<TranslationKey, string> = {
'settings.pets.retry': '重試',
'settings.pets.refresh': '重新整理',
'settings.pets.create': '新增寵物',
'settings.pets.createTitle': '建立自訂寵物',
'settings.pets.createMethodIntro': '選擇製作方式。匯入過程都在本機完成,不會呼叫目前的聊天模型產生圖片。',
'settings.pets.createTitle': '做一隻自己的寵物',
'settings.pets.createMethodIntro': '選一種做法。圖片都在你自己的電腦上處理,不會上傳,也不會消耗你的對話額度。',
'settings.pets.createRecommended': '推薦',
'settings.pets.createImageTitle': '用一張圖片製作輕動畫',
'settings.pets.createImageDescription': '把一張透明背景 PNG 或 WebP 變成會輕柔活動的寵物。',
'settings.pets.createImageDetail': '本機處理 · 不需要圖片模型或影片生成',
'settings.pets.createImageHint': '選擇一張透明背景的靜態 PNG 或 WebP(單邊 32–4096px、最大 8MB)。應用程式會在本機加入呼吸、漂浮和任務狀態等輕動畫;這不是逐格生成的完整動畫。',
'settings.pets.createImageSubmit': '選擇圖片並建立',
'settings.pets.createAtlasTitle': '匯入專業動畫圖集',
'settings.pets.createAtlasDescription': '匯入已經製作好的逐格精靈圖集,取得完整角色動作。',
'settings.pets.createAtlasDetail': '進階 · 必須是 1536×2288 的 v2 PNG 或 WebP',
'settings.pets.createAtlasSubmit': '選擇圖集並建立',
'settings.pets.createAiTitle': 'AI 生成完整動畫',
'settings.pets.createAiDescription': '透過圖片生成服務創作角色和動作格。',
'settings.pets.createAiUnavailable': '目前未設定:需要獨立的圖片生成服務;不會使用目前的聊天模型。',
'settings.pets.createBack': '重新選擇製作方式',
'settings.pets.createImageTitle': '用一張現成的圖',
'settings.pets.createImageDescription': '手邊有一張角色圖就能開始。牠會輕輕呼吸、上下浮動,你忙的時候牠也跟著動。',
'settings.pets.createImageDetail': '一分鐘搞定 · 但牠不會跑、也不會揮手',
'settings.pets.createImageBadge': '最省事',
'settings.pets.createImageHint': '等一下選一張背景透明的 PNG 或 WebP(長寬都在 32 到 4096 像素之間,檔案不超過 8MB)。這種寵物只會輕輕晃動,不會真的跑起來——想要能跑能跳會揮手,回上一頁選「用 AI 畫一隻會跑會跳的」。',
'settings.pets.createImageSubmit': '選圖片,做寵物',
'settings.pets.createAtlasTitle': '我已經有動作圖了',
'settings.pets.createAtlasDescription': '直接把畫好的多格動作圖選進來,跳過教學。',
'settings.pets.createAtlasDetail': '尺寸不用自己算 · 選進來會自動對齊',
'settings.pets.createAtlasSubmit': '選動作圖,做寵物',
'settings.pets.createAtlasHint': '等一下選一張 8 格寬、9 列高的動作圖。尺寸不用剛好,選進來會自動切格、縮放,還會幫你把「向左跑」那一列鏡像補出來。已經做好的 1536×2288 成品圖集也能直接用,會原樣保留。',
'settings.pets.createAiTitle': '用 AI 畫一隻會跑會跳的',
'settings.pets.createAiDescription': '照著三步走:複製一段提示詞、讓 AI 畫圖、把圖選進來。提示詞和對照範本都替你準備好了。',
'settings.pets.createAiDetail': '十來分鐘 · 需要一個會畫圖的 AI',
'settings.pets.createBack': '換一種做法',
'settings.pets.createId': '寵物 ID',
'settings.pets.createIdHint': '僅使用小寫字母、數字和單個連字號。',
'settings.pets.createIdHint': '給牠一個英文小名,只能用小寫字母、數字和中間的連字號,例如 moon-cat。',
'settings.pets.createName': '顯示名稱',
'settings.pets.createDescription': '寵物描述',
'settings.pets.createAtlasHint': '選擇透明背景、無間距的 1536×2288 PNG/WebP:8 欄×11 列,每格 192×208。列順序為待機、向右跑、向左跑、揮手、跳躍、失敗、等待、工作、複核,以及最後兩列的 16 個視線方向。',
'settings.pets.createCancel': '取消',
'settings.pets.createSubmit': '建立',
'settings.pets.createError': '無法建立自訂寵物。',
'settings.pets.createError.invalidId': '寵物 ID 只能使用小寫字母、數字和單一連字號。',
'settings.pets.createError.duplicateId': '已有使用此 ID 的自訂寵物。',
'settings.pets.createError.unsupportedFormat': '請選擇 PNG 或 WebP 圖片。',
'settings.pets.createError.imageTooLarge': '所選圖片不得超過 8 MB。',
'settings.pets.createError.imageDimensions': '寵物圖片每邊必須為 32–4096 像素,且總像素數不得超過 16,777,216。',
'settings.pets.createError.atlasDimensions': '動畫圖集尺寸必須恰好為 1536×2288 像素。',
'settings.pets.createError.invalidImage': '無法讀取所選圖片,請選擇靜態 PNG 或 WebP 圖片。',
'settings.pets.createError.storage': '無法將寵物儲存到自訂寵物資料夾。',
'settings.pets.dialog.imageTitle': '選擇透明背景的寵物圖片',
'settings.pets.guide.intro': '三步,大約十分鐘,全程在你自己的電腦上完成。',
'settings.pets.guide.step1Title': '讓 AI 畫一張動作圖',
'settings.pets.guide.step1Body': '打開一個會畫圖的 AI,把下面這段整個發給它。開頭「角色」那兩行換成你想要的樣子,例如「一隻戴黃色帽子的柴犬」。',
'settings.pets.guide.step1Tools': '即夢、Nano Banana、ChatGPT 畫圖、Midjourney、Stable Diffusion 都行(隨手舉例,不是推薦)。畫得不對就讓它重畫,或者說「角色保持不變,只重畫第 2 列」。',
'settings.pets.guide.prompt': `幫我畫一張遊戲角色動作表(sprite sheet)。
【角色】
一隻圓頭圓腦的橘色小貓,戴著藍色小圍巾,Q 版三頭身,
3D 卡通渲染,表面柔和有光澤,顏色明快。
(把這兩行換成你想要的角色,寫得越具體越好)
【整張圖的要求】
· 背景完全透明,不要背景色、不要格線、不要文字、不要投影
· 整張圖平均分成 8 欄 × 9 列,一共 72 個一樣大的方格
· 每格放一個動作格,角色在格子裡置中,四周留一點空隙
· 所有格子裡必須是同一隻角色,體型、配色、畫風完全一致
· 用不到的格子保持完全透明
【每一列畫什麼】
第 1 列 前 6 格:站著不動,輕微呼吸起伏
第 2 列 全 8 格:向右跑的完整循環,角色始終朝右
第 3 列 前 4 格:抬起手揮手打招呼
第 4 列 前 5 格:下蹲、起跳、落地
第 5 列 全 8 格:失落沮喪,垂頭嘆氣
第 6 列 前 6 格:原地等待,東張西望
第 7 列 前 6 格:低頭忙碌工作
第 8 列 全 8 格:頭和視線從正上方開始向右慢慢轉,經過右上、右邊、右下,轉到接近正下方
第 9 列 全 8 格:接著上一列,從正下方繼續向左轉,經過左下、左邊、左上,轉回接近正上方`,
'settings.pets.guide.promptCopy': '複製這段提示詞',
'settings.pets.guide.promptCopied': '複製好了',
'settings.pets.guide.copyFailed': '沒能自動複製,手動選取上面的文字複製一下。',
'settings.pets.guide.step2Title': '照著範本檢查一遍',
'settings.pets.guide.step2Body': '圖出來以後,對著下面這張範本看三件事。不對就讓 AI 重畫,別急著選進來。',
'settings.pets.guide.check1': '背景是透空的,不是白底——白底會在桌面上變成一個方塊',
'settings.pets.guide.check2': '橫著數 8 格,直著數 9 列,每格一個動作',
'settings.pets.guide.check3': '九列裡從頭到尾是同一隻,沒有中途換臉換配色',
'settings.pets.guide.templateAlt': '動作表範本:8 欄 9 列,標註了每一列該畫什麼',
'settings.pets.guide.templateExpand': '點圖片看完整範本',
'settings.pets.guide.templateCollapse': '點圖片收起',
'settings.pets.guide.saveTemplate': '把範本存下來',
'settings.pets.guide.saveFailed': '沒能儲存範本圖,稍後再試。',
'settings.pets.guide.step3Title': '選進來就好',
'settings.pets.guide.step3Body': '下一頁填個名字,然後選取剛才那張圖。尺寸不用自己調,長寬差一點也沒關係;「向左跑」那一列也會自動鏡像補上,不用你畫。',
'settings.pets.guide.continue': '畫好了,下一步',
'settings.pets.guide.backToSteps': '回去看步驟',
'settings.pets.createError': '沒能做成這隻寵物。',
'settings.pets.createError.invalidId': '寵物 ID 只能用小寫字母、數字和中間的連字號。',
'settings.pets.createError.duplicateId': '已經有一隻用這個 ID 的寵物了,換一個。',
'settings.pets.createError.unsupportedFormat': '請選 PNG 或 WebP 格式的圖片。',
'settings.pets.createError.imageTooLarge': '圖片太大了,請控制在 8 MB 以內。',
'settings.pets.createError.imageDimensions': '圖片長寬都要在 32 到 4096 像素之間,總像素數不超過 16,777,216。',
'settings.pets.createError.atlasDimensions': '這張圖沒辦法按 8 欄 × 9 列切開。請確認橫著 8 格、直著 9 列,或者直接用 1536×2288 的成品圖集。',
'settings.pets.createError.opaqueBackground': '這張圖沒有透明背景,做出來會是桌面上的一個方塊。請讓 AI 重新匯出透明背景的 PNG(不要白底)。',
'settings.pets.createError.invalidImage': '讀不出這張圖,請換一張靜態的 PNG 或 WebP(動圖不行)。',
'settings.pets.createError.storage': '沒能把寵物存到本機資料夾。',
'settings.pets.dialog.imageTitle': '選一張背景透明的寵物圖片',
'settings.pets.dialog.imageFilter': '寵物圖片',
'settings.pets.dialog.atlasTitle': '選擇 v2 寵物動畫圖集',
'settings.pets.dialog.atlasFilter': '寵物動畫圖集',
'settings.pets.dialog.atlasTitle': '選擇寵物動作圖',
'settings.pets.dialog.atlasFilter': '寵物動作圖',
'settings.pets.builtInTitle': '內建寵物',
'settings.pets.customTitle': '你的寵物',
'settings.pets.customEmpty': '還沒有自訂寵物。',
+76 -29
View File
@@ -281,42 +281,89 @@ export const zh: Record<TranslationKey, string> = {
'settings.pets.retry': '重试',
'settings.pets.refresh': '刷新',
'settings.pets.create': '添加宠物',
'settings.pets.createTitle': '创建自定义宠物',
'settings.pets.createMethodIntro': '选择制作方式。导入过程都在本地完成,不会调用当前聊天模型生成图片。',
'settings.pets.createTitle': '做一只自己的宠物',
'settings.pets.createMethodIntro': '选一种做法。图片都在你自己的电脑上处理,不会上传,也不会消耗你的对话额度。',
'settings.pets.createRecommended': '推荐',
'settings.pets.createImageTitle': '用一张图片制作轻动画',
'settings.pets.createImageDescription': '把一张透明背景 PNG 或 WebP 变成会轻柔活动的宠物。',
'settings.pets.createImageDetail': '本地处理 · 无需图片模型或视频生成',
'settings.pets.createImageHint': '选择一张透明背景、静态 PNG 或 WebP(单边 32–4096px、最大 8MB)。应用会在本地添加呼吸、漂浮和任务状态等轻动画;这不是逐帧生成的完整动画。',
'settings.pets.createImageSubmit': '选择图片并创建',
'settings.pets.createAtlasTitle': '导入专业动画图集',
'settings.pets.createAtlasDescription': '导入已经制作好的逐帧精灵图集,获得完整角色动作。',
'settings.pets.createAtlasDetail': '高级 · 必须是 1536×2288 的 v2 PNG 或 WebP',
'settings.pets.createAtlasSubmit': '选择图集并创建',
'settings.pets.createAiTitle': 'AI 生成完整动画',
'settings.pets.createAiDescription': '通过图片生成服务创作角色和动作帧。',
'settings.pets.createAiUnavailable': '当前未配置:需要单独的图片生成服务;不会使用当前聊天模型。',
'settings.pets.createBack': '重新选择制作方式',
'settings.pets.createImageTitle': '用一张现成的图',
'settings.pets.createImageDescription': '手里有一张角色图就能开始。它会轻轻呼吸、上下浮动,你忙的时候它也跟着动。',
'settings.pets.createImageDetail': '一分钟搞定 · 但它不会跑、也不会挥手',
'settings.pets.createImageBadge': '最省事',
'settings.pets.createImageHint': '待会儿选一张背景透明的 PNG 或 WebP(长宽都在 32 到 4096 像素之间,文件不超过 8MB)。这种宠物只会轻轻晃动,不会真的跑起来——想要能跑能跳会挥手,回上一页选「用 AI 画一只会跑会跳的」。',
'settings.pets.createImageSubmit': '选图片,做宠物',
'settings.pets.createAtlasTitle': '我已经有动作图了',
'settings.pets.createAtlasDescription': '直接把画好的多格动作图选进来,跳过教程。',
'settings.pets.createAtlasDetail': '尺寸不用自己算 · 选进来会自动对齐',
'settings.pets.createAtlasSubmit': '选动作图,做宠物',
'settings.pets.createAtlasHint': '待会儿选一张 8 格宽、9 行高的动作图。尺寸不用刚好,选进来会自动切格、缩放,还会帮你把「向左跑」那一行镜像补出来。已经做好的 1536×2288 成品图也能直接用,会原样保留。',
'settings.pets.createAiTitle': '用 AI 画一只会跑会跳的',
'settings.pets.createAiDescription': '照着三步走:复制一段提示词、让 AI 画图、把图选进来。提示词和对照模板都替你准备好了。',
'settings.pets.createAiDetail': '十来分钟 · 需要一个会画图的 AI',
'settings.pets.createBack': '换一种做法',
'settings.pets.createId': '宠物 ID',
'settings.pets.createIdHint': '仅使用小写字母、数字和单个连字符。',
'settings.pets.createIdHint': '给它一个英文小名,只能用小写字母、数字和中间的连字符,比如 moon-cat。',
'settings.pets.createName': '显示名称',
'settings.pets.createDescription': '宠物描述',
'settings.pets.createAtlasHint': '选择透明背景、无间距的 1536×2288 PNG/WebP:8列×11行,每格 192×208。行顺序为待机、向右跑、向左跑、挥手、跳跃、失败、等待、工作、复核,以及最后两行的 16 个视线方向。',
'settings.pets.createCancel': '取消',
'settings.pets.createSubmit': '创建',
'settings.pets.createError': '无法创建自定义宠物。',
'settings.pets.createError.invalidId': '宠物 ID 只能使用小写字母、数字和单个连字符。',
'settings.pets.createError.duplicateId': '已存在使用此 ID 的自定义宠物。',
'settings.pets.createError.unsupportedFormat': '请选择 PNG 或 WebP 图片。',
'settings.pets.createError.imageTooLarge': '所选图片不能超过 8 MB。',
'settings.pets.createError.imageDimensions': '宠物图片每边必须为 32–4096 像素,且总像素数不能超过 16,777,216。',
'settings.pets.createError.atlasDimensions': '动画图集尺寸必须恰好为 1536×2288 像素。',
'settings.pets.createError.invalidImage': '无法读取所选图片,请选择静态 PNG 或 WebP 图片。',
'settings.pets.createError.storage': '无法将宠物保存到自定义宠物目录。',
'settings.pets.dialog.imageTitle': '选择透明背景的宠物图片',
'settings.pets.guide.intro': '三步,大约十分钟,全程在你自己的电脑上完成。',
'settings.pets.guide.step1Title': '让 AI 画一张动作图',
'settings.pets.guide.step1Body': '打开一个会画图的 AI,把下面这段整个发给它。开头「角色」那两行换成你想要的样子,比如「一只戴黄色帽子的柴犬」。',
'settings.pets.guide.step1Tools': '即梦、Nano Banana、ChatGPT 画图、Midjourney、Stable Diffusion 都行(随手举例,不是推荐)。画得不对就让它重画,或者说「角色保持不变,只重画第 2 行」。',
'settings.pets.guide.prompt': `帮我画一张游戏角色动作表(sprite sheet)。
【角色】
一只圆头圆脑的橘色小猫,戴着蓝色小围巾,Q 版三头身,
3D 卡通渲染,表面柔和有光泽,颜色明快。
(把这两行换成你想要的角色,写得越具体越好)
【整张图的要求】
· 背景完全透明,不要背景色、不要格子线、不要文字、不要投影
· 整张图平均分成 8 列 × 9 行,一共 72 个一样大的方格
· 每格放一个动作帧,角色在格子里居中,四周留一点空隙
· 所有格子里必须是同一只角色,体型、配色、画风完全一致
· 用不到的格子保持完全透明
【每一行画什么】
第 1 行 前 6 格:站着不动,轻微呼吸起伏
第 2 行 全 8 格:向右跑的完整循环,角色始终朝右
第 3 行 前 4 格:抬起手挥手打招呼
第 4 行 前 5 格:下蹲、起跳、落地
第 5 行 全 8 格:失落沮丧,垂头叹气
第 6 行 前 6 格:原地等待,东张西望
第 7 行 前 6 格:低头忙碌工作
第 8 行 全 8 格:头和视线从正上方开始向右慢慢转,经过右上、右边、右下,转到接近正下方
第 9 行 全 8 格:接着上一行,从正下方继续向左转,经过左下、左边、左上,转回接近正上方`,
'settings.pets.guide.promptCopy': '复制这段提示词',
'settings.pets.guide.promptCopied': '复制好了',
'settings.pets.guide.copyFailed': '没能自动复制,手动选中上面的文字复制一下。',
'settings.pets.guide.step2Title': '照着模板检查一遍',
'settings.pets.guide.step2Body': '图出来以后,对着下面这张模板看三件事。不对就让 AI 重画,别急着选进来。',
'settings.pets.guide.check1': '背景是透空的,不是白底——白底会在桌面上变成一个方块',
'settings.pets.guide.check2': '横着数 8 格,竖着数 9 行,每格一个动作',
'settings.pets.guide.check3': '九行里从头到尾是同一只,没有中途换脸换配色',
'settings.pets.guide.templateAlt': '动作表模板:8 列 9 行,标注了每一行该画什么',
'settings.pets.guide.templateExpand': '点图片看完整模板',
'settings.pets.guide.templateCollapse': '点图片收起',
'settings.pets.guide.saveTemplate': '把模板存下来',
'settings.pets.guide.saveFailed': '没能保存模板图,稍后再试。',
'settings.pets.guide.step3Title': '选进来就好',
'settings.pets.guide.step3Body': '下一页填个名字,然后选中刚才那张图。尺寸不用自己调,长宽差一点也没关系;「向左跑」那一行也会自动镜像补上,不用你画。',
'settings.pets.guide.continue': '画好了,下一步',
'settings.pets.guide.backToSteps': '回去看步骤',
'settings.pets.createError': '没能做成这只宠物。',
'settings.pets.createError.invalidId': '宠物 ID 只能用小写字母、数字和中间的连字符。',
'settings.pets.createError.duplicateId': '已经有一只用这个 ID 的宠物了,换一个。',
'settings.pets.createError.unsupportedFormat': '请选 PNG 或 WebP 格式的图片。',
'settings.pets.createError.imageTooLarge': '图片太大了,请控制在 8 MB 以内。',
'settings.pets.createError.imageDimensions': '图片长宽都要在 32 到 4096 像素之间,总像素数不超过 16,777,216。',
'settings.pets.createError.atlasDimensions': '这张图没法按 8 列 × 9 行切开。请确认横着 8 格、竖着 9 行,或者直接用 1536×2288 的成品图集。',
'settings.pets.createError.opaqueBackground': '这张图没有透明背景,做出来会是桌面上的一个方块。请让 AI 重新导出透明背景的 PNG(不要白底)。',
'settings.pets.createError.invalidImage': '读不出这张图,请换一张静态的 PNG 或 WebP(动图不行)。',
'settings.pets.createError.storage': '没能把宠物存到本地文件夹。',
'settings.pets.dialog.imageTitle': '选一张背景透明的宠物图片',
'settings.pets.dialog.imageFilter': '宠物图片',
'settings.pets.dialog.atlasTitle': '选择 v2 宠物动画图集',
'settings.pets.dialog.atlasFilter': '宠物动画图集',
'settings.pets.dialog.atlasTitle': '选择宠物动作图',
'settings.pets.dialog.atlasFilter': '宠物动作图',
'settings.pets.builtInTitle': '内置宠物',
'settings.pets.customTitle': '你的宠物',
'settings.pets.customEmpty': '还没有自定义宠物。',
@@ -119,6 +119,12 @@ export const browserHost: DesktopHost = {
async createFromAtlas() {
unsupported('Creating custom pets')
},
async pickSourceSheet() {
unsupported('Creating custom pets')
},
async createFromAtlasBytes() {
unsupported('Creating custom pets')
},
async openFolder() {
unsupported('Opening the custom pets folder')
},
@@ -41,6 +41,14 @@ describe('desktop host contract', () => {
displayName: 'Moon Cat',
description: 'A quiet companion.',
})).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.pickSourceSheet({})).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.createFromAtlasBytes({
slug: 'moon-cat',
displayName: 'Moon Cat',
description: 'A quiet moonlight companion.',
atlasData: new Uint8Array([1, 2, 3]),
mimeType: 'image/png',
})).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.openFolder()).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.show()).rejects.toThrow('desktop app runtime')
await expect(browserHost.pets.hide()).rejects.toThrow('desktop app runtime')
@@ -113,6 +113,8 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
list: () => invoke(ELECTRON_IPC_CHANNELS.petsList),
createFromImage: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromImage, input),
createFromAtlas: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromAtlas, input),
pickSourceSheet: input => invoke(ELECTRON_IPC_CHANNELS.petsPickSourceSheet, input),
createFromAtlasBytes: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromAtlasBytes, input),
openFolder: () => invoke(ELECTRON_IPC_CHANNELS.petsOpenFolder),
show: () => invoke(ELECTRON_IPC_CHANNELS.petsShow),
hide: () => invoke(ELECTRON_IPC_CHANNELS.petsHide),
+29
View File
@@ -198,6 +198,31 @@ export type DesktopPetCreateResult =
| { id: string }
| { errorCode: string }
export type DesktopPetSheetPickInput = {
dialogTitle?: string
dialogFilterName?: string
}
/** Decoded pixels of a user-picked action sheet, ready to be normalized on a canvas. */
export type DesktopPetSourceSheet = {
bytes: Uint8Array
mimeType: 'image/png' | 'image/webp'
width: number
height: number
}
export type DesktopPetSheetPickResult =
| DesktopPetSourceSheet
| { errorCode: string }
export type DesktopPetCreateFromAtlasBytesInput = {
slug: string
displayName: string
description: string
atlasData: Uint8Array
mimeType: 'image/png' | 'image/webp'
}
export type DesktopPetWindowDrag = {
phase: 'start' | 'move' | 'end'
x: number
@@ -249,6 +274,10 @@ export type DesktopHost = {
list(): Promise<DesktopPetListResult>
createFromImage(input: DesktopPetCreateInput): Promise<DesktopPetCreateResult | null>
createFromAtlas(input: DesktopPetCreateInput): Promise<DesktopPetCreateResult | null>
pickSourceSheet(input: DesktopPetSheetPickInput): Promise<DesktopPetSheetPickResult | null>
createFromAtlasBytes(
input: DesktopPetCreateFromAtlasBytesInput,
): Promise<DesktopPetCreateResult | null>
openFolder(): Promise<void>
show(): Promise<void>
hide(): Promise<void>
+1 -1
View File
@@ -155,7 +155,7 @@
任务面板只显示正在工作、等待处理或失败的活跃任务;没有活跃任务时会自动隐藏。右键关闭宠物不会停止正在执行的任务,下次可以回到「设置 → 宠物」重新开启。
还可以通过「添加宠物」导入透明 PNG / WebP 单图,或导入符合规格的 v2 动画图集。导入在本地完成,不会调用当前聊天模型;AI 生成完整动画目前不可用。图片要求、创建步骤、存储位置和故障排查见[桌面宠物使用指南](./pets.md)。
还可以通过「添加宠物」做一只自己的:用一张透明 PNG / WebP 现成图,或按弹窗里的提示词让任意画图 AI 生成一张动作表再选进来(尺寸会自动对齐)。全程在本地完成,不会调用当前聊天模型,也不消耗对话额度。提示词、动作表模板、图片要求和故障排查见[桌面宠物使用指南](./pets.md)。
宠物仅在 Electron 桌面端运行,不会出现在 H5 页面中。窗口置顶、拖动与多显示器行为仍受操作系统窗口管理影响。
+7 -5
View File
@@ -182,13 +182,15 @@
### 添加自己的宠物
![添加宠物:单图轻动画、专业动画图集与暂不可用的 AI 生成路径](../images/desktop_ui/15_pet_create_methods.png)
![添加宠物:三种做法](../images/desktop_ui/15_pet_create_methods.png)
点击「添加宠物」,填写宠物 ID、显示名称和描述,再选择创建方式;前两种当前可用:
点击「添加宠物」,填写宠物 ID、显示名称和描述,再选择做法;三种都可用:
- **一张图片制作轻动画**:导入透明 PNG / WebP,由应用在本地添加呼吸、漂浮和任务状态等轻动画。
- **专业动画图集**:导入已经制作好的 `1536 × 2288` v2 图集,使用完整逐帧动作。
- **AI 生成完整动画**:当前不可用,需要独立的图片生成服务,不会调用当前聊天模型。
- **用一张现成的图**:导入透明 PNG / WebP,由应用在本地添加呼吸、漂浮和任务状态等轻动画。这种宠物不会跑动,也不会跟随鼠标。
- **用 AI 画一只会跑会跳的**:弹窗内给出可复制的提示词、对照模板和检查清单,用任意能画图的 AI 产出一张 8 列 × 9 行的动作表,再选进来。
- **我已经有动作图了**:跳过教程,直接导入已经画好的动作表或成品图集。
后两种路径导入时会在本地自动切格、缩放对齐、镜像补出向左跑的一帧,因此源图尺寸不必精确;已经是 `1536 × 2288` 的成品图集会原样保留。
创建成功后,新宠物会自动被选中。自定义宠物保存在本机 `~/.claude/cc-haha/pets`(设置 `CLAUDE_CONFIG_DIR` 时跟随该目录);当前没有界面内删除按钮,需要通过「打开文件夹」移除对应宠物包后再刷新。
+5 -6
View File
@@ -199,7 +199,7 @@ Windows 签名不是当前 Release 的强制发布条件。确认安装包来自
### 单张图片能生成完整宠物动画吗?
不能。透明 PNG / WebP 单图只会在本地添加呼吸、漂浮和状态等轻动画。完整逐帧动作需要符合规格的 v2 动画图集;AI 生成路径需要单独的图片生成服务,不会调用当前聊天模型。
不能。透明 PNG / WebP 单图只会在本地添加呼吸、漂浮和状态等轻动画。完整逐帧动作需要一张 8 列 × 9 行的动作表——可以用「用 AI 画一只会跑会跳的」路径,按弹窗里的提示词让任意画图 AI 生成,再选进来;导入时会自动切格对齐。
宠物只运行在 Electron 桌面端,不显示在 H5。
@@ -220,7 +220,8 @@ Windows 签名不是当前 Release 的强制发布条件。确认安装包来自
逐项检查:
- 单图必须是 PNG 或 WebP,单边为 32–4096 像素,文件不超过 8 MB,总像素不超过 16,777,216。
- v2 动画图集必须恰好为 1536×2288 像素,并符合 8 列 × 11 行的帧布局。
- 动作表必须是 8 列 × 9 行的布局;尺寸不必精确,导入时会自动切格缩放。已经是 1536×2288 的成品图集会原样保留。
- 动作表必须有透明背景。白底或彩底会被拒绝,因为宠物会在桌面上显示成一个方块。
- 宠物 ID 最多 73 个字符,只能包含小写字母、数字和单个连字符,且不能与已有 ID 重复。
- 显示名称与描述不能为空。
@@ -238,11 +239,9 @@ Windows 签名不是当前 Release 的强制发布条件。确认安装包来自
只删除 `${CLAUDE_CONFIG_DIR:-~/.claude}/cc-haha/pets` 下确认属于该宠物的目录,不要删除整个 `~/.claude`。内置宠物随应用提供,不在这里删除。
### 为什么“AI 生成完整动画”不可用?
### 「用 AI 画一只会跑会跳的」会消耗我的对话额度吗?
该入口需要单独配置图片生成服务,当前聊天模型不会被拿来生成宠物图片或动作帧。未配置对应服务时,入口会明确显示不可用。
现在可以使用推荐的单图轻动画,或导入已经制作好的 v2 动画图集。
不会。这条路径不调用当前会话选择的聊天模型,应用也不会替你请求任何图片生成服务。它给你的是一段提示词和一张对照模板,图由你自己在任意画图工具里生成,再把文件选进来;整理和导入都在本机完成。
### 能在宠物窗口里批准权限或直接回复吗?
+99 -19
View File
@@ -55,9 +55,9 @@
## 添加自定义宠物
在「你的宠物」右侧点击「添加宠物」,可以选择两条本地导入路径。
在「你的宠物」右侧点击「添加宠物」,可以选择三种做法。图片全程在你自己的电脑上处理,不会上传,也不会消耗对话额度。
![创建自定义宠物:单图轻动画、专业动画图集和暂不可用的 AI 生成](../images/desktop_ui/15_pet_create_methods.png)
![创建自定义宠物:三种做法](../images/desktop_ui/15_pet_create_methods.png)
无论选择哪种方式,都需要填写:
@@ -69,9 +69,9 @@
![成功导入并选中的自定义宠物](../images/desktop_ui/16_pet_custom_result.png)
### 用一张图片制作轻动画
### 做法一:用一张现成的图
这是普通用户最容易使用的方式。选择一张透明背景的静态 PNG 或 WebP,应用会在本地添加呼吸、漂浮和任务状态等轻动画。
最省事,大约一分钟。选一张背景透明的静态 PNG 或 WebP,应用会在本地加上呼吸、漂浮和任务状态等轻动画。
图片需要满足:
@@ -80,29 +80,109 @@
- 总像素数不超过 `16,777,216`。
- 文件大小不超过 `8 MB`。
单图轻动画不是 AI 生成的逐帧动画,也不会调用当前聊天模型。
这种宠物只会轻轻晃动,**不会真的跑起来,也不会跟着鼠标转头**。想要完整动作,用下面的做法二。
### 导入 v2 动画图集
### 做法二:用 AI 画一只会跑会跳的
已经制作好完整动作帧时,可以导入 v2 动画图集。图片必须满足:
大约十分钟,需要一个能画图的 AI。弹窗里会把提示词、对照模板和检查清单都列出来,跟着走即可。
| 项目 | 规格 |
|------|------|
| 格式 | 静态、透明背景的 PNG 或 WebP |
| 总尺寸 | 精确为 `1536 × 2288` |
| 网格 | `8` 列 × `11` 行,无单元格间距 |
| 单格尺寸 | `192 × 208` |
#### 第一步:让 AI 画一张动作表
动作行的顺序依次是:待机、向右跑、向左跑、挥手、跳跃、失败、等待、工作、复核,以及最后两行的 16 个视线方向。
打开任意能画图的 AI(即梦、Nano Banana、ChatGPT 画图、Midjourney、Stable Diffusion 都可以;这里只是举例,不构成推荐),把下面这段整个发给它,并把「角色」两行换成你想要的样子:
「复核」行属于图集格式的预留动作,不代表当前运行时会自动显示一个独立的复核状态。
```text
帮我画一张游戏角色动作表(sprite sheet)。
【角色】
一只圆头圆脑的橘色小猫,戴着蓝色小围巾,Q 版三头身,
3D 卡通渲染,表面柔和有光泽,颜色明快。
(把这两行换成你想要的角色,写得越具体越好)
【整张图的要求】
· 背景完全透明,不要背景色、不要格子线、不要文字、不要投影
· 整张图平均分成 8 列 × 9 行,一共 72 个一样大的方格
· 每格放一个动作帧,角色在格子里居中,四周留一点空隙
· 所有格子里必须是同一只角色,体型、配色、画风完全一致
· 用不到的格子保持完全透明
【每一行画什么】
第 1 行 前 6 格:站着不动,轻微呼吸起伏
第 2 行 全 8 格:向右跑的完整循环,角色始终朝右
第 3 行 前 4 格:抬起手挥手打招呼
第 4 行 前 5 格:下蹲、起跳、落地
第 5 行 全 8 格:失落沮丧,垂头叹气
第 6 行 前 6 格:原地等待,东张西望
第 7 行 前 6 格:低头忙碌工作
第 8 行 全 8 格:头和视线从正上方开始向右慢慢转,经过右上、右边、右下,转到接近正下方
第 9 行 全 8 格:接着上一行,从正下方继续向左转,经过左下、左边、左上,转回接近正上方
```
一次画不好很正常。可以让它重画,或者说「角色保持不变,只重画第 2 行」。
#### 第二步:对照模板检查
![动作表模板:8 列 9 行,标注了每一行该画什么](../images/desktop_ui/17_pet_action_sheet_zh.png)
图出来以后,先看三件事,不对就让 AI 重画:
1. **背景是透空的,不是白底。** 白底会在桌面上变成一个方块,这是最常见的问题。
2. **横着 8 格、竖着 9 行**,每格一个动作。
3. **九行里从头到尾是同一只**,没有中途换脸、换配色、换体型。
弹窗里点「把模板存下来」可以把这张模板保存到本地,方便对照排图。
#### 第三步:选进来
填好 ID、名称和描述,选中刚才那张图即可。**尺寸不用自己调**,详见下面的「尺寸会自动对齐」。
### 做法三:我已经有动作图了
已经画好动作表、或者手里有做好的成品图集时,用这条路径可以跳过教程,直接填表选图。校验规则和做法二完全一致。
### 动作表要画什么
作者只需要画 **9 行**,其余两行由应用补齐:
| 行 | 内容 | 需要的帧数 |
|----|------|-----------|
| 1 | 待机:站着不动,轻微呼吸起伏 | 6 |
| 2 | 向右跑:完整跑步循环,始终朝右 | 8 |
| 3 | 挥手:抬手打招呼 | 4 |
| 4 | 跳跃:下蹲 → 起跳 → 落地 | 5 |
| 5 | 失败:沮丧、垂头、叹气 | 8 |
| 6 | 等待:东张西望、原地踱步 | 6 |
| 7 | 工作:低头忙碌 | 6 |
| 8 | 视线上半圈:从正上方顺时针转到接近正下方 | 8 |
| 9 | 视线下半圈:从正下方继续转回接近正上方 | 8 |
几点说明:
- **「向左跑」不用画。** 应用会把第 2 行水平镜像,自动生成向左跑的帧。
- **最后两行懒得画也行。** 直接重复待机的第一帧即可,宠物只是不会跟着鼠标转头,其他动作照常。
- 每行右侧用不到的格子保持完全透明。
### 尺寸会自动对齐
选图之后,应用会在本地把动作表整理成运行时需要的 `1536 × 2288` 图集:按 8 列 × 9 行切格、等比缩放到每格 `192 × 208`、把角色在格子里居中、镜像补出向左跑的一行,并补齐运行时用到的其余行。
因此:
- **长宽不必精确。** AI 出的常见尺寸(如 `1024 × 1152`)都可以,比例接近 8:9 时效果最好。参考尺寸是 `1536 × 1872`。
- **已经是 `1536 × 2288` 的成品图集会原样保留**,不会被重新缩放。
- 整理后的文件不超过 `8 MB`;超出时会自动改用 WebP 编码。
### 常见问题
| 提示 | 原因和处理 |
|------|-----------|
| 这张图没有透明背景…… | 图是白底或彩底导出的。让 AI 重新导出透明背景的 PNG,或用抠图工具去掉背景。 |
| 这张图没法按 8 列 × 9 行切开 | 行列数对不上。确认横着 8 格、竖着 9 行,或改用 `1536 × 2288` 的成品图集。 |
| 读不出这张图 | 选到了动图(APNG / 动态 WebP)或损坏文件。换一张静态 PNG 或 WebP。 |
| 图片太大了 | 原图超过 8 MB。先压缩再选,或让 AI 输出小一点的尺寸。 |
| 已经有一只用这个 ID 的宠物了 | 换一个宠物 ID,或先删掉原来那只(见下面的「存储与删除」)。 |
尺寸、格式或图像内容不符合要求时,应用会拒绝创建并显示对应错误,不会把无效图片加入宠物列表。
### AI 生成为什么不可选
「AI 生成完整动画」目前只是预留入口,需要单独配置图片生成服务,当前版本还不能在这里直接使用。它不会自动调用当前会话选择的 Claude、ChatGPT、Grok 或其他聊天模型。
## 存储与删除
自定义宠物包默认保存在:
+1 -1
View File
@@ -123,7 +123,7 @@ Open **Settings → Pets** and enable **Show desktop pet**.
3. Enable the active-task panel if you want it to open automatically while a task is running. The panel remains hidden when no task is active.
4. Hover for a small reaction, click the pet to focus the main window, or drag it to another position. Right-click the pet to close it; return to **Settings → Pets** to show it again.
Select **Add pet** to make a lightweight local animation from a transparent PNG or WebP, or import a supported `1536×2288` v2 animation atlas. The AI full-animation option is currently unavailable. Pets run only in the Electron desktop app and are not supported in H5.
Select **Add pet** to make one of your own: use a transparent PNG or WebP you already have, or follow the in-dialog prompt to have any drawing AI produce an action sheet and pick that (sizes are aligned for you). Everything happens locally, without calling the current chat model or using chat quota. Pets run only in the Electron desktop app and are not supported in H5.
Follow the complete [Desktop Pet Guide](./pets.md) for custom-image requirements, task states, and interaction details.
+6 -4
View File
@@ -110,11 +110,13 @@ The window also supports direct interaction:
Select **Add pet** to choose a local creation path.
![Add pet dialog with single-image animation, professional atlas, and unavailable AI generation options](../../images/desktop_ui/15_pet_create_methods.png)
![Add pet dialog with three ways to make a pet](../../images/desktop_ui/15_pet_create_methods.png)
- **Animate one image** turns a static transparent PNG or WebP into a lightweight locally animated pet.
- **Import professional animation atlas** accepts the supported `1536×2288` v2 sprite-sheet format.
- **Generate full animation with AI** is visible for context but is currently unavailable.
- **Use a picture you already have** turns a static transparent PNG or WebP into a lightweight locally animated pet. It will not run or track the cursor.
- **Draw one with AI that runs and jumps** supplies a copyable prompt, a reference template and a checklist, so any drawing AI can produce an 8-column × 9-row action sheet you then pick.
- **I already have an action sheet** skips the walkthrough and imports a sheet or finished atlas directly.
The last two paths slice, rescale and mirror the sheet locally on import, so exact source dimensions are not required; a finished `1536×2288` atlas is kept as-is.
Imports stay local and do not call the selected chat model. After a successful import, the custom companion appears under **Your pets** and becomes the selected pet.
+4 -5
View File
@@ -92,7 +92,8 @@ A custom pet made from one image receives lightweight breathing, floating, and t
Check each requirement:
- A single image must be PNG or WebP, 32–4096 pixels on each side, no larger than 8 MB, and no more than 16,777,216 total pixels.
- A v2 animation atlas must be exactly 1536×2288 pixels and use the documented 8-column × 11-row frame layout.
- An action sheet must use an 8-column × 9-row layout. Exact pixel dimensions are not required — it is sliced and rescaled on import. A finished 1536×2288 atlas is kept as-is.
- An action sheet must have a transparent background. White or coloured backdrops are rejected because the pet would render as a square on the desktop.
- The pet ID may be at most 73 characters, contain only lowercase letters, numbers, and single hyphens, and must not duplicate an existing ID.
- Display name and description are required.
@@ -110,11 +111,9 @@ There is no in-app delete button in the current version. Select a built-in pet f
Delete only the confirmed pet directory under `${CLAUDE_CONFIG_DIR:-~/.claude}/cc-haha/pets`. Do not delete the entire `~/.claude` directory. Built-in pets ship with the app and are not removed here.
### Why is “Generate full animation with AI” unavailable?
### Does “Draw one with AI that runs and jumps” use my chat quota?
That path requires a separately configured image-generation service. The current chat model is not used to generate pet artwork or action frames, so the option is explicitly unavailable when no suitable service is configured.
You can use the recommended single-image animation path now, or import a prepared v2 animation atlas.
No. That path never calls the chat model selected for the session, and the app does not request any image-generation service on your behalf. It gives you a prompt and a reference template; you generate the picture in whichever drawing tool you like and then pick the file. Assembly and import both happen on your own machine.
### Can the pet approve permissions or send replies?
+101 -22
View File
@@ -40,9 +40,9 @@ The animation switch affects the pet only. Turning it off does not stop sessions
## Create a custom pet
Select **Add pet** under **Your pets**. The creation dialog offers two working local import paths and shows a third, currently unavailable AI path.
Select **Add pet** under **Your pets**. The dialog offers three ways to make one. Images are processed entirely on your own computer: nothing is uploaded, and no chat quota is used.
![Custom Pet creation dialog with local image, sprite-sheet, and unavailable AI options](../../images/desktop_ui/15_pet_create_methods.png)
![Custom pet creation dialog with three ways to make a pet](../../images/desktop_ui/15_pet_create_methods.png)
Before choosing a file, enter:
@@ -52,41 +52,120 @@ Before choosing a file, enter:
The ID must be unique among your custom pets.
### Option 1: animate one image
### Option 1: use a picture you already have
This is the recommended path for most users.
The quickest route, about a minute. Pick a static image with a transparent background and the app adds breathing, floating and status motion locally.
Use a static image with:
The image must have:
- PNG or WebP format;
- a transparent background for the cleanest floating-window result;
- PNG or WebP format (APNG and animated WebP are rejected);
- width and height between `32px` and `4096px`;
- no more than `16,777,216` total pixels;
- a file size no larger than `8MB`.
Claude Code Haha copies and validates the image locally, then adds lightweight breathing, floating, and status motion. It does not invent new action frames, and it does not call the model selected for chat.
This kind of pet only sways gently. It **will not run, and it will not track your cursor**. For full motion, use option 2.
### Option 2: import a v2 animation atlas
### Option 2: draw one with AI that runs and jumps
Use this path only when you already have a correctly prepared frame atlas.
About ten minutes, and you need an AI that can draw. The dialog walks you through the prompt, the reference template and the checks.
The atlas must be a static PNG or WebP with:
#### Step 1: have an AI draw an action sheet
| Property | Required value |
|---|---|
| Full size | Exactly `1536×2288` |
| Grid | `8` columns × `11` rows |
| Cell size | `192×208` |
| Spacing | No padding or gaps between cells |
| Background | Transparent |
Open any AI that can draw (Nano Banana, ChatGPT image generation, Midjourney, Stable Diffusion and Jimeng all work — these are examples, not endorsements) and send it the whole block below, replacing the two "Character" lines with what you want:
Rows are assigned to idle, run right, run left, wave, jump, failed, waiting, working, review, and two rows containing 16 gaze directions. The review row is part of the asset format; its presence does not guarantee that the current runtime will surface a separate review state.
```text
Draw me a game character action sheet (sprite sheet).
Every row may use fewer than eight visible action frames, but the grid dimensions and row positions must remain unchanged.
[Character]
A round-headed orange kitten wearing a small blue scarf, chibi
three-heads-tall proportions, 3D cartoon render, soft glossy
surface, bright cheerful colours.
(Replace these lines with your own character - the more specific the better)
### AI animation is not available
[Whole image]
- Fully transparent background: no backdrop colour, no grid lines, no text, no drop shadow
- Divide the image evenly into 8 columns x 9 rows, 72 equally sized cells
- One action frame per cell, character centred with a little margin around it
- Every cell must show the same character with identical proportions, colours and art style
- Leave unused cells fully transparent
**AI-generate full animation** is displayed as an unavailable future path. It requires a separate image-generation service and does not fall back to the current chat model. A disabled card in this dialog is therefore expected behavior, not a provider configuration error.
[What to draw in each row]
Row 1, first 6 cells: standing still with a gentle breathing bob
Row 2, all 8 cells: a full run cycle facing right, always facing right
Row 3, first 4 cells: raising a hand and waving hello
Row 4, first 5 cells: crouch, leap, land
Row 5, all 8 cells: dejected and downcast, head lowered, sighing
Row 6, first 6 cells: waiting in place, glancing around
Row 7, first 6 cells: head down, busy working
Row 8, all 8 cells: head and gaze starting straight up, turning slowly to the right through upper-right, right and lower-right, ending near straight down
Row 9, all 8 cells: continuing from straight down, turning left through lower-left, left and upper-left, back to near straight up
```
Getting it wrong on the first try is normal. Ask for a redraw, or say "keep the character, redraw row 2 only".
#### Step 2: check it against the template
![Action sheet template: 8 columns by 9 rows, labelled with what each row should contain](../../images/desktop_ui/17_pet_action_sheet_en.png)
When the picture is ready, check three things before importing:
1. **The background is see-through, not white.** A white backdrop becomes a square on your desktop; this is the most common mistake.
2. **8 cells across, 9 rows down**, one action per cell.
3. **The same character throughout**, with no change of face, colours or proportions.
**Save the template** in the dialog writes this reference image to disk so you can lay out frames against it.
#### Step 3: pick the file
Fill in the ID, name and description, then select the picture. **You do not need to resize anything** — see "Sizes are aligned for you" below.
### Option 3: I already have an action sheet
If you have drawn a sheet already, or hold a finished atlas, this path skips the walkthrough and goes straight to the form. Validation is identical to option 2.
### What to draw in each row
You only draw **nine rows**; the app derives the rest:
| Row | Content | Frames needed |
|-----|---------|---------------|
| 1 | Idle: standing still with a gentle breathing bob | 6 |
| 2 | Run right: full run cycle, always facing right | 8 |
| 3 | Wave: raise a hand and greet | 4 |
| 4 | Jump: crouch, leap, land | 5 |
| 5 | Fail: discouraged, head down, sighing | 8 |
| 6 | Wait: looking around, shifting in place | 6 |
| 7 | Work: head down, busy | 6 |
| 8 | Gaze, upper half: straight up turning clockwise to near straight down | 8 |
| 9 | Gaze, lower half: continuing from straight down back to near straight up | 8 |
Notes:
- **You do not draw "run left".** The app mirrors row 2 horizontally to produce it.
- **The last two rows are optional in practice.** Repeat the first idle frame and the pet simply will not track your cursor; everything else still works.
- Leave unused cells at the right of each row fully transparent.
### Sizes are aligned for you
After you pick the file, the app assembles the runtime atlas locally: it slices the sheet on an 8 × 9 grid, rescales each cell to `192 × 208`, centres the character, mirrors the run row, and fills in the remaining runtime rows to reach `1536 × 2288`.
That means:
- **Exact dimensions are not required.** Common AI output sizes such as `1024 × 1152` work; a ratio close to 8:9 gives the best result. The reference size is `1536 × 1872`.
- **A finished `1536 × 2288` atlas is kept byte-for-byte** and is never resampled.
- The assembled file stays under `8MB`; WebP encoding is used automatically if a lossless PNG would exceed it.
### Common problems
| Message | Cause and fix |
|---------|---------------|
| This image has no transparent background… | The sheet was exported on a white or coloured backdrop. Ask for a transparent PNG, or remove the background with an editor. |
| This image cannot be sliced into 8 columns by 9 rows | The row or column count is off. Confirm 8 across and 9 down, or use a finished `1536 × 2288` atlas. |
| That image could not be read | An animated file (APNG / animated WebP) or a corrupt one. Use a static PNG or WebP. |
| That image is too big | The source exceeds 8 MB. Compress it, or ask for smaller output. |
| A pet with this ID already exists | Choose a different Pet ID, or remove the existing one (see "Storage and removal"). |
When the size, format, or image content does not meet these rules, the app refuses to create the pet and shows the matching error instead of adding an invalid entry.
After a successful import, the new pet is selected automatically and appears under **Your pets**.
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB