Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/engine/contracts/types/persona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
45 changes: 18 additions & 27 deletions src/features/catalog/personas/components/PersonaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -108,7 +115,7 @@ interface PersonaFormData {
personaStats: PersonaStatsData | null;
altDescriptions: AltDescriptionEntry[];
tags: string[];
avatarCrop: AvatarCrop | null;
avatarCrop: AvatarCrop | LegacyAvatarCrop | null;
}

interface PersonaRow {
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand Down
6 changes: 3 additions & 3 deletions src/features/modes/game/components/GameNarration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -250,7 +250,7 @@ function narrationSegmentAnchorKey(segment: NarrationSegment): string {

type SpeakerAvatarInfo = {
url: string;
crop?: AvatarCrop | null;
crop?: AvatarCropValue | null;
nameColor?: string;
dialogueColor?: string;
};
Expand Down Expand Up @@ -4712,7 +4712,7 @@ function CroppedAvatar({
}: {
src: string;
alt: string;
crop?: AvatarCrop | null;
crop?: AvatarCropValue | null;
className?: string;
onLoadError?: () => void;
}) {
Expand Down
4 changes: 2 additions & 2 deletions src/features/modes/game/components/GameSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2555,7 +2555,7 @@ export function GameSurface({
string,
{
url: string;
crop?: AvatarCrop | null;
crop?: AvatarCropValue | null;
nameColor?: string;
dialogueColor?: string;
}
Expand Down
6 changes: 3 additions & 3 deletions src/features/runtime/visuals/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AvatarCrop } from "../../../shared/lib/utils";
import type { AvatarCropValue } from "../../../shared/lib/utils";

export type CharacterMap = Map<
string,
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
28 changes: 15 additions & 13 deletions src/shared/components/ui/AvatarCropWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="space-y-3 rounded-xl border border-[var(--border)] bg-[var(--secondary)] p-4">
Expand Down
41 changes: 41 additions & 0 deletions src/shared/lib/avatar-crop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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();
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({
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",
});
});
});
Loading
Loading