From fc3fb5e0bc63cad370ae06507d530552bbd927ec Mon Sep 17 00:00:00 2001 From: Chai Date: Tue, 26 May 2026 22:26:08 -0400 Subject: [PATCH 1/2] Fix legacy persona avatar crop parsing --- src/engine/contracts/types/persona.ts | 12 ++++- .../components/CharacterAvatarImage.tsx | 28 ++++------- .../personas/components/PersonaEditor.tsx | 45 ++++++++---------- .../modes/game/components/GameNarration.tsx | 6 +-- .../modes/game/components/GameSurface.tsx | 4 +- src/features/runtime/visuals/types.ts | 6 +-- src/shared/components/ui/AvatarCropWidget.tsx | 8 ++-- src/shared/lib/avatar-crop.test.ts | 36 +++++++++++++++ src/shared/lib/utils.ts | 46 +++++++++++++++++-- 9 files changed, 128 insertions(+), 63 deletions(-) create mode 100644 src/shared/lib/avatar-crop.test.ts diff --git a/src/engine/contracts/types/persona.ts b/src/engine/contracts/types/persona.ts index cf43220f7..0611a3910 100644 --- a/src/engine/contracts/types/persona.ts +++ b/src/engine/contracts/types/persona.ts @@ -16,7 +16,7 @@ export interface Persona { /** Avatar image path */ avatarPath: string | null; /** Avatar crop settings for the circle avatar. */ - avatarCrop?: PersonaAvatarCrop | null; + avatarCrop?: PersonaAvatarCropValue | null; /** Whether this is the currently active persona */ isActive: boolean; /** Name display color/gradient (CSS value) */ @@ -92,6 +92,16 @@ export interface PersonaAvatarCrop { srcHeight: number; } +/** Legacy avatar crop format kept for backwards-compatible persona imports. */ +export interface LegacyPersonaAvatarCrop { + zoom: number; + offsetX: number; + offsetY: number; + fullImage?: boolean; +} + +export type PersonaAvatarCropValue = PersonaAvatarCrop | LegacyPersonaAvatarCrop; + /** A toggleable alternative/extended description block for a persona. */ export interface AltDescription { id: string; diff --git a/src/features/catalog/characters/components/CharacterAvatarImage.tsx b/src/features/catalog/characters/components/CharacterAvatarImage.tsx index dd8793625..f4c54bf3b 100644 --- a/src/features/catalog/characters/components/CharacterAvatarImage.tsx +++ b/src/features/catalog/characters/components/CharacterAvatarImage.tsx @@ -1,28 +1,16 @@ -import type { AvatarCrop } from "../../../../shared/lib/utils"; +import type { AvatarCropValue } from "../../../../shared/lib/utils"; import { cn, getAvatarCropStyle, parseAvatarCropJson } from "../../../../shared/lib/utils"; import { getCharacterAvatarLoadingMode } from "../lib/character-avatar-loading"; -function isAvatarCrop(value: unknown): value is AvatarCrop { - return ( - !!value && - typeof value === "object" && - Number.isFinite((value as AvatarCrop).srcX) && - Number.isFinite((value as AvatarCrop).srcY) && - Number.isFinite((value as AvatarCrop).srcWidth) && - Number.isFinite((value as AvatarCrop).srcHeight) && - (value as AvatarCrop).srcWidth > 0 && - (value as AvatarCrop).srcHeight > 0 && - (value as AvatarCrop).srcX >= 0 && - (value as AvatarCrop).srcY >= 0 && - (value as AvatarCrop).srcX + (value as AvatarCrop).srcWidth <= 1.001 && - (value as AvatarCrop).srcY + (value as AvatarCrop).srcHeight <= 1.001 - ); -} - -function resolveAvatarCrop(crop: unknown): AvatarCrop | null { +function resolveAvatarCrop(crop: unknown): AvatarCropValue | null { if (!crop) return null; if (typeof crop === "string") return parseAvatarCropJson(crop); - return isAvatarCrop(crop) ? crop : null; + if (typeof crop !== "object") return null; + try { + return parseAvatarCropJson(JSON.stringify(crop)); + } catch { + return null; + } } export function CharacterAvatarImage({ diff --git a/src/features/catalog/personas/components/PersonaEditor.tsx b/src/features/catalog/personas/components/PersonaEditor.tsx index 65a11943a..ebfe5e8cf 100644 --- a/src/features/catalog/personas/components/PersonaEditor.tsx +++ b/src/features/catalog/personas/components/PersonaEditor.tsx @@ -37,7 +37,14 @@ import { RotateCcw, Crop, } from "lucide-react"; -import { cn, generateClientId, getAvatarCropStyle, type AvatarCrop } from "../../../../shared/lib/utils"; +import { + cn, + generateClientId, + getAvatarCropStyle, + parseAvatarCropJson, + type AvatarCrop, + type LegacyAvatarCrop, +} from "../../../../shared/lib/utils"; import { showAlertDialog, showConfirmDialog } from "../../../../shared/lib/app-dialogs"; import { extractColorsFromImage } from "../../../../shared/lib/avatar-color-extraction"; import { HelpTooltip } from "../../../../shared/components/ui/HelpTooltip"; @@ -108,7 +115,7 @@ interface PersonaFormData { personaStats: PersonaStatsData | null; altDescriptions: AltDescriptionEntry[]; tags: string[]; - avatarCrop: AvatarCrop | null; + avatarCrop: AvatarCrop | LegacyAvatarCrop | null; } interface PersonaRow { @@ -121,7 +128,7 @@ interface PersonaRow { backstory: string; appearance: string; avatarPath: string | null; - avatarCrop?: AvatarCrop | null; + avatarCrop?: AvatarCrop | LegacyAvatarCrop | string | null; isActive: string | boolean; nameColor?: string; dialogueColor?: string; @@ -133,6 +140,12 @@ interface PersonaRow { tags?: string[]; } +function parseAvatarCropValue(value: PersonaRow["avatarCrop"]): AvatarCrop | LegacyAvatarCrop | null { + if (!value) return null; + if (typeof value === "string") return parseAvatarCropJson(value); + return parseAvatarCropJson(JSON.stringify(value)); +} + export function PersonaEditor() { const personaId = useUIStore((s) => s.personaDetailId); const closeDetail = useUIStore((s) => s.closePersonaDetail); @@ -183,29 +196,7 @@ export function PersonaEditor() { const parsedAltDescs: AltDescriptionEntry[] = Array.isArray(rawPersona.altDescriptions) ? rawPersona.altDescriptions : []; - let parsedAvatarCrop: AvatarCrop | null = null; - const obj = rawPersona.avatarCrop; - if (obj && typeof obj === "object") { - if ( - Number.isFinite(obj.srcX) && - Number.isFinite(obj.srcY) && - Number.isFinite(obj.srcWidth) && - Number.isFinite(obj.srcHeight) && - obj.srcWidth > 0 && - obj.srcHeight > 0 && - obj.srcX >= 0 && - obj.srcY >= 0 && - obj.srcX + obj.srcWidth <= 1.001 && - obj.srcY + obj.srcHeight <= 1.001 - ) { - parsedAvatarCrop = { - srcX: obj.srcX, - srcY: obj.srcY, - srcWidth: obj.srcWidth, - srcHeight: obj.srcHeight, - }; - } - } + const parsedAvatarCrop = parseAvatarCropValue(rawPersona.avatarCrop); const savedTrackerCardColors = typeof rawPersona[TRACKER_CARD_COLOR_PREVIEW_BASE_FIELD] === "string" @@ -1908,7 +1899,7 @@ function DescriptionTab({ // Pass through whichever shape is saved (or null when unset). The widget // initializes the cropper from the saved value or a centered max-square. - const avatarCrop: AvatarCrop | null = formData.avatarCrop; + const avatarCrop: AvatarCrop | LegacyAvatarCrop | null = formData.avatarCrop; return (
diff --git a/src/features/modes/game/components/GameNarration.tsx b/src/features/modes/game/components/GameNarration.tsx index 8f5a8f580..939b4d5a6 100644 --- a/src/features/modes/game/components/GameNarration.tsx +++ b/src/features/modes/game/components/GameNarration.tsx @@ -33,7 +33,7 @@ import { Wand2, RotateCcw, } from "lucide-react"; -import { cn, copyToClipboard, getAvatarCropStyle, type AvatarCrop } from "../../../../shared/lib/utils"; +import { cn, copyToClipboard, getAvatarCropStyle, type AvatarCropValue } from "../../../../shared/lib/utils"; import { findNamedMapValue } from "../lib/game-character-name-match"; import type { GameSegmentEdit } from "../lib/game-segment-edits"; import { parseGmTags, stripGmTagsKeepReadables } from "../lib/game-tag-parser"; @@ -250,7 +250,7 @@ function narrationSegmentAnchorKey(segment: NarrationSegment): string { type SpeakerAvatarInfo = { url: string; - crop?: AvatarCrop | null; + crop?: AvatarCropValue | null; nameColor?: string; dialogueColor?: string; }; @@ -4712,7 +4712,7 @@ function CroppedAvatar({ }: { src: string; alt: string; - crop?: AvatarCrop | null; + crop?: AvatarCropValue | null; className?: string; onLoadError?: () => void; }) { diff --git a/src/features/modes/game/components/GameSurface.tsx b/src/features/modes/game/components/GameSurface.tsx index 32f1f165c..eb9aa79c8 100644 --- a/src/features/modes/game/components/GameSurface.tsx +++ b/src/features/modes/game/components/GameSurface.tsx @@ -58,7 +58,7 @@ import { spotifyApi } from "../../../../shared/api/integration-utility-api"; import { gameAssetFileUrlFromPath, userBackgroundUrl } from "../../../../shared/api/local-file-api"; import { storageApi } from "../../../../shared/api/storage-api"; import { showConfirmDialog } from "../../../../shared/lib/app-dialogs"; -import { cn, type AvatarCrop, type AvatarCropValue } from "../../../../shared/lib/utils"; +import { cn, type AvatarCropValue } from "../../../../shared/lib/utils"; import { filterLanguageGenerationConnections } from "../../../../shared/lib/connection-filters"; import { audioManager } from "../lib/game-audio"; import { @@ -2555,7 +2555,7 @@ export function GameSurface({ string, { url: string; - crop?: AvatarCrop | null; + crop?: AvatarCropValue | null; nameColor?: string; dialogueColor?: string; } diff --git a/src/features/runtime/visuals/types.ts b/src/features/runtime/visuals/types.ts index a179e0042..be40fb4e5 100644 --- a/src/features/runtime/visuals/types.ts +++ b/src/features/runtime/visuals/types.ts @@ -1,4 +1,4 @@ -import type { AvatarCrop } from "../../../shared/lib/utils"; +import type { AvatarCropValue } from "../../../shared/lib/utils"; export type CharacterMap = Map< string, @@ -14,7 +14,7 @@ export type CharacterMap = Map< nameColor?: string; dialogueColor?: string; boxColor?: string; - avatarCrop?: AvatarCrop | null; + avatarCrop?: AvatarCropValue | null; conversationStatus?: "online" | "idle" | "dnd" | "offline"; conversationActivity?: string; } @@ -28,7 +28,7 @@ export type PersonaInfo = { appearance?: string; scenario?: string; avatarUrl?: string; - avatarCrop?: AvatarCrop | null; + avatarCrop?: AvatarCropValue | null; nameColor?: string; dialogueColor?: string; boxColor?: string; diff --git a/src/shared/components/ui/AvatarCropWidget.tsx b/src/shared/components/ui/AvatarCropWidget.tsx index d0140b11f..4b5a2b2bd 100644 --- a/src/shared/components/ui/AvatarCropWidget.tsx +++ b/src/shared/components/ui/AvatarCropWidget.tsx @@ -13,7 +13,7 @@ // ratio without distortion. import { useEffect, useRef, useState } from "react"; import { Crop, Maximize2, RotateCcw, X } from "lucide-react"; -import { type AvatarCrop, getAvatarCropStyle } from "../../lib/utils"; +import { type AvatarCrop, type LegacyAvatarCrop, getAvatarCropStyle, isLegacyAvatarCrop } from "../../lib/utils"; interface CropPx { x: number; @@ -28,7 +28,7 @@ export interface AvatarCropWidgetProps { src: string; alt: string; /** Currently saved crop. Pass null when none has been set. */ - crop: AvatarCrop | null; + crop: AvatarCrop | LegacyAvatarCrop | null; /** Fired on every change (drag, corner resize, reset). Always emits the * current AvatarCrop shape. */ onChange: (next: AvatarCrop) => void; @@ -80,7 +80,7 @@ export function AvatarCropWidget({ src, alt, crop, onChange }: AvatarCropWidgetP useEffect(() => { if (!imgRect || dragRef.current) return; const { w, h } = imgRect; - if (crop) { + if (crop && !isLegacyAvatarCrop(crop)) { const size = clamp(crop.srcWidth * w, MIN_CROP_PX, Math.min(w, h)); setCropPx({ x: clamp(crop.srcX * w, 0, w - size), @@ -117,7 +117,7 @@ export function AvatarCropWidget({ src, alt, crop, onChange }: AvatarCropWidgetP setImgRect({ w, h }); let initial: CropPx; - if (crop) { + if (crop && !isLegacyAvatarCrop(crop)) { initial = { x: crop.srcX * w, y: crop.srcY * h, diff --git a/src/shared/lib/avatar-crop.test.ts b/src/shared/lib/avatar-crop.test.ts new file mode 100644 index 000000000..c9b26b713 --- /dev/null +++ b/src/shared/lib/avatar-crop.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getAvatarCropStyle, isLegacyAvatarCrop, parseAvatarCropJson } from "./utils"; + +describe("avatar crop compatibility", () => { + it("parses current source-rectangle crops", () => { + expect(parseAvatarCropJson('{"srcX":0.1,"srcY":0.2,"srcWidth":0.5,"srcHeight":0.5}')).toEqual({ + srcX: 0.1, + srcY: 0.2, + srcWidth: 0.5, + srcHeight: 0.5, + }); + }); + + it("parses legacy zoom/offset crops", () => { + const crop = parseAvatarCropJson('{"zoom":1.4,"offsetX":12,"offsetY":-8,"fullImage":true}'); + + expect(crop).toEqual({ zoom: 1.4, offsetX: 12, offsetY: -8, fullImage: true }); + expect(crop && isLegacyAvatarCrop(crop)).toBe(true); + }); + + it("rejects malformed legacy crops", () => { + expect(parseAvatarCropJson('{"zoom":0,"offsetX":12,"offsetY":-8}')).toBeNull(); + expect(parseAvatarCropJson('{"zoom":1.2,"offsetX":12,"offsetY":-8,"fullImage":"yes"}')).toBeNull(); + }); + + it("renders legacy crop transforms", () => { + expect(getAvatarCropStyle({ zoom: 1.4, offsetX: 12, offsetY: -8 })).toEqual({ + transform: "scale(1.4) translate(12px, -8px)", + }); + expect(getAvatarCropStyle({ zoom: 1, offsetX: 12, offsetY: -8 })).toEqual({}); + expect(getAvatarCropStyle({ zoom: 1, offsetX: 0, offsetY: 0, fullImage: true })).toEqual({ + objectFit: "contain", + transform: "scale(1) translate(0px, 0px)", + }); + }); +}); diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index ed83171fe..17586f02a 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -57,13 +57,26 @@ export interface AvatarCrop { srcHeight: number; } -export type AvatarCropValue = AvatarCrop; +/** Avatar crop -- legacy zoom/offset format used by older persona rows. */ +export interface LegacyAvatarCrop { + zoom: number; + offsetX: number; + offsetY: number; + /** Legacy full-image mode rendered the portrait contained instead of covered. */ + fullImage?: boolean; +} + +export type AvatarCropValue = AvatarCrop | LegacyAvatarCrop; + +export function isLegacyAvatarCrop(crop: AvatarCropValue): crop is LegacyAvatarCrop { + return "zoom" in crop; +} /** Parses a JSON-encoded avatarCrop string (as stored on persona rows and as * emitted from extensions on character rows when serialized) with defensive * shape validation. Malformed data returns null and the caller falls back to * the uncropped render. */ -export function parseAvatarCropJson(raw: string | undefined | null): AvatarCrop | null { +export function parseAvatarCropJson(raw: string | undefined | null): AvatarCropValue | null { if (!raw) return null; try { const obj = JSON.parse(raw); @@ -87,6 +100,20 @@ export function parseAvatarCropJson(raw: string | undefined | null): AvatarCrop srcHeight: obj.srcHeight, }; } + if ( + Number.isFinite(obj.zoom) && + Number.isFinite(obj.offsetX) && + Number.isFinite(obj.offsetY) && + obj.zoom > 0 && + (obj.fullImage === undefined || typeof obj.fullImage === "boolean") + ) { + return { + zoom: obj.zoom, + offsetX: obj.offsetX, + offsetY: obj.offsetY, + ...(obj.fullImage ? { fullImage: true } : {}), + }; + } } catch { /* fall through to null */ } @@ -106,9 +133,22 @@ export function parseAvatarCropJson(raw: string | undefined | null): AvatarCrop * the image, because a square-in-source-pixels crop makes the `` element * box take the source's aspect ratio, and `object-fit: fill` then fills that * box undistorted. */ -export function getAvatarCropStyle(crop?: AvatarCrop | null): CSSProperties { +export function getAvatarCropStyle(crop?: AvatarCropValue | null): CSSProperties { if (!crop) return {}; + if (isLegacyAvatarCrop(crop)) { + if (crop.fullImage) { + return { + objectFit: "contain", + transform: `scale(${crop.zoom}) translate(${crop.offsetX}px, ${crop.offsetY}px)`, + }; + } + if (crop.zoom <= 1) return {}; + return { + transform: `scale(${crop.zoom}) translate(${crop.offsetX}px, ${crop.offsetY}px)`, + }; + } + const { srcX, srcY, srcWidth, srcHeight } = crop; if (srcWidth <= 0 || srcHeight <= 0) return {}; return { From c3d22aafa934c92880e4891cbac79a90d6582ce6 Mon Sep 17 00:00:00 2001 From: Chai Date: Tue, 26 May 2026 22:36:04 -0400 Subject: [PATCH 2/2] Address legacy crop review feedback --- src/shared/components/ui/AvatarCropWidget.tsx | 20 ++++++++++--------- src/shared/lib/avatar-crop.test.ts | 9 +++++++-- src/shared/lib/utils.ts | 9 ++++++--- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/shared/components/ui/AvatarCropWidget.tsx b/src/shared/components/ui/AvatarCropWidget.tsx index 4b5a2b2bd..dde615aae 100644 --- a/src/shared/components/ui/AvatarCropWidget.tsx +++ b/src/shared/components/ui/AvatarCropWidget.tsx @@ -193,15 +193,17 @@ export function AvatarCropWidget({ src, alt, crop, onChange }: AvatarCropWidgetP // Live preview reads cropPx (instant) rather than the saved crop prop, so the // preview stays in sync with the overlay even between onChange ticks. - const previewCrop: AvatarCrop | null = - imgRect && cropPx - ? { - srcX: cropPx.x / imgRect.w, - srcY: cropPx.y / imgRect.h, - srcWidth: cropPx.size / imgRect.w, - srcHeight: cropPx.size / imgRect.h, - } - : null; + const previewCrop: AvatarCrop | LegacyAvatarCrop | null = + crop && isLegacyAvatarCrop(crop) + ? crop + : imgRect && cropPx + ? { + srcX: cropPx.x / imgRect.w, + srcY: cropPx.y / imgRect.h, + srcWidth: cropPx.size / imgRect.w, + srcHeight: cropPx.size / imgRect.h, + } + : null; return (
diff --git a/src/shared/lib/avatar-crop.test.ts b/src/shared/lib/avatar-crop.test.ts index c9b26b713..39b829270 100644 --- a/src/shared/lib/avatar-crop.test.ts +++ b/src/shared/lib/avatar-crop.test.ts @@ -21,16 +21,21 @@ describe("avatar crop compatibility", () => { it("rejects malformed legacy crops", () => { expect(parseAvatarCropJson('{"zoom":0,"offsetX":12,"offsetY":-8}')).toBeNull(); expect(parseAvatarCropJson('{"zoom":1.2,"offsetX":12,"offsetY":-8,"fullImage":"yes"}')).toBeNull(); + expect(parseAvatarCropJson('{"zoom":1.2,"offsetX":"12","offsetY":-8}')).toBeNull(); + expect(parseAvatarCropJson('{"zoom":1.2,"offsetX":12}')).toBeNull(); + expect(parseAvatarCropJson('{"zoom":1.2,"offsetX":12,"offsetY":null}')).toBeNull(); }); it("renders legacy crop transforms", () => { expect(getAvatarCropStyle({ zoom: 1.4, offsetX: 12, offsetY: -8 })).toEqual({ transform: "scale(1.4) translate(12px, -8px)", }); - expect(getAvatarCropStyle({ zoom: 1, offsetX: 12, offsetY: -8 })).toEqual({}); + expect(getAvatarCropStyle({ zoom: 1, offsetX: 12, offsetY: -8 })).toEqual({ + transform: "scale(1) translate(12px, -8px)", + }); + expect(getAvatarCropStyle({ zoom: 1, offsetX: 0, offsetY: 0 })).toEqual({}); expect(getAvatarCropStyle({ zoom: 1, offsetX: 0, offsetY: 0, fullImage: true })).toEqual({ objectFit: "contain", - transform: "scale(1) translate(0px, 0px)", }); }); }); diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index 17586f02a..9dd0647c5 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -137,15 +137,18 @@ export function getAvatarCropStyle(crop?: AvatarCropValue | null): CSSProperties if (!crop) return {}; if (isLegacyAvatarCrop(crop)) { + const isIdentityCrop = crop.zoom === 1 && crop.offsetX === 0 && crop.offsetY === 0; + const transform = `scale(${crop.zoom}) translate(${crop.offsetX}px, ${crop.offsetY}px)`; + if (crop.fullImage) { return { objectFit: "contain", - transform: `scale(${crop.zoom}) translate(${crop.offsetX}px, ${crop.offsetY}px)`, + ...(isIdentityCrop ? {} : { transform }), }; } - if (crop.zoom <= 1) return {}; + if (isIdentityCrop) return {}; return { - transform: `scale(${crop.zoom}) translate(${crop.offsetX}px, ${crop.offsetY}px)`, + transform, }; }