PetOverlay loadStoredPosition now falls back to the legacy 'sidekick-overlay-position' localStorage key and write-throughs to the new key. Deleted orphaned src/main/ipc/sidekick-pet-bundle.ts and its test. Rebased onto current main to resolve conflicts. Co-authored-by: orca-bot <bot@stably.ai>
This commit is contained in:
parent
4541383f87
commit
f2c01d837d
|
|
@ -1,108 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CODEX_PET_ANIMATIONS,
|
||||
CODEX_PET_FRAME,
|
||||
CODEX_PET_SPRITESHEET_PATH,
|
||||
applyCodexPetDefaults,
|
||||
readWebpDimensionsFromBuffer
|
||||
} from './sidekick-pet-bundle'
|
||||
|
||||
function u32(value: number): Buffer {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32LE(value, 0)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function u24(value: number): Buffer {
|
||||
return Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff])
|
||||
}
|
||||
|
||||
function webpVp8x(width: number, height: number): Buffer {
|
||||
const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)])
|
||||
return Buffer.concat([
|
||||
Buffer.from('RIFF'),
|
||||
u32(4 + 8 + payload.byteLength),
|
||||
Buffer.from('WEBP'),
|
||||
Buffer.from('VP8X'),
|
||||
u32(payload.byteLength),
|
||||
payload
|
||||
])
|
||||
}
|
||||
|
||||
describe('applyCodexPetDefaults', () => {
|
||||
it('fills Codex pet manifests that omit Orca sprite metadata', () => {
|
||||
const manifest = applyCodexPetDefaults({ id: 'apupepe', displayName: 'Pepe' })
|
||||
|
||||
expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH)
|
||||
expect(manifest.frame).toEqual(CODEX_PET_FRAME)
|
||||
expect(manifest.defaultAnimation).toBe('idle')
|
||||
expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS)
|
||||
expect(Object.keys(manifest.animations ?? [])).toEqual([
|
||||
'idle',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'waving',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting',
|
||||
'running',
|
||||
'review'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills Codex pet manifests that declare only spritesheetPath', () => {
|
||||
const manifest = applyCodexPetDefaults({
|
||||
id: 'itachi',
|
||||
displayName: 'Itachi',
|
||||
spritesheetPath: 'spritesheet.webp'
|
||||
})
|
||||
|
||||
expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH)
|
||||
expect(manifest.frame).toEqual(CODEX_PET_FRAME)
|
||||
expect(manifest.defaultAnimation).toBe('idle')
|
||||
expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS)
|
||||
})
|
||||
|
||||
it('does not override explicit Orca bundle sprite metadata', () => {
|
||||
const manifest = applyCodexPetDefaults({
|
||||
spritesheetPath: 'custom.png',
|
||||
frame: { width: 64, height: 64 },
|
||||
fps: 12,
|
||||
defaultAnimation: 'blink',
|
||||
animations: { blink: { row: 0, frames: 2 } }
|
||||
})
|
||||
|
||||
expect(manifest).toEqual({
|
||||
spritesheetPath: 'custom.png',
|
||||
frame: { width: 64, height: 64 },
|
||||
fps: 12,
|
||||
defaultAnimation: 'blink',
|
||||
animations: { blink: { row: 0, frames: 2 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults only spritesheetPath when explicit sprite metadata is present', () => {
|
||||
const manifest = applyCodexPetDefaults({
|
||||
frame: { width: 64, height: 64 },
|
||||
animations: { blink: { row: 0, frames: 2 } }
|
||||
})
|
||||
|
||||
expect(manifest.spritesheetPath).toBe(CODEX_PET_SPRITESHEET_PATH)
|
||||
expect(manifest.frame).toEqual({ width: 64, height: 64 })
|
||||
expect(manifest.animations).toEqual({ blink: { row: 0, frames: 2 } })
|
||||
expect(manifest.defaultAnimation).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWebpDimensionsFromBuffer', () => {
|
||||
it('reads VP8X WebP canvas dimensions without decoding pixels', () => {
|
||||
expect(readWebpDimensionsFromBuffer(webpVp8x(1536, 1872))).toEqual({
|
||||
width: 1536,
|
||||
height: 1872
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-WebP data', () => {
|
||||
expect(readWebpDimensionsFromBuffer(Buffer.from('not an image'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import type { SpriteAnimation } from '../../shared/types'
|
||||
|
||||
export type PetManifestLike = {
|
||||
id?: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
spritesheetPath?: string
|
||||
frame?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
fps?: number
|
||||
defaultAnimation?: string
|
||||
animations?: Record<string, SpriteAnimation>
|
||||
}
|
||||
|
||||
export type ResolvedPetManifest<T extends PetManifestLike = PetManifestLike> = T &
|
||||
PetManifestLike & {
|
||||
spritesheetPath: string
|
||||
}
|
||||
|
||||
export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp'
|
||||
export const CODEX_PET_FRAME = { width: 192, height: 208 } as const
|
||||
export const CODEX_PET_DEFAULT_ANIMATION = 'idle'
|
||||
export const CODEX_PET_DEFAULT_FPS = 8
|
||||
|
||||
export const CODEX_PET_ANIMATIONS: Record<string, SpriteAnimation> = {
|
||||
idle: { row: 0, frames: 6 },
|
||||
'running-right': { row: 1, frames: 8 },
|
||||
'running-left': { row: 2, frames: 8 },
|
||||
waving: { row: 3, frames: 4 },
|
||||
jumping: { row: 4, frames: 5 },
|
||||
failed: { row: 5, frames: 8 },
|
||||
waiting: { row: 6, frames: 6 },
|
||||
running: { row: 7, frames: 6 },
|
||||
review: { row: 8, frames: 6 }
|
||||
}
|
||||
|
||||
function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean {
|
||||
return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath)
|
||||
}
|
||||
|
||||
export function applyCodexPetDefaults<T extends PetManifestLike>(
|
||||
manifest: T
|
||||
): ResolvedPetManifest<T> {
|
||||
const shouldApplyCodexLayout =
|
||||
isCodexPetSpritePath(manifest.spritesheetPath) &&
|
||||
manifest.frame === undefined &&
|
||||
manifest.animations === undefined
|
||||
|
||||
if (!shouldApplyCodexLayout) {
|
||||
return {
|
||||
...manifest,
|
||||
spritesheetPath: manifest.spritesheetPath ?? CODEX_PET_SPRITESHEET_PATH
|
||||
} as ResolvedPetManifest<T>
|
||||
}
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
spritesheetPath: manifest.spritesheetPath ?? CODEX_PET_SPRITESHEET_PATH,
|
||||
frame: manifest.frame ?? CODEX_PET_FRAME,
|
||||
fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS,
|
||||
defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION,
|
||||
animations: manifest.animations ?? CODEX_PET_ANIMATIONS
|
||||
}
|
||||
}
|
||||
|
||||
function readUInt24LE(buffer: Buffer, offset: number): number {
|
||||
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16)
|
||||
}
|
||||
|
||||
export function readWebpDimensionsFromBuffer(
|
||||
buffer: Buffer
|
||||
): { width: number; height: number } | null {
|
||||
if (
|
||||
buffer.byteLength < 20 ||
|
||||
buffer.toString('ascii', 0, 4) !== 'RIFF' ||
|
||||
buffer.toString('ascii', 8, 12) !== 'WEBP'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
let offset = 12
|
||||
while (offset + 8 <= buffer.byteLength) {
|
||||
const chunkType = buffer.toString('ascii', offset, offset + 4)
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4)
|
||||
const dataOffset = offset + 8
|
||||
const dataEnd = dataOffset + chunkSize
|
||||
if (dataEnd > buffer.byteLength) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8X' && chunkSize >= 10) {
|
||||
return {
|
||||
width: readUInt24LE(buffer, dataOffset + 4) + 1,
|
||||
height: readUInt24LE(buffer, dataOffset + 7) + 1
|
||||
}
|
||||
}
|
||||
|
||||
if (chunkType === 'VP8L' && chunkSize >= 5 && buffer[dataOffset] === 0x2f) {
|
||||
const b0 = buffer[dataOffset + 1]
|
||||
const b1 = buffer[dataOffset + 2]
|
||||
const b2 = buffer[dataOffset + 3]
|
||||
const b3 = buffer[dataOffset + 4]
|
||||
return {
|
||||
width: 1 + (((b1 & 0x3f) << 8) | b0),
|
||||
height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6))
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
chunkType === 'VP8 ' &&
|
||||
chunkSize >= 10 &&
|
||||
buffer[dataOffset + 3] === 0x9d &&
|
||||
buffer[dataOffset + 4] === 0x01 &&
|
||||
buffer[dataOffset + 5] === 0x2a
|
||||
) {
|
||||
const width = buffer.readUInt16LE(dataOffset + 6) & 0x3fff
|
||||
const height = buffer.readUInt16LE(dataOffset + 8) & 0x3fff
|
||||
return width > 0 && height > 0 ? { width, height } : null
|
||||
}
|
||||
|
||||
offset = dataEnd + (chunkSize % 2)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -199,6 +199,7 @@ function usePrefersReducedMotion(): boolean {
|
|||
// from the store so the user can resize from the status-bar menu.
|
||||
const SIZE = 180
|
||||
const POSITION_STORAGE_KEY = 'pet-overlay-position'
|
||||
const LEGACY_POSITION_STORAGE_KEY = 'sidekick-overlay-position'
|
||||
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
|
|
@ -219,14 +220,26 @@ function loadStoredPosition(size: number = SIZE): Position | null {
|
|||
return null
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(POSITION_STORAGE_KEY)
|
||||
let raw = window.localStorage.getItem(POSITION_STORAGE_KEY)
|
||||
let migratedFromLegacy = false
|
||||
if (!raw) {
|
||||
return null
|
||||
raw = window.localStorage.getItem(LEGACY_POSITION_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
migratedFromLegacy = true
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<Position>
|
||||
if (typeof parsed.x !== 'number' || typeof parsed.y !== 'number') {
|
||||
return null
|
||||
}
|
||||
if (migratedFromLegacy) {
|
||||
try {
|
||||
window.localStorage.setItem(POSITION_STORAGE_KEY, raw)
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
// Why: clamp using the live overlay size so a persisted position from a
|
||||
// larger overlay doesn't slip off the bottom/right edge after a shrink.
|
||||
return clampToViewport({ x: parsed.x, y: parsed.y }, size)
|
||||
|
|
|
|||
Loading…
Reference in New Issue