mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
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.
1510 lines
49 KiB
TypeScript
1510 lines
49 KiB
TypeScript
import { constants, type Dirent } from 'node:fs'
|
|
import { lstat, mkdir, mkdtemp, open, opendir, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
export const CUSTOM_PET_SPRITESHEET_WIDTH = 1536
|
|
export const CUSTOM_PET_SPRITESHEET_HEIGHT = 2288
|
|
export const CUSTOM_PET_SPRITESHEET_PIXELS =
|
|
CUSTOM_PET_SPRITESHEET_WIDTH * CUSTOM_PET_SPRITESHEET_HEIGHT
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION = 32
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION = 4096
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS =
|
|
CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION * CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION = 1
|
|
export const CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION = 1
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE = 'soft-spring-v1' as const
|
|
export const CUSTOM_PET_FOLDER_MAX_LENGTH = 73
|
|
export const DEFAULT_CUSTOM_PET_MAX_ENTRIES = 128
|
|
export const DEFAULT_CUSTOM_PET_MAX_DECODED_PIXELS = CUSTOM_PET_SPRITESHEET_PIXELS * 16
|
|
export const DEFAULT_CUSTOM_PET_MAX_MANIFEST_BYTES = 64 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_TOTAL_DATA_URL_BYTES = 12 * 1024 * 1024
|
|
|
|
const MAX_DISPLAY_NAME_LENGTH = 80
|
|
const MAX_DESCRIPTION_LENGTH = 500
|
|
const MAX_IMAGE_PATH_LENGTH = 512
|
|
const MIN_CUSTOM_PET_IMAGE_BYTES = 20
|
|
const PET_FOLDER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/
|
|
|
|
export type CustomPetImageMimeType = 'image/png' | 'image/webp'
|
|
|
|
export type ImageSize = {
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
export type ImageSizeInspectorInput = {
|
|
data: Buffer
|
|
mimeType: CustomPetImageMimeType
|
|
}
|
|
|
|
export type ImageSizeInspector = (
|
|
input: ImageSizeInspectorInput,
|
|
) => ImageSize | Promise<ImageSize>
|
|
|
|
type LoadedCustomPetBase = {
|
|
id: string
|
|
displayName: string
|
|
description: string
|
|
mimeType: CustomPetImageMimeType
|
|
dataUrl: string
|
|
}
|
|
|
|
export type LoadedCustomAtlasPet = LoadedCustomPetBase & {
|
|
spriteVersionNumber: 2
|
|
spritesheetPath: string
|
|
}
|
|
|
|
export type LoadedCustomImagePet = LoadedCustomPetBase & {
|
|
manifestVersion: 1
|
|
spriteVersionNumber: 1
|
|
imagePath: string
|
|
motionProfile: typeof CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
}
|
|
|
|
export type LoadedCustomPet = LoadedCustomAtlasPet | LoadedCustomImagePet
|
|
|
|
export type CustomPetLoadErrorCode =
|
|
| 'root-invalid'
|
|
| 'entry-limit'
|
|
| 'invalid-id'
|
|
| 'symlink-entry'
|
|
| 'missing-manifest'
|
|
| 'symlink-manifest'
|
|
| 'manifest-too-large'
|
|
| 'invalid-manifest'
|
|
| 'invalid-display-name'
|
|
| 'invalid-description'
|
|
| 'invalid-manifest-version'
|
|
| 'invalid-renderer'
|
|
| 'invalid-sprite-version'
|
|
| 'invalid-spritesheet-path'
|
|
| 'invalid-image-path'
|
|
| 'unsupported-image-format'
|
|
| 'missing-image'
|
|
| 'symlink-image'
|
|
| 'image-too-large'
|
|
| 'total-image-bytes-exceeded'
|
|
| 'decode-budget-exceeded'
|
|
| 'invalid-image'
|
|
| 'invalid-image-dimensions'
|
|
| 'duplicate-id'
|
|
| 'directory-changed'
|
|
| 'io-error'
|
|
|
|
export type CustomPetLoadError = {
|
|
entry?: string
|
|
code: CustomPetLoadErrorCode
|
|
message: string
|
|
}
|
|
|
|
export type CustomPetLoadResult = {
|
|
root: string
|
|
pets: LoadedCustomPet[]
|
|
errors: CustomPetLoadError[]
|
|
}
|
|
|
|
export type CustomPetCatalogLoader = (() => Promise<CustomPetLoadResult>) & {
|
|
invalidate: () => void
|
|
invalidateAfter: <T>(mutation: () => Promise<T>) => Promise<T>
|
|
}
|
|
|
|
export function createCustomPetCatalogLoader(
|
|
load: () => Promise<CustomPetLoadResult>,
|
|
): CustomPetCatalogLoader {
|
|
let inFlight: Promise<CustomPetLoadResult> | null = null
|
|
const loadCatalog = (() => {
|
|
if (inFlight) return inFlight
|
|
const current = load()
|
|
inFlight = current
|
|
void current.finally(() => {
|
|
if (inFlight === current) inFlight = null
|
|
}).catch(() => undefined)
|
|
return current
|
|
}) as CustomPetCatalogLoader
|
|
loadCatalog.invalidate = () => {
|
|
inFlight = null
|
|
}
|
|
loadCatalog.invalidateAfter = async mutation => {
|
|
const result = await mutation()
|
|
loadCatalog.invalidate()
|
|
return result
|
|
}
|
|
return loadCatalog
|
|
}
|
|
|
|
export type CustomPetsRootOptions = {
|
|
root?: string
|
|
env?: NodeJS.ProcessEnv
|
|
homeDir?: string
|
|
}
|
|
|
|
export type LoadCustomPetsOptions = CustomPetsRootOptions & {
|
|
inspectImageSize?: ImageSizeInspector
|
|
maxEntries?: number
|
|
maxManifestBytes?: number
|
|
maxImageBytes?: number
|
|
maxTotalImageBytes?: number
|
|
maxTotalDataUrlBytes?: number
|
|
maxDecodedPixels?: number
|
|
}
|
|
|
|
export type CreateCustomPetFromAtlasInput = {
|
|
slug: string
|
|
displayName: string
|
|
description: string
|
|
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
|
|
}
|
|
|
|
export type CreateCustomPetFromImageInput = {
|
|
slug: string
|
|
displayName: string
|
|
description: string
|
|
imagePath: string
|
|
motionProfile?: typeof CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
}
|
|
|
|
export type CreateCustomPetFromImageOptions = CustomPetsRootOptions & {
|
|
inspectImageSize?: ImageSizeInspector
|
|
}
|
|
|
|
type SanitizedAtlasManifest = Omit<LoadedCustomAtlasPet, 'mimeType' | 'dataUrl'>
|
|
type SanitizedImageManifest = Omit<LoadedCustomImagePet, 'mimeType' | 'dataUrl'>
|
|
type SanitizedManifest = SanitizedAtlasManifest | SanitizedImageManifest
|
|
|
|
type ManifestCandidate = {
|
|
entry: string
|
|
imagePath: string
|
|
mimeType: CustomPetImageMimeType
|
|
imageKind: 'atlas-v2' | 'single-image'
|
|
directoryIdentities: DirectoryIdentity[]
|
|
metadata: SanitizedManifest
|
|
}
|
|
|
|
type DirectoryIdentity = {
|
|
directoryPath: string
|
|
realPath: string
|
|
dev: number
|
|
ino: number
|
|
missingCode: CustomPetLoadErrorCode
|
|
symlinkCode: CustomPetLoadErrorCode
|
|
invalidCode: CustomPetLoadErrorCode
|
|
changedCode: CustomPetLoadErrorCode
|
|
invalidMessage: string
|
|
changedMessage: string
|
|
}
|
|
|
|
class PetPackageError extends Error {
|
|
readonly code: CustomPetLoadErrorCode
|
|
|
|
constructor(code: CustomPetLoadErrorCode, message: string) {
|
|
super(message)
|
|
this.name = 'PetPackageError'
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
export function getPetPackageErrorCode(error: unknown): CustomPetLoadErrorCode {
|
|
return error instanceof PetPackageError ? error.code : 'io-error'
|
|
}
|
|
|
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
return error instanceof Error && 'code' in error
|
|
}
|
|
|
|
function normalizeLimit(value: number | undefined, fallback: number): number {
|
|
return Number.isSafeInteger(value) && value !== undefined && value >= 0
|
|
? value
|
|
: fallback
|
|
}
|
|
|
|
function resolveHomePath(input: string, homeDir: string): string {
|
|
if (input === '~') return homeDir
|
|
if (input.startsWith(`~${path.sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
|
|
return path.join(homeDir, input.slice(2))
|
|
}
|
|
return input
|
|
}
|
|
|
|
export function resolveCustomPetsRoot(options: CustomPetsRootOptions = {}): string {
|
|
if (options.root) return path.resolve(options.root)
|
|
|
|
const env = options.env ?? process.env
|
|
const homeDir = path.resolve(options.homeDir ?? os.homedir())
|
|
const configuredRoot = env.CLAUDE_CONFIG_DIR?.trim()
|
|
const claudeConfigDir = configuredRoot
|
|
? path.resolve(resolveHomePath(configuredRoot, homeDir))
|
|
: path.join(homeDir, '.claude')
|
|
return path.join(claudeConfigDir, 'cc-haha', 'pets')
|
|
}
|
|
|
|
export async function ensureCustomPetsRoot(options: CustomPetsRootOptions = {}): Promise<string> {
|
|
const root = resolveCustomPetsRoot(options)
|
|
await mkdir(root, { recursive: true, mode: 0o700 })
|
|
const rootStat = await lstat(root)
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
throw new Error('Custom pets root must be a real directory')
|
|
}
|
|
return root
|
|
}
|
|
|
|
function sanitizeErrorEntry(entry: string): string | undefined {
|
|
if (!entry || entry.length > 256 || CONTROL_CHARACTER_PATTERN.test(entry)) return undefined
|
|
return entry
|
|
}
|
|
|
|
function packageError(entry: string, error: unknown): CustomPetLoadError {
|
|
const safeEntry = sanitizeErrorEntry(entry)
|
|
const normalized = error instanceof PetPackageError
|
|
? { code: error.code, message: error.message }
|
|
: { code: 'io-error' as const, message: 'Unable to read this custom pet package.' }
|
|
return safeEntry ? { entry: safeEntry, ...normalized } : normalized
|
|
}
|
|
|
|
function rootError(code: CustomPetLoadErrorCode, message: string): CustomPetLoadError {
|
|
return { code, message }
|
|
}
|
|
|
|
type DirectoryIdentityOptions = Pick<
|
|
DirectoryIdentity,
|
|
| 'missingCode'
|
|
| 'symlinkCode'
|
|
| 'invalidCode'
|
|
| 'changedCode'
|
|
| 'invalidMessage'
|
|
| 'changedMessage'
|
|
>
|
|
|
|
async function captureDirectoryIdentity(
|
|
directoryPath: string,
|
|
options: DirectoryIdentityOptions,
|
|
): Promise<DirectoryIdentity> {
|
|
let beforeStat
|
|
try {
|
|
beforeStat = await lstat(directoryPath)
|
|
} catch {
|
|
throw new PetPackageError(options.missingCode, options.invalidMessage)
|
|
}
|
|
if (beforeStat.isSymbolicLink()) {
|
|
throw new PetPackageError(options.symlinkCode, options.invalidMessage)
|
|
}
|
|
if (!beforeStat.isDirectory()) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
let resolvedPath: string
|
|
let afterStat
|
|
try {
|
|
resolvedPath = await realpath(directoryPath)
|
|
afterStat = await lstat(directoryPath)
|
|
} catch {
|
|
throw new PetPackageError(options.changedCode, options.changedMessage)
|
|
}
|
|
if (
|
|
afterStat.isSymbolicLink() ||
|
|
!afterStat.isDirectory() ||
|
|
beforeStat.dev !== afterStat.dev ||
|
|
beforeStat.ino !== afterStat.ino
|
|
) {
|
|
throw new PetPackageError(options.changedCode, options.changedMessage)
|
|
}
|
|
|
|
return {
|
|
...options,
|
|
directoryPath,
|
|
realPath: path.resolve(resolvedPath),
|
|
dev: afterStat.dev,
|
|
ino: afterStat.ino,
|
|
}
|
|
}
|
|
|
|
async function assertDirectoryIdentity(identity: DirectoryIdentity): Promise<void> {
|
|
const current = await captureDirectoryIdentity(identity.directoryPath, identity)
|
|
if (
|
|
current.realPath !== identity.realPath ||
|
|
current.dev !== identity.dev ||
|
|
current.ino !== identity.ino
|
|
) {
|
|
throw new PetPackageError(identity.changedCode, identity.changedMessage)
|
|
}
|
|
}
|
|
|
|
async function assertDirectoryIdentities(identities: DirectoryIdentity[]): Promise<void> {
|
|
for (const identity of identities) {
|
|
await assertDirectoryIdentity(identity)
|
|
}
|
|
}
|
|
|
|
function assertDirectRealChild(parent: DirectoryIdentity, child: DirectoryIdentity): void {
|
|
if (path.dirname(child.realPath) !== parent.realPath) {
|
|
throw new PetPackageError(
|
|
child.changedCode,
|
|
child.changedMessage,
|
|
)
|
|
}
|
|
}
|
|
|
|
const ROOT_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'root-invalid',
|
|
symlinkCode: 'root-invalid',
|
|
invalidCode: 'root-invalid',
|
|
changedCode: 'root-invalid',
|
|
invalidMessage: 'Custom pets root must be a real directory.',
|
|
changedMessage: 'Custom pets root changed while loading.',
|
|
}
|
|
|
|
const PACKAGE_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'directory-changed',
|
|
symlinkCode: 'symlink-entry',
|
|
invalidCode: 'directory-changed',
|
|
changedCode: 'directory-changed',
|
|
invalidMessage: 'The custom pet package must be a real directory.',
|
|
changedMessage: 'The custom pet package changed while loading.',
|
|
}
|
|
|
|
const IMAGE_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
invalidCode: 'invalid-image',
|
|
changedCode: 'directory-changed',
|
|
invalidMessage: 'The pet image path must contain only real directories.',
|
|
changedMessage: 'The pet image directory changed while loading.',
|
|
}
|
|
|
|
async function readDirectEntries(
|
|
root: string,
|
|
maxEntries: number,
|
|
validateRoot: () => Promise<void>,
|
|
): Promise<{ entries: Dirent[], capped: boolean }> {
|
|
await validateRoot()
|
|
const directory = await opendir(root)
|
|
const entries: Dirent[] = []
|
|
try {
|
|
await validateRoot()
|
|
while (entries.length <= maxEntries) {
|
|
const entry = await directory.read()
|
|
if (!entry) break
|
|
entries.push(entry)
|
|
}
|
|
await validateRoot()
|
|
} finally {
|
|
try {
|
|
await directory.close()
|
|
} catch {
|
|
// A completed scan can already close the handle on some runtimes.
|
|
}
|
|
}
|
|
|
|
return {
|
|
entries: entries.slice(0, maxEntries).sort((left, right) => left.name.localeCompare(right.name)),
|
|
capped: entries.length > maxEntries,
|
|
}
|
|
}
|
|
|
|
async function readBoundedRegularFile(options: {
|
|
filePath: string
|
|
maxBytes: number
|
|
validatePathContext?: () => Promise<void>
|
|
onBytesRead?: (bytes: number) => void
|
|
missingCode: CustomPetLoadErrorCode
|
|
symlinkCode: CustomPetLoadErrorCode
|
|
tooLargeCode: CustomPetLoadErrorCode
|
|
invalidCode: CustomPetLoadErrorCode
|
|
missingMessage: string
|
|
symlinkMessage: string
|
|
tooLargeMessage: string
|
|
invalidMessage: string
|
|
}): Promise<Buffer> {
|
|
await options.validatePathContext?.()
|
|
let pathStat
|
|
try {
|
|
pathStat = await lstat(options.filePath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
throw new PetPackageError(options.missingCode, options.missingMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
if (pathStat.isSymbolicLink()) {
|
|
throw new PetPackageError(options.symlinkCode, options.symlinkMessage)
|
|
}
|
|
if (!pathStat.isFile()) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
if (pathStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
|
|
const noFollow = (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0
|
|
let file
|
|
try {
|
|
file = await open(options.filePath, constants.O_RDONLY | noFollow)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
throw new PetPackageError(options.missingCode, options.missingMessage)
|
|
}
|
|
if (isNodeError(error) && error.code === 'ELOOP') {
|
|
throw new PetPackageError(options.symlinkCode, options.symlinkMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
try {
|
|
const openedStat = await file.stat()
|
|
if (!openedStat.isFile() || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
if (openedStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
const chunks: Buffer[] = []
|
|
let totalBytes = 0
|
|
while (totalBytes < openedStat.size) {
|
|
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, openedStat.size - totalBytes))
|
|
const { bytesRead } = await file.read(chunk, 0, chunk.byteLength, null)
|
|
if (bytesRead === 0) break
|
|
totalBytes += bytesRead
|
|
options.onBytesRead?.(bytesRead)
|
|
chunks.push(chunk.subarray(0, bytesRead))
|
|
}
|
|
const afterReadStat = await file.stat()
|
|
if (
|
|
!afterReadStat.isFile() ||
|
|
afterReadStat.dev !== openedStat.dev ||
|
|
afterReadStat.ino !== openedStat.ino ||
|
|
afterReadStat.size !== openedStat.size ||
|
|
totalBytes !== openedStat.size
|
|
) {
|
|
if (afterReadStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
return Buffer.concat(chunks, totalBytes)
|
|
} finally {
|
|
await file.close()
|
|
}
|
|
}
|
|
|
|
function decodeManifest(data: Buffer): Record<string, unknown> {
|
|
let text: string
|
|
try {
|
|
text = new TextDecoder('utf-8', { fatal: true }).decode(data)
|
|
} catch {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain valid UTF-8 JSON.')
|
|
}
|
|
|
|
let manifest: unknown
|
|
try {
|
|
manifest = JSON.parse(text)
|
|
} catch {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain valid JSON.')
|
|
}
|
|
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain a JSON object.')
|
|
}
|
|
return manifest as Record<string, unknown>
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function sanitizedTextField(
|
|
value: unknown,
|
|
maxLength: number,
|
|
code: CustomPetLoadErrorCode,
|
|
fieldName: string,
|
|
): string {
|
|
if (typeof value !== 'string') {
|
|
throw new PetPackageError(code, `${fieldName} must be a string.`)
|
|
}
|
|
const trimmed = value.trim()
|
|
if (!trimmed || trimmed.length > maxLength || CONTROL_CHARACTER_PATTERN.test(trimmed)) {
|
|
throw new PetPackageError(code, `${fieldName} is invalid.`)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
function resolvePortableRelativePath(
|
|
packageDir: string,
|
|
value: unknown,
|
|
options: {
|
|
code: 'invalid-spritesheet-path' | 'invalid-image-path'
|
|
fieldName: 'spritesheetPath' | 'imagePath'
|
|
},
|
|
): {
|
|
relativePath: string
|
|
absolutePath: string
|
|
} {
|
|
if (typeof value !== 'string' || !value || value !== value.trim()) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must be a relative path.`)
|
|
}
|
|
if (value.length > MAX_IMAGE_PATH_LENGTH || CONTROL_CHARACTER_PATTERN.test(value)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} is invalid.`)
|
|
}
|
|
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must be relative.`)
|
|
}
|
|
|
|
const portablePath = value.replaceAll('\\', '/')
|
|
const segments = portablePath.split('/')
|
|
if (
|
|
segments.some(segment => !segment || segment === '.' || segment === '..' || segment.includes(':')) ||
|
|
path.posix.normalize(portablePath) !== portablePath
|
|
) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} cannot traverse the package.`)
|
|
}
|
|
|
|
const absolutePath = path.resolve(packageDir, ...segments)
|
|
const relativeToPackage = path.relative(packageDir, absolutePath)
|
|
if (!relativeToPackage || relativeToPackage.startsWith('..') || path.isAbsolute(relativeToPackage)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must stay inside the package.`)
|
|
}
|
|
return { relativePath: segments.join('/'), absolutePath }
|
|
}
|
|
|
|
function mimeTypeForPath(
|
|
imagePath: string,
|
|
fieldName: 'spritesheetPath' | 'imagePath',
|
|
): CustomPetImageMimeType {
|
|
const extension = path.posix.extname(imagePath).toLowerCase()
|
|
if (extension === '.png') return 'image/png'
|
|
if (extension === '.webp') return 'image/webp'
|
|
throw new PetPackageError(
|
|
'unsupported-image-format',
|
|
`${fieldName} must point to a PNG or WebP image.`,
|
|
)
|
|
}
|
|
|
|
async function captureImageDirectoryIdentities(
|
|
packageIdentity: DirectoryIdentity,
|
|
imagePath: string,
|
|
): Promise<DirectoryIdentity[]> {
|
|
const identities = [packageIdentity]
|
|
const relativePath = path.relative(packageIdentity.directoryPath, imagePath)
|
|
const segments = relativePath.split(path.sep)
|
|
let current = packageIdentity.directoryPath
|
|
let parentIdentity = packageIdentity
|
|
for (const segment of segments.slice(0, -1)) {
|
|
current = path.join(current, segment)
|
|
const componentIdentity = await captureDirectoryIdentity(current, IMAGE_DIRECTORY_OPTIONS)
|
|
assertDirectRealChild(parentIdentity, componentIdentity)
|
|
identities.push(componentIdentity)
|
|
parentIdentity = componentIdentity
|
|
}
|
|
return identities
|
|
}
|
|
|
|
function hasExpectedImageSignature(data: Buffer, mimeType: CustomPetImageMimeType): boolean {
|
|
if (mimeType === 'image/png') {
|
|
return data.byteLength >= 24 &&
|
|
data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
|
data.readUInt32BE(8) === 13 &&
|
|
data.toString('ascii', 12, 16) === 'IHDR'
|
|
}
|
|
|
|
return data.byteLength >= 20 &&
|
|
data.toString('ascii', 0, 4) === 'RIFF' &&
|
|
data.toString('ascii', 8, 12) === 'WEBP' &&
|
|
data.readUInt32LE(4) + 8 === data.byteLength
|
|
}
|
|
|
|
const PNG_ACTL_CHUNK = 0x6163544c
|
|
const WEBP_VP8X_CHUNK = 0x56503858
|
|
const WEBP_ANIM_CHUNK = 0x414e494d
|
|
const WEBP_ANMF_CHUNK = 0x414e4d46
|
|
const WEBP_VP8X_ANIMATION_FLAG = 0x02
|
|
|
|
function hasPngAnimationChunk(data: Buffer): boolean {
|
|
let offset = 8
|
|
while (offset + 12 <= data.byteLength) {
|
|
const chunkLength = data.readUInt32BE(offset)
|
|
if (chunkLength > data.byteLength - offset - 12) break
|
|
if (data.readUInt32BE(offset + 4) === PNG_ACTL_CHUNK) return true
|
|
offset += 12 + chunkLength
|
|
}
|
|
return false
|
|
}
|
|
|
|
function hasWebpAnimationChunk(data: Buffer): boolean {
|
|
let offset = 12
|
|
while (offset + 8 <= data.byteLength) {
|
|
const chunkType = data.readUInt32BE(offset)
|
|
const chunkLength = data.readUInt32LE(offset + 4)
|
|
const chunkStart = offset + 8
|
|
if (chunkLength > data.byteLength - chunkStart) break
|
|
if (chunkType === WEBP_ANIM_CHUNK || chunkType === WEBP_ANMF_CHUNK) return true
|
|
if (
|
|
chunkType === WEBP_VP8X_CHUNK &&
|
|
chunkLength >= 1 &&
|
|
(data[chunkStart]! & WEBP_VP8X_ANIMATION_FLAG) !== 0
|
|
) {
|
|
return true
|
|
}
|
|
offset = chunkStart + chunkLength + (chunkLength % 2)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function hasEmbeddedImageAnimation(data: Buffer, mimeType: CustomPetImageMimeType): boolean {
|
|
return mimeType === 'image/png'
|
|
? hasPngAnimationChunk(data)
|
|
: hasWebpAnimationChunk(data)
|
|
}
|
|
|
|
function readUInt24LE(data: Buffer, offset: number): number {
|
|
return data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16)
|
|
}
|
|
|
|
export function inspectPetImageSize({ data, mimeType }: ImageSizeInspectorInput): ImageSize {
|
|
if (!hasExpectedImageSignature(data, mimeType)) {
|
|
throw new Error('Invalid image header')
|
|
}
|
|
if (mimeType === 'image/png') {
|
|
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
|
|
}
|
|
|
|
let offset = 12
|
|
while (offset + 8 <= data.byteLength) {
|
|
const chunkType = data.toString('ascii', offset, offset + 4)
|
|
const chunkLength = data.readUInt32LE(offset + 4)
|
|
const chunkStart = offset + 8
|
|
const chunkEnd = chunkStart + chunkLength
|
|
if (chunkEnd > data.byteLength) break
|
|
|
|
if (chunkType === 'VP8X' && chunkLength >= 10) {
|
|
return {
|
|
width: readUInt24LE(data, chunkStart + 4) + 1,
|
|
height: readUInt24LE(data, chunkStart + 7) + 1,
|
|
}
|
|
}
|
|
if (chunkType === 'VP8L' && chunkLength >= 5 && data[chunkStart] === 0x2f) {
|
|
const b1 = data[chunkStart + 1]!
|
|
const b2 = data[chunkStart + 2]!
|
|
const b3 = data[chunkStart + 3]!
|
|
const b4 = data[chunkStart + 4]!
|
|
return {
|
|
width: 1 + b1 + ((b2 & 0x3f) << 8),
|
|
height: 1 + (b2 >> 6) + (b3 << 2) + ((b4 & 0x0f) << 10),
|
|
}
|
|
}
|
|
if (
|
|
chunkType === 'VP8 ' &&
|
|
chunkLength >= 10 &&
|
|
data[chunkStart + 3] === 0x9d &&
|
|
data[chunkStart + 4] === 0x01 &&
|
|
data[chunkStart + 5] === 0x2a
|
|
) {
|
|
return {
|
|
width: data.readUInt16LE(chunkStart + 6) & 0x3fff,
|
|
height: data.readUInt16LE(chunkStart + 8) & 0x3fff,
|
|
}
|
|
}
|
|
|
|
offset = chunkEnd + (chunkLength % 2)
|
|
}
|
|
throw new Error('Unsupported WebP image header')
|
|
}
|
|
|
|
function isValidImageDimension(value: number): boolean {
|
|
return Number.isSafeInteger(value) && value > 0
|
|
}
|
|
|
|
function imagePixels(size: ImageSize): number {
|
|
if (!isValidImageDimension(size.width) || !isValidImageDimension(size.height)) return 0
|
|
const pixels = size.width * size.height
|
|
return Number.isSafeInteger(pixels) ? pixels : 0
|
|
}
|
|
|
|
function assertAtlasImageSize(size: ImageSize): void {
|
|
if (
|
|
size.width !== CUSTOM_PET_SPRITESHEET_WIDTH ||
|
|
size.height !== CUSTOM_PET_SPRITESHEET_HEIGHT
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-image-dimensions',
|
|
`The spritesheet image must be ${CUSTOM_PET_SPRITESHEET_WIDTH}x${CUSTOM_PET_SPRITESHEET_HEIGHT}.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertSingleImageSize(size: ImageSize): void {
|
|
const pixels = imagePixels(size)
|
|
if (
|
|
size.width < CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION ||
|
|
size.height < CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION ||
|
|
size.width > CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION ||
|
|
size.height > CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION ||
|
|
pixels === 0 ||
|
|
pixels > CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-image-dimensions',
|
|
`The pet image must be between ${CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION} and ${CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION} pixels per side and contain no more than ${CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS} pixels.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertCandidateImageSize(
|
|
size: ImageSize,
|
|
imageKind: ManifestCandidate['imageKind'],
|
|
): void {
|
|
if (imageKind === 'atlas-v2') assertAtlasImageSize(size)
|
|
else assertSingleImageSize(size)
|
|
}
|
|
|
|
function parseManifestRenderer(
|
|
packageDir: string,
|
|
entry: string,
|
|
manifest: Record<string, unknown>,
|
|
displayName: string,
|
|
description: string,
|
|
): Pick<ManifestCandidate, 'imagePath' | 'mimeType' | 'imageKind' | 'metadata'> {
|
|
if (manifest.manifestVersion === undefined) {
|
|
if (manifest.renderer !== undefined || manifest.spriteVersionNumber !== 2) {
|
|
throw new PetPackageError(
|
|
'invalid-sprite-version',
|
|
'spriteVersionNumber must be 2 for a legacy atlas pet.',
|
|
)
|
|
}
|
|
const resolvedImage = resolvePortableRelativePath(
|
|
packageDir,
|
|
manifest.spritesheetPath,
|
|
{ code: 'invalid-spritesheet-path', fieldName: 'spritesheetPath' },
|
|
)
|
|
return {
|
|
imagePath: resolvedImage.absolutePath,
|
|
mimeType: mimeTypeForPath(resolvedImage.relativePath, 'spritesheetPath'),
|
|
imageKind: 'atlas-v2',
|
|
metadata: {
|
|
id: `custom:${entry}`,
|
|
displayName,
|
|
description,
|
|
spriteVersionNumber: 2,
|
|
spritesheetPath: resolvedImage.relativePath,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (manifest.manifestVersion !== CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION) {
|
|
throw new PetPackageError(
|
|
'invalid-manifest-version',
|
|
`manifestVersion must be ${CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION}.`,
|
|
)
|
|
}
|
|
if (!isRecord(manifest.renderer)) {
|
|
throw new PetPackageError('invalid-renderer', 'renderer must be an object.')
|
|
}
|
|
const renderer = manifest.renderer
|
|
if (
|
|
renderer.kind !== 'single-image' ||
|
|
renderer.version !== CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-renderer',
|
|
`renderer must use single-image version ${CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION}.`,
|
|
)
|
|
}
|
|
const motionProfile = renderer.motionProfile ?? CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
if (motionProfile !== CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE) {
|
|
throw new PetPackageError('invalid-renderer', 'The single-image motion profile is unsupported.')
|
|
}
|
|
const resolvedImage = resolvePortableRelativePath(
|
|
packageDir,
|
|
renderer.imagePath,
|
|
{ code: 'invalid-image-path', fieldName: 'imagePath' },
|
|
)
|
|
return {
|
|
imagePath: resolvedImage.absolutePath,
|
|
mimeType: mimeTypeForPath(resolvedImage.relativePath, 'imagePath'),
|
|
imageKind: 'single-image',
|
|
metadata: {
|
|
id: `custom:${entry}`,
|
|
displayName,
|
|
description,
|
|
manifestVersion: CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION,
|
|
spriteVersionNumber: 1,
|
|
imagePath: resolvedImage.relativePath,
|
|
motionProfile,
|
|
},
|
|
}
|
|
}
|
|
|
|
async function readManifestCandidate(
|
|
root: string,
|
|
rootIdentity: DirectoryIdentity,
|
|
entry: Dirent,
|
|
maxManifestBytes: number,
|
|
): Promise<ManifestCandidate | null> {
|
|
const packageDir = path.join(root, entry.name)
|
|
if (entry.isSymbolicLink()) {
|
|
throw new PetPackageError('symlink-entry', 'Custom pet package symlinks are not allowed.')
|
|
}
|
|
if (!entry.isDirectory()) return null
|
|
if (entry.name.length > CUSTOM_PET_FOLDER_MAX_LENGTH || !PET_FOLDER_PATTERN.test(entry.name)) {
|
|
throw new PetPackageError('invalid-id', 'Custom pet folder name is not a safe slug.')
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
const packageIdentity = await captureDirectoryIdentity(
|
|
packageDir,
|
|
PACKAGE_DIRECTORY_OPTIONS,
|
|
)
|
|
assertDirectRealChild(rootIdentity, packageIdentity)
|
|
const manifestDirectoryIdentities = [rootIdentity, packageIdentity]
|
|
|
|
const manifestData = await readBoundedRegularFile({
|
|
filePath: path.join(packageDir, 'pet.json'),
|
|
maxBytes: maxManifestBytes,
|
|
validatePathContext: () => assertDirectoryIdentities(manifestDirectoryIdentities),
|
|
missingCode: 'missing-manifest',
|
|
symlinkCode: 'symlink-manifest',
|
|
tooLargeCode: 'manifest-too-large',
|
|
invalidCode: 'invalid-manifest',
|
|
missingMessage: 'The custom pet package is missing pet.json.',
|
|
symlinkMessage: 'pet.json cannot be a symlink.',
|
|
tooLargeMessage: 'pet.json exceeds the allowed size.',
|
|
invalidMessage: 'pet.json must be a regular file.',
|
|
})
|
|
const manifest = decodeManifest(manifestData)
|
|
const displayName = sanitizedTextField(
|
|
manifest.displayName,
|
|
MAX_DISPLAY_NAME_LENGTH,
|
|
'invalid-display-name',
|
|
'displayName',
|
|
)
|
|
const description = sanitizedTextField(
|
|
manifest.description,
|
|
MAX_DESCRIPTION_LENGTH,
|
|
'invalid-description',
|
|
'description',
|
|
)
|
|
const renderer = parseManifestRenderer(
|
|
packageDir,
|
|
entry.name,
|
|
manifest,
|
|
displayName,
|
|
description,
|
|
)
|
|
const imageDirectoryIdentities = await captureImageDirectoryIdentities(
|
|
packageIdentity,
|
|
renderer.imagePath,
|
|
)
|
|
const directoryIdentities = [rootIdentity, ...imageDirectoryIdentities]
|
|
await assertDirectoryIdentities(directoryIdentities)
|
|
|
|
return {
|
|
entry: entry.name,
|
|
...renderer,
|
|
directoryIdentities,
|
|
}
|
|
}
|
|
|
|
async function loadCandidateImage(
|
|
candidate: ManifestCandidate,
|
|
inspectImageSize: ImageSizeInspector,
|
|
maxImageBytes: number,
|
|
tooLargeCode: 'image-too-large' | 'total-image-bytes-exceeded',
|
|
onImageRead: (bytes: number) => void,
|
|
onDecodeAttempt: (decodedPixels: number) => void,
|
|
): Promise<Buffer> {
|
|
const data = await readBoundedRegularFile({
|
|
filePath: candidate.imagePath,
|
|
maxBytes: maxImageBytes,
|
|
validatePathContext: () => assertDirectoryIdentities(candidate.directoryIdentities),
|
|
onBytesRead: onImageRead,
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
tooLargeCode,
|
|
invalidCode: 'invalid-image',
|
|
missingMessage: 'The pet image does not exist.',
|
|
symlinkMessage: 'The pet image cannot be a symlink.',
|
|
tooLargeMessage: tooLargeCode === 'image-too-large'
|
|
? 'The pet image exceeds the allowed size.'
|
|
: 'The total custom pet image budget has been reached.',
|
|
invalidMessage: 'The pet image must be a regular file.',
|
|
})
|
|
if (!hasExpectedImageSignature(data, candidate.mimeType)) {
|
|
throw new PetPackageError('invalid-image', 'The pet image header is invalid.')
|
|
}
|
|
if (hasEmbeddedImageAnimation(data, candidate.mimeType)) {
|
|
throw new PetPackageError(
|
|
'invalid-image',
|
|
'The pet image must be a static PNG or WebP.',
|
|
)
|
|
}
|
|
|
|
let headerSize: ImageSize
|
|
try {
|
|
headerSize = inspectPetImageSize({
|
|
data,
|
|
mimeType: candidate.mimeType,
|
|
})
|
|
} catch {
|
|
throw new PetPackageError('invalid-image', 'The pet image header is invalid.')
|
|
}
|
|
assertCandidateImageSize(headerSize, candidate.imageKind)
|
|
onDecodeAttempt(imagePixels(headerSize))
|
|
|
|
if (inspectImageSize !== inspectPetImageSize) {
|
|
let decodedSize: ImageSize
|
|
try {
|
|
decodedSize = await inspectImageSize({
|
|
data,
|
|
mimeType: candidate.mimeType,
|
|
})
|
|
} catch {
|
|
throw new PetPackageError('invalid-image', 'The pet image cannot be decoded.')
|
|
}
|
|
assertCandidateImageSize(decodedSize, candidate.imageKind)
|
|
if (decodedSize.width !== headerSize.width || decodedSize.height !== headerSize.height) {
|
|
throw new PetPackageError(
|
|
'invalid-image',
|
|
'The decoded pet image dimensions do not match its header.',
|
|
)
|
|
}
|
|
}
|
|
await assertDirectoryIdentities(candidate.directoryIdentities)
|
|
return data
|
|
}
|
|
|
|
function dataUrlPrefix(mimeType: CustomPetImageMimeType): string {
|
|
return `data:${mimeType};base64,`
|
|
}
|
|
|
|
function maxRawBytesForDataUrl(mimeType: CustomPetImageMimeType, remainingBytes: number): number {
|
|
const availableBase64Bytes = remainingBytes - dataUrlPrefix(mimeType).length
|
|
if (availableBase64Bytes < 4) return 0
|
|
return Math.floor(availableBase64Bytes / 4) * 3
|
|
}
|
|
|
|
function totalImageBudgetError(): PetPackageError {
|
|
return new PetPackageError(
|
|
'total-image-bytes-exceeded',
|
|
'The total custom pet image budget has been reached.',
|
|
)
|
|
}
|
|
|
|
async function assertCustomPetTargetAvailable(targetPath: string): Promise<void> {
|
|
try {
|
|
await lstat(targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') return
|
|
throw new PetPackageError('io-error', 'Unable to inspect the custom pet destination.')
|
|
}
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
|
|
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
|
|
|| slug.length === 0
|
|
|| slug.length > CUSTOM_PET_FOLDER_MAX_LENGTH
|
|
|| !PET_FOLDER_PATTERN.test(slug)
|
|
) {
|
|
throw new PetPackageError('invalid-id', 'Custom pet ID must be a lowercase kebab-case slug.')
|
|
}
|
|
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 atlasData = await readBoundedRegularFile({
|
|
filePath: atlasPath,
|
|
maxBytes: DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES,
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
tooLargeCode: 'image-too-large',
|
|
invalidCode: 'invalid-image',
|
|
missingMessage: 'The selected spritesheet image does not exist.',
|
|
symlinkMessage: 'The selected spritesheet image cannot be a symlink.',
|
|
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)
|
|
const targetPath = path.join(root, slug)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
|
|
const stagingRoot = await mkdtemp(path.join(path.dirname(root), '.pet-install-'))
|
|
const packagePath = path.join(stagingRoot, slug)
|
|
try {
|
|
await mkdir(packagePath, { mode: 0o700 })
|
|
await writeFile(path.join(packagePath, spritesheetPath), atlasData, { flag: 'wx', mode: 0o600 })
|
|
await writeFile(
|
|
path.join(packagePath, 'pet.json'),
|
|
`${JSON.stringify({
|
|
id: slug,
|
|
displayName,
|
|
description,
|
|
spriteVersionNumber: 2,
|
|
spritesheetPath,
|
|
}, null, 2)}\n`,
|
|
{ flag: 'wx', mode: 0o600 },
|
|
)
|
|
|
|
const validation = await loadCustomPets({
|
|
root: stagingRoot,
|
|
inspectImageSize: options.inspectImageSize,
|
|
maxEntries: 1,
|
|
})
|
|
const pet = validation.pets.find(candidate => candidate.id === `custom:${slug}`)
|
|
const validationError = validation.errors[0]
|
|
if (!pet || pet.spriteVersionNumber !== 2 || validationError) {
|
|
throw new PetPackageError(
|
|
validationError?.code ?? 'invalid-image',
|
|
validationError?.message ?? 'The custom pet package could not be validated.',
|
|
)
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
try {
|
|
await rename(packagePath, targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
throw error
|
|
}
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
return pet
|
|
} finally {
|
|
await rm(stagingRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export async function createCustomPetFromImage(
|
|
input: CreateCustomPetFromImageInput,
|
|
options: CreateCustomPetFromImageOptions = {},
|
|
): Promise<LoadedCustomImagePet> {
|
|
const slug = input.slug.trim()
|
|
if (
|
|
slug !== input.slug ||
|
|
slug.length === 0 ||
|
|
slug.length > CUSTOM_PET_FOLDER_MAX_LENGTH ||
|
|
!PET_FOLDER_PATTERN.test(slug)
|
|
) {
|
|
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',
|
|
)
|
|
const motionProfile = input.motionProfile ?? CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
if (motionProfile !== CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE) {
|
|
throw new PetPackageError('invalid-renderer', 'The single-image motion profile is unsupported.')
|
|
}
|
|
|
|
const sourcePath = path.resolve(input.imagePath)
|
|
const mimeType = mimeTypeForPath(sourcePath, 'imagePath')
|
|
const extension = mimeType === 'image/png' ? 'png' : 'webp'
|
|
const imagePath = `pet.${extension}`
|
|
const imageData = await readBoundedRegularFile({
|
|
filePath: sourcePath,
|
|
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.',
|
|
})
|
|
|
|
const root = await ensureCustomPetsRoot(options)
|
|
const rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
|
|
const targetPath = path.join(root, slug)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
|
|
const stagingRoot = await mkdtemp(path.join(path.dirname(root), '.pet-install-'))
|
|
const packagePath = path.join(stagingRoot, slug)
|
|
try {
|
|
await mkdir(packagePath, { mode: 0o700 })
|
|
await writeFile(path.join(packagePath, imagePath), imageData, { flag: 'wx', mode: 0o600 })
|
|
await writeFile(
|
|
path.join(packagePath, 'pet.json'),
|
|
`${JSON.stringify({
|
|
id: slug,
|
|
displayName,
|
|
description,
|
|
manifestVersion: CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION,
|
|
renderer: {
|
|
kind: 'single-image',
|
|
version: CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION,
|
|
imagePath,
|
|
motionProfile,
|
|
},
|
|
}, null, 2)}\n`,
|
|
{ flag: 'wx', mode: 0o600 },
|
|
)
|
|
|
|
const validation = await loadCustomPets({
|
|
root: stagingRoot,
|
|
inspectImageSize: options.inspectImageSize,
|
|
maxEntries: 1,
|
|
})
|
|
const pet = validation.pets.find(candidate => candidate.id === `custom:${slug}`)
|
|
const validationError = validation.errors[0]
|
|
if (!pet || pet.spriteVersionNumber !== 1 || validationError) {
|
|
throw new PetPackageError(
|
|
validationError?.code ?? 'invalid-image',
|
|
validationError?.message ?? 'The custom pet package could not be validated.',
|
|
)
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
try {
|
|
await rename(packagePath, targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
throw error
|
|
}
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
return pet
|
|
} finally {
|
|
await rm(stagingRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function packageCanBeSkippedForExhaustedBudget(entry: Dirent): boolean {
|
|
return entry.isDirectory() &&
|
|
entry.name.length <= CUSTOM_PET_FOLDER_MAX_LENGTH &&
|
|
PET_FOLDER_PATTERN.test(entry.name)
|
|
}
|
|
|
|
function canFitAnyCustomPetImage(options: {
|
|
maxImageBytes: number
|
|
remainingImageBytes: number
|
|
remainingDataUrlBytes: number
|
|
}): boolean {
|
|
const dataUrlRawBudget = Math.max(
|
|
maxRawBytesForDataUrl('image/png', options.remainingDataUrlBytes),
|
|
maxRawBytesForDataUrl('image/webp', options.remainingDataUrlBytes),
|
|
)
|
|
return Math.min(
|
|
options.maxImageBytes,
|
|
options.remainingImageBytes,
|
|
dataUrlRawBudget,
|
|
) >= MIN_CUSTOM_PET_IMAGE_BYTES
|
|
}
|
|
|
|
export async function loadCustomPets(
|
|
options: LoadCustomPetsOptions = {},
|
|
): Promise<CustomPetLoadResult> {
|
|
const root = resolveCustomPetsRoot(options)
|
|
const pets: LoadedCustomPet[] = []
|
|
const errors: CustomPetLoadError[] = []
|
|
const maxEntries = normalizeLimit(options.maxEntries, DEFAULT_CUSTOM_PET_MAX_ENTRIES)
|
|
const maxManifestBytes = normalizeLimit(
|
|
options.maxManifestBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_MANIFEST_BYTES,
|
|
)
|
|
const maxImageBytes = normalizeLimit(options.maxImageBytes, DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES)
|
|
const maxTotalImageBytes = normalizeLimit(
|
|
options.maxTotalImageBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_TOTAL_IMAGE_BYTES,
|
|
)
|
|
const maxTotalDataUrlBytes = normalizeLimit(
|
|
options.maxTotalDataUrlBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_TOTAL_DATA_URL_BYTES,
|
|
)
|
|
const maxDecodedPixels = normalizeLimit(
|
|
options.maxDecodedPixels,
|
|
DEFAULT_CUSTOM_PET_MAX_DECODED_PIXELS,
|
|
)
|
|
const inspectImageSize = options.inspectImageSize ?? inspectPetImageSize
|
|
|
|
let rootStat
|
|
try {
|
|
rootStat = await lstat(root)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') return { root, pets, errors }
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [rootError('io-error', 'Unable to read the custom pets root.')],
|
|
}
|
|
}
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [rootError('root-invalid', 'Custom pets root must be a real directory.')],
|
|
}
|
|
}
|
|
|
|
let rootIdentity: DirectoryIdentity
|
|
try {
|
|
rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
|
|
} catch (error) {
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('root-invalid', 'Custom pets root changed while loading.')
|
|
return { root, pets, errors: [normalized] }
|
|
}
|
|
|
|
let directEntries: { entries: Dirent[], capped: boolean }
|
|
try {
|
|
directEntries = await readDirectEntries(
|
|
root,
|
|
maxEntries,
|
|
() => assertDirectoryIdentity(rootIdentity),
|
|
)
|
|
} catch (error) {
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('io-error', 'Unable to scan the custom pets root.')
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [normalized],
|
|
}
|
|
}
|
|
if (directEntries.capped) {
|
|
errors.push(rootError('entry-limit', `Only the first ${maxEntries} custom pet entries were scanned.`))
|
|
}
|
|
|
|
let remainingImageBytes = maxTotalImageBytes
|
|
let remainingDataUrlBytes = maxTotalDataUrlBytes
|
|
let remainingDecodedPixels = maxDecodedPixels
|
|
for (const entry of directEntries.entries) {
|
|
if (!canFitAnyCustomPetImage({
|
|
maxImageBytes,
|
|
remainingImageBytes,
|
|
remainingDataUrlBytes,
|
|
})) {
|
|
if (packageCanBeSkippedForExhaustedBudget(entry)) {
|
|
errors.push(packageError(entry.name, totalImageBudgetError()))
|
|
} else if (entry.isSymbolicLink()) {
|
|
errors.push(packageError(
|
|
entry.name,
|
|
new PetPackageError('symlink-entry', 'Custom pet package symlinks are not allowed.'),
|
|
))
|
|
} else if (
|
|
entry.isDirectory() &&
|
|
(entry.name.length > CUSTOM_PET_FOLDER_MAX_LENGTH || !PET_FOLDER_PATTERN.test(entry.name))
|
|
) {
|
|
errors.push(packageError(
|
|
entry.name,
|
|
new PetPackageError('invalid-id', 'Custom pet folder name is not a safe slug.'),
|
|
))
|
|
}
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const candidate = await readManifestCandidate(root, rootIdentity, entry, maxManifestBytes)
|
|
if (!candidate) continue
|
|
|
|
const remainingDataUrlRawBytes = maxRawBytesForDataUrl(
|
|
candidate.mimeType,
|
|
remainingDataUrlBytes,
|
|
)
|
|
const remainingReadBytes = Math.min(
|
|
maxImageBytes,
|
|
remainingImageBytes,
|
|
remainingDataUrlRawBytes,
|
|
)
|
|
if (remainingReadBytes === 0) throw totalImageBudgetError()
|
|
|
|
const data = await loadCandidateImage(
|
|
candidate,
|
|
inspectImageSize,
|
|
remainingReadBytes,
|
|
remainingReadBytes < maxImageBytes
|
|
? 'total-image-bytes-exceeded'
|
|
: 'image-too-large',
|
|
bytes => {
|
|
remainingImageBytes -= bytes
|
|
},
|
|
decodedPixels => {
|
|
if (remainingDecodedPixels < decodedPixels) {
|
|
throw new PetPackageError(
|
|
'decode-budget-exceeded',
|
|
'The total custom pet decoded-pixel budget has been reached.',
|
|
)
|
|
}
|
|
remainingDecodedPixels -= decodedPixels
|
|
},
|
|
)
|
|
const dataUrl = `${dataUrlPrefix(candidate.mimeType)}${data.toString('base64')}`
|
|
if (dataUrl.length > remainingDataUrlBytes) throw totalImageBudgetError()
|
|
remainingDataUrlBytes -= dataUrl.length
|
|
pets.push({
|
|
...candidate.metadata,
|
|
mimeType: candidate.mimeType,
|
|
dataUrl,
|
|
})
|
|
} catch (error) {
|
|
errors.push(packageError(entry.name, error))
|
|
}
|
|
}
|
|
|
|
try {
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
} catch (error) {
|
|
pets.length = 0
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('root-invalid', 'Custom pets root changed while loading.')
|
|
errors.push(normalized)
|
|
}
|
|
|
|
return { root, pets, errors }
|
|
}
|