diff --git a/src-tauri/src/commands/avatars.rs b/src-tauri/src/commands/avatars.rs index a0a0238ed..51c5454e0 100644 --- a/src-tauri/src/commands/avatars.rs +++ b/src-tauri/src/commands/avatars.rs @@ -137,6 +137,15 @@ pub struct CachedAvatar { pub asset: CachedAvatarAsset, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CachedAvatarAnimation { + pub bytes: Vec, + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub alpha_mode: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AvatarCacheWarmedPayload { @@ -364,6 +373,46 @@ pub async fn get_avatar_library_snapshot( }) } +#[tauri::command] +pub async fn read_cached_avatar_animation( + app: AppHandle, + avatar_ref: String, +) -> Result, String> { + let cached = get_cached_avatar_for_ref(app, avatar_ref).await?; + let Some(cached) = cached else { + return Ok(None); + }; + read_cached_avatar_animation_asset(cached.asset) +} + +fn read_cached_avatar_animation_asset( + asset: CachedAvatarAsset, +) -> Result, String> { + if !matches!(asset.mime_type.as_str(), "video/webm" | "video/mp4") { + return Ok(None); + } + let metadata = fs::metadata(&asset.path) + .map_err(|error| format!("Failed to inspect cached avatar animation: {error}"))?; + if metadata.len() == 0 || metadata.len() > MAX_IMPORTED_AVATAR_BYTES as u64 { + return Ok(None); + } + let bytes = fs::read(&asset.path) + .map_err(|error| format!("Failed to read cached avatar animation: {error}"))?; + // Recheck the actual payload after reading; metadata is only an early exit + // and the cache file could be replaced between the stat and read calls. + if bytes.is_empty() || bytes.len() > MAX_IMPORTED_AVATAR_BYTES { + return Ok(None); + } + if validate_imported_avatar_signature(&bytes, &asset.mime_type).is_err() { + return Ok(None); + } + Ok(Some(CachedAvatarAnimation { + bytes, + mime_type: asset.mime_type, + alpha_mode: asset.alpha_mode, + })) +} + #[tauri::command] pub async fn get_cached_avatar_for_ref( app: AppHandle, @@ -2493,6 +2542,43 @@ mod tests { format!("data:video/webm;base64,{}", BASE64.encode(bytes)) } + #[test] + fn cached_animation_reader_enforces_type_and_payload_bounds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("avatar.webm"); + fs::write(&path, WEBM_SIGNATURE).unwrap(); + let asset = |mime_type: &str| CachedAvatarAsset { + id: "gloopy-1".to_string(), + path: path.to_string_lossy().into_owned(), + mime_type: mime_type.to_string(), + alpha_mode: Some("stacked".to_string()), + poster_path: None, + }; + + let animation = read_cached_avatar_animation_asset(asset("video/webm")) + .unwrap() + .unwrap(); + assert_eq!(animation.bytes, WEBM_SIGNATURE); + assert_eq!(animation.mime_type, "video/webm"); + assert_eq!(animation.alpha_mode.as_deref(), Some("stacked")); + + assert!(read_cached_avatar_animation_asset(asset("image/png")) + .unwrap() + .is_none()); + fs::write(&path, b"not webm").unwrap(); + assert!(read_cached_avatar_animation_asset(asset("video/webm")) + .unwrap() + .is_none()); + fs::write(&path, []).unwrap(); + assert!(read_cached_avatar_animation_asset(asset("video/webm")) + .unwrap() + .is_none()); + fs::write(&path, vec![0; MAX_IMPORTED_AVATAR_BYTES + 1]).unwrap(); + assert!(read_cached_avatar_animation_asset(asset("video/webm")) + .unwrap() + .is_none()); + } + #[test] fn imported_avatar_limit_accounts_for_base64_padding_exactly() { for size in [MAX_IMPORTED_AVATAR_BYTES - 1, MAX_IMPORTED_AVATAR_BYTES] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4a701541..a92eea183 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -433,6 +433,7 @@ pub fn run() { commands::avatars::refresh_avatar_cache, commands::avatars::get_cached_avatar_for_ref, commands::avatars::get_cached_avatars_for_refs, + commands::avatars::read_cached_avatar_animation, commands::avatars::import_user_avatar_data_url, commands::avatars::delete_user_avatar, commands::cache::clear_local_media_caches, diff --git a/src/features/agents/agent-snapshot/pngCodec.test.ts b/src/features/agents/agent-snapshot/pngCodec.test.ts index fba2bf34d..fcf316259 100644 --- a/src/features/agents/agent-snapshot/pngCodec.test.ts +++ b/src/features/agents/agent-snapshot/pngCodec.test.ts @@ -5,6 +5,7 @@ import { decodeAgentImage, decodeAvatarAnimation, encodeAgentImage, + getPngDimensions, } from "./pngCodec"; import { AgentSnapshotError, @@ -112,6 +113,15 @@ function expectCode( } describe("buzz-agent-snapshot PNG codec", () => { + it("returns dimensions from a fully validated PNG", () => { + expect(getPngDimensions(pngWithDimensions(321, 654))).toEqual({ + width: 321, + height: 654, + }); + expect(() => getPngDimensions(Uint8Array.from([1, 2, 3]))).toThrow( + "Invalid PNG signature", + ); + }); it("round trips animated avatar bytes outside snapshot JSON", () => { const animation = { bytes: Uint8Array.from([26, 69, 223, 163]), diff --git a/src/features/agents/agent-snapshot/pngCodec.ts b/src/features/agents/agent-snapshot/pngCodec.ts index 17b343eae..26e237b87 100644 --- a/src/features/agents/agent-snapshot/pngCodec.ts +++ b/src/features/agents/agent-snapshot/pngCodec.ts @@ -60,6 +60,21 @@ function crc32(bytes: Uint8Array): number { return (crc ^ 0xffffffff) >>> 0; } +export function getPngDimensions(bytes: Uint8Array): { + width: number; + height: number; +} { + const chunks = parseChunks(bytes); + const ihdr = chunks[0]; + if (!ihdr || ihdr.type !== "IHDR") { + throw new AgentSnapshotError("PNG must begin with IHDR", "invalid-png"); + } + return { + width: readU32(ihdr.data, 0), + height: readU32(ihdr.data, 4), + }; +} + function parseChunks(bytes: Uint8Array): Chunk[] { if (bytes.length > MAX_SNAPSHOT_PNG_BYTES) { throw new AgentSnapshotError( diff --git a/src/features/agents/assets/share-card/berd-card-logo.svg b/src/features/agents/assets/share-card/berd-card-logo.svg new file mode 100644 index 000000000..760f14205 --- /dev/null +++ b/src/features/agents/assets/share-card/berd-card-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/features/agents/assets/share-card/card-foil.png b/src/features/agents/assets/share-card/card-foil.png new file mode 100644 index 000000000..5cfe6fee8 Binary files /dev/null and b/src/features/agents/assets/share-card/card-foil.png differ diff --git a/src/features/agents/ui/AgentImageImportDialog.tsx b/src/features/agents/ui/AgentImageImportDialog.tsx index 1b6d52530..361abbfe8 100644 --- a/src/features/agents/ui/AgentImageImportDialog.tsx +++ b/src/features/agents/ui/AgentImageImportDialog.tsx @@ -237,7 +237,7 @@ export function AgentImageImportDialog({ void; @@ -91,13 +64,11 @@ export function AgentImportDialog({ maxImportBytes, importTooLargeMessage, }: AgentImportDialogProps) { - const { t } = useTranslation("agents"); - const shouldReduceMotion = useReducedMotion(); - const cardRef = useRef(null); - const [refractionOrigin, setRefractionOrigin] = useState<{ - x: number; - y: number; - } | null>(null); + const { t, i18n } = useTranslation("agents"); + const locale = i18n?.resolvedLanguage ?? i18n?.language ?? "en"; + const [importAccentColor, setImportAccentColor] = useState( + null, + ); const [prepared, setPrepared] = useState<{ bytes: Uint8Array; name: string; @@ -108,6 +79,34 @@ export function AgentImportDialog({ if (!open) setPrepared(null); }, [open]); + useEffect(() => { + if (!prepared?.preview.cardImageUrl) { + setImportAccentColor(null); + return; + } + const fallbackColor = fallbackAgentCardColor(prepared.preview.identity); + setImportAccentColor(fallbackColor); + const avatarSrc = prepared.preview.avatar; + if (!avatarSrc) return; + + let active = true; + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const sampled = sampleAgentAvatarColor(image); + if (active && sampled) setImportAccentColor(sampled); + }; + image.onerror = () => { + if (active) setImportAccentColor(fallbackColor); + }; + image.src = avatarSrc; + return () => { + active = false; + image.onload = null; + image.onerror = null; + }; + }, [prepared]); + useEffect( () => () => { if (prepared?.preview.cardImageUrl) { @@ -117,16 +116,6 @@ export function AgentImportDialog({ [prepared?.preview.cardImageUrl], ); - useLayoutEffect(() => { - if (!prepared) return; - const bounds = cardRef.current?.getBoundingClientRect(); - if (!bounds) return; - setRefractionOrigin({ - x: bounds.left + bounds.width / 2, - y: bounds.top + bounds.height / 2, - }); - }, [prepared]); - const { fileInputRef, isDragOver, @@ -152,34 +141,39 @@ export function AgentImportDialog({ return ( - + {t("importDialog.title")} {t("importDialog.description")} - + {prepared ? ( -
- + {prepared.preview.cardImageUrl ? ( )} - - {!shouldReduceMotion && - refractionOrigin && - createPortal( - , - document.body, - )} +
) : (
{ if (isAgentImageFileName(name)) { const snapshot = decodeAgentImage(bytes); + const { width, height } = getPngDimensions(bytes); return { displayName: snapshot.profile?.displayName ?? @@ -457,7 +460,13 @@ export function AgentsView({ "Imported agent", systemPrompt: snapshot.definition.systemPrompt ?? "", identity: name, + avatar: + typeof snapshot.profile?.avatarDataUrl === "string" && + isSafePngAvatarDataUrl(snapshot.profile.avatarDataUrl) + ? snapshot.profile.avatarDataUrl + : undefined, snapshot, + cardAspectRatio: width / height, cardImageUrl: URL.createObjectURL( new Blob([new Uint8Array(bytes).buffer], { type: "image/png", diff --git a/src/features/agents/ui/__tests__/AgentImageImportDialog.test.tsx b/src/features/agents/ui/__tests__/AgentImageImportDialog.test.tsx index 3b8d57940..387ea337f 100644 --- a/src/features/agents/ui/__tests__/AgentImageImportDialog.test.tsx +++ b/src/features/agents/ui/__tests__/AgentImageImportDialog.test.tsx @@ -109,6 +109,22 @@ describe("AgentImageImportDialog", () => { expect(onConfirm).not.toHaveBeenCalled(); }); + it("closes from the dialog x button", async () => { + const onCancel = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + it("does not persist when canceled", async () => { const onCancel = vi.fn(); const onConfirm = vi.fn(); diff --git a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx index 697d13b85..5c2b3ba89 100644 --- a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx +++ b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx @@ -9,7 +9,10 @@ import { describe, expect, it, vi } from "vitest"; import { AgentImportDialog } from "../AgentImportDialog"; vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { resolvedLanguage: "en", language: "en" }, + }), })); vi.mock("motion/react", async (importOriginal) => { @@ -40,6 +43,7 @@ describe("AgentImportDialog", () => { systemPrompt: "Review carefully.", identity: "agent.agent.png", cardImageUrl: "blob:card", + cardAspectRatio: 1 / 8192, })} validateImportFile={() => null} onImportError={vi.fn()} @@ -53,7 +57,21 @@ describe("AgentImportDialog", () => { const image = await screen.findByRole("img", { name: "importDialog.previewAlt", }); - const tiltSurface = image.parentElement as HTMLDivElement; + const tiltSurface = image.closest( + '[data-agent-card-surface="true"]', + ) as HTMLDivElement; + const reveal = tiltSurface.closest( + '[data-agent-card-reveal="true"]', + ); + const revealContent = reveal?.querySelector( + '[data-agent-card-reveal-content="true"]', + ); + expect(revealContent?.className).toContain("z-10"); + expect(tiltSurface.style.aspectRatio).toBe(""); + expect(image).toHaveClass("object-contain"); + expect( + tiltSurface.querySelector('canvas[data-agent-card-frame-only="true"]'), + ).not.toBeNull(); vi.spyOn(tiltSurface, "getBoundingClientRect").mockReturnValue({ left: 0, top: 0, diff --git a/src/features/agents/ui/share-card/AgentCardReveal.test.tsx b/src/features/agents/ui/share-card/AgentCardReveal.test.tsx new file mode 100644 index 000000000..59095edc5 --- /dev/null +++ b/src/features/agents/ui/share-card/AgentCardReveal.test.tsx @@ -0,0 +1,93 @@ +import { act, render, screen } from "@testing-library/react"; +import type { ComponentPropsWithoutRef } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentCardReveal } from "./AgentCardReveal"; + +const motionMocks = vi.hoisted(() => ({ + completions: [] as Array<() => void>, + reduced: false, +})); + +vi.mock("motion/react", () => ({ + useReducedMotion: () => motionMocks.reduced, + motion: { + div: ({ + animate: _animate, + initial: _initial, + onAnimationComplete, + transition: _transition, + ...props + }: ComponentPropsWithoutRef<"div"> & { + animate?: unknown; + initial?: unknown; + onAnimationComplete?: () => void; + transition?: unknown; + }) => { + if (onAnimationComplete) + motionMocks.completions.push(onAnimationComplete); + return
; + }, + }, +})); + +describe("AgentCardReveal", () => { + beforeEach(() => { + motionMocks.completions = []; + motionMocks.reduced = false; + }); + + it("keeps the completed refraction geometry mounted behind the card", () => { + render( + +
Card
+
, + ); + + expect(screen.getByText("Card").parentElement).toHaveClass("z-10"); + expect( + document.querySelector('[data-agent-card-refraction="true"]'), + ).toHaveClass("z-0"); + + act(() => motionMocks.completions.at(-1)?.()); + + expect( + document.querySelector('[data-agent-card-refraction="true"]'), + ).toBeInTheDocument(); + }); + + it("replays refraction when the card identity changes", () => { + const { rerender } = render( + +
Card
+
, + ); + const firstRefraction = document.querySelector( + '[data-agent-card-refraction="true"]', + ); + + rerender( + +
Card
+
, + ); + + const secondRefraction = document.querySelector( + '[data-agent-card-refraction="true"]', + ); + expect(secondRefraction).toBeInTheDocument(); + expect(secondRefraction).not.toBe(firstRefraction); + }); + + it("omits refraction when reduced motion is preferred", () => { + motionMocks.reduced = true; + render( + +
Card
+
, + ); + + expect( + document.querySelector('[data-agent-card-refraction="true"]'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/agents/ui/share-card/AgentCardReveal.tsx b/src/features/agents/ui/share-card/AgentCardReveal.tsx new file mode 100644 index 000000000..077df242e --- /dev/null +++ b/src/features/agents/ui/share-card/AgentCardReveal.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { cn } from "@/shared/lib/cn"; + +const REFRACTION_LOBES = [ + { + x: -18, + y: -10, + rotate: -12, + scaleX: 1.15, + scaleY: 0.72, + color: "rgba(100, 220, 255, 0.52)", + }, + { + x: 20, + y: 4, + rotate: 24, + scaleX: 0.82, + scaleY: 1.08, + color: "rgba(239, 112, 255, 0.4)", + }, + { + x: -2, + y: 18, + rotate: 8, + scaleX: 1.02, + scaleY: 0.76, + color: "rgba(255, 218, 92, 0.34)", + }, +] as const; + +interface AgentCardRevealProps { + children: ReactNode; + identity: string; + className?: string; +} + +/** Shared entrance and refraction treatment for generated and imported cards. */ +export function AgentCardReveal({ + children, + identity, + className, +}: AgentCardRevealProps) { + const shouldReduceMotion = useReducedMotion(); + + return ( +
+ {!shouldReduceMotion ? ( + + ) : null} + + {children} + +
+ ); +} diff --git a/src/features/agents/ui/share-card/AgentShareCardPreview.tsx b/src/features/agents/ui/share-card/AgentShareCardPreview.tsx index 57f7582af..6d75171ea 100644 --- a/src/features/agents/ui/share-card/AgentShareCardPreview.tsx +++ b/src/features/agents/ui/share-card/AgentShareCardPreview.tsx @@ -1,9 +1,34 @@ +import { useEffect, useId, useMemo, useState } from "react"; import { resolveAgentIcon } from "@/features/agents/lib/resolveAgentIcon"; -import { getAgentShareCardBase } from "./agentShareCard"; +import cardFoil from "@/features/agents/assets/share-card/card-foil.png"; +import berdCardLogo from "@/features/agents/assets/share-card/berd-card-logo.svg"; +import { HolographicAgentCard } from "./HolographicAgentCard"; import { - HolographicAgentCard, - holographicCardPresets, -} from "./HolographicAgentCard"; + fallbackAgentCardColor, + sampleAgentAvatarColor, +} from "./agentCardColor"; +import { + deriveAgentCardTraitLines, + deriveAgentShareCardTextLayout, +} from "./agentShareCardLayout"; +import { loadAgentCardFonts } from "./agentShareCardFonts"; +import type { AgentShareCardCopy } from "./agentShareCardCopy"; +import { + AGENT_CARD_GEOMETRY, + agentCardFramePath, +} from "./agentShareCardGeometry"; + +function createAgentCardTextMeasure() { + const canvas = document.createElement("canvas"); + const context = /jsdom/u.test(navigator.userAgent) + ? null + : canvas.getContext("2d"); + return (text: string, font: string) => { + if (!context) return Array.from(text).length * 42; + context.font = font; + return context.measureText(text).width; + }; +} interface AgentShareCardPreviewProps { identity: string; @@ -11,6 +36,8 @@ interface AgentShareCardPreviewProps { description: string; avatarSrc?: string; alt: string; + copy: AgentShareCardCopy; + locale: string; } export function AgentShareCardPreview({ @@ -19,29 +46,266 @@ export function AgentShareCardPreview({ description, avatarSrc, alt, + copy, + locale, }: AgentShareCardPreviewProps) { + const resolvedAvatarSrc = avatarSrc ?? resolveAgentIcon(identity); + const markFilterId = `berd-card-mark-${useId().replaceAll(":", "")}`; + const [fontGeneration, setFontGeneration] = useState(0); + useEffect(() => { + let active = true; + void loadAgentCardFonts().then(() => { + if (active) setFontGeneration((generation) => generation + 1); + }); + return () => { + active = false; + }; + }, []); + const textLayout = useMemo(() => { + void fontGeneration; + const measure = createAgentCardTextMeasure(); + return deriveAgentShareCardTextLayout( + displayName, + description, + (value) => measure(value, "600 64px Inter, sans-serif"), + (value) => measure(value, "600 42px Inter, sans-serif"), + locale, + ); + }, [description, displayName, fontGeneration, locale]); + const [accentColor, setAccentColor] = useState(() => + fallbackAgentCardColor(identity), + ); + + useEffect(() => { + let active = true; + setAccentColor(fallbackAgentCardColor(identity)); + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const sampled = sampleAgentAvatarColor(image); + if (active && sampled) setAccentColor(sampled); + }; + image.src = resolvedAvatarSrc; + return () => { + active = false; + image.onload = null; + }; + }, [identity, resolvedAvatarSrc]); + + const geometry = AGENT_CARD_GEOMETRY; + const traitMeasure = createAgentCardTextMeasure(); + const goodForLines = deriveAgentCardTraitLines( + copy.goodForLabel, + copy.goodFor, + geometry.goodFor.width, + (value) => traitMeasure(value, "600 42px Inter, sans-serif"), + locale, + ); + const vibesLines = deriveAgentCardTraitLines( + copy.vibesLabel, + copy.vibes, + geometry.vibes.width, + (value) => traitMeasure(value, "600 42px Inter, sans-serif"), + locale, + ); + const { title, descriptionLines, contentShift } = textLayout; + const descriptionLineKeys = descriptionLines.map( + (line, index) => + `${line}:${descriptionLines.slice(0, index).filter((item) => item === line).length}`, + ); + return ( - -
-

- {displayName} -

-
- -
-

- {description} -

-
+ + + + + ); } diff --git a/src/features/agents/ui/share-card/AgentShareDialog.test.tsx b/src/features/agents/ui/share-card/AgentShareDialog.test.tsx index 54b2d1a09..a3fa639d6 100644 --- a/src/features/agents/ui/share-card/AgentShareDialog.test.tsx +++ b/src/features/agents/ui/share-card/AgentShareDialog.test.tsx @@ -9,9 +9,16 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Persona } from "@/shared/types/agents"; import { toast } from "sonner"; -import { MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES } from "@/features/agents/agent-snapshot"; -import { encodeAgentImage } from "@/features/agents/agent-snapshot"; -import { AgentShareDialog } from "./AgentShareDialog"; +import { + AgentSnapshotError, + encodeAgentImage, + MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES, +} from "@/features/agents/agent-snapshot"; +import { readCachedAvatarAnimation } from "@/shared/api/avatars"; +import { + AgentShareDialog, + AVATAR_ANIMATION_EMBED_TIMEOUT_MS, +} from "./AgentShareDialog"; import { downloadBlob, renderAgentShareCard } from "./agentShareCard"; const avatarHookMocks = vi.hoisted(() => ({ @@ -26,7 +33,10 @@ const avatarHookMocks = vi.hoisted(() => ({ })); vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { resolvedLanguage: "en", language: "en" }, + }), })); vi.mock("sonner", () => ({ @@ -34,11 +44,23 @@ vi.mock("sonner", () => ({ })); vi.mock("@/features/agents/agent-snapshot", () => ({ + AgentSnapshotError: class AgentSnapshotError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + } + }, MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES: 5 * 1024 * 1024, encodeAgentImage: vi.fn((bytes: Uint8Array) => bytes), personaToSnapshot: vi.fn(() => ({})), })); +vi.mock("@/shared/api/avatars", () => ({ + readCachedAvatarAnimation: vi.fn(), +})); + vi.mock("@/shared/hooks/useAvatarSrc", () => ({ useAvatarImage: () => avatarHookMocks.image, useAvatarMediaState: () => ({ @@ -76,6 +98,22 @@ const persona: Persona = { writable: true, }; +async function markCardReady(): Promise { + const preload = await waitFor(() => { + const image = document.querySelector( + '[data-testid="agent-card-avatar-preload"]', + ); + expect(image).not.toBeNull(); + return image as HTMLImageElement; + }); + fireEvent.load(preload); + await waitFor(() => + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).toBeEnabled(), + ); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((promiseResolve) => { @@ -89,6 +127,7 @@ describe("AgentShareDialog", () => { vi.clearAllMocks(); avatarHookMocks.image = "https://example.com/avatar.png"; avatarHookMocks.media = undefined; + vi.mocked(readCachedAvatarAnimation).mockResolvedValue(null); }); it("prevents duplicate agent-file downloads while one is pending", async () => { @@ -114,6 +153,114 @@ describe("AgentShareDialog", () => { await waitFor(() => expect(button).not.toBeDisabled()); }); + it("recognizes an avatar that completed before its load handler attached", async () => { + const originalComplete = Object.getOwnPropertyDescriptor( + HTMLImageElement.prototype, + "complete", + ); + const originalNaturalWidth = Object.getOwnPropertyDescriptor( + HTMLImageElement.prototype, + "naturalWidth", + ); + Object.defineProperty(HTMLImageElement.prototype, "complete", { + configurable: true, + get: () => true, + }); + Object.defineProperty(HTMLImageElement.prototype, "naturalWidth", { + configurable: true, + get: () => 400, + }); + + try { + render( + , + ); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).not.toBeDisabled(), + ); + await waitFor(() => + expect( + screen.queryByLabelText("share.loadingCard"), + ).not.toBeInTheDocument(), + ); + } finally { + if (originalComplete) { + Object.defineProperty( + HTMLImageElement.prototype, + "complete", + originalComplete, + ); + } + if (originalNaturalWidth) { + Object.defineProperty( + HTMLImageElement.prototype, + "naturalWidth", + originalNaturalWidth, + ); + } + } + }); + + it("keeps the modal out of scroll containment for the refraction halo", () => { + render( + , + ); + + expect(screen.getByRole("dialog")).toHaveClass( + "overflow-visible", + "overflow-y-visible", + ); + }); + + it("uses viewport-safe geometry for loading and unavailable states", async () => { + const { rerender } = render( + , + ); + + expect( + screen.getByLabelText("share.loadingCard").parentElement, + ).toHaveClass("max-w-[min(19rem,calc((100dvh-18rem)*0.6667))]"); + + fireEvent.error(screen.getByTestId("agent-card-avatar-preload")); + await waitFor(() => + expect(screen.getByTestId("agent-card-avatar-preload")).toHaveAttribute( + "src", + expect.not.stringContaining("example.com"), + ), + ); + fireEvent.error(screen.getByTestId("agent-card-avatar-preload")); + expect(await screen.findByRole("status")).toHaveClass( + "max-w-[min(19rem,calc((100dvh-18rem)*0.6667))]", + ); + + rerender( + , + ); + }); + it("uses a generated avatar poster when composing the card", async () => { avatarHookMocks.image = undefined; avatarHookMocks.media = { @@ -138,6 +285,7 @@ describe("AgentShareDialog", () => { onDownloadAgent={vi.fn()} />, ); + await markCardReady(); await user.click( screen.getByRole("button", { name: "share.downloadCard" }), ); @@ -147,10 +295,346 @@ describe("AgentShareDialog", () => { generatedPersona, "asset://generated-avatar.png", expect.any(String), + expect.objectContaining({ + goodForLabel: "share.cardLabels.goodFor", + vibesLabel: "share.cardLabels.vibes", + }), + "en", + ), + ); + }); + + it("embeds cached asset-protocol avatar animation", async () => { + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "asset://generated-avatar.webm", + mediaType: "video", + posterSrc: "asset://generated-avatar.png", + }; + vi.mocked(readCachedAvatarAnimation).mockResolvedValue({ + bytes: [1, 2, 3], + mimeType: "video/webm", + }); + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + + render( + , + ); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + + await waitFor(() => + expect(encodeAgentImage).toHaveBeenCalledWith( + expect.any(Uint8Array), + expect.anything(), + expect.objectContaining({ + bytes: Uint8Array.from([1, 2, 3]), + mimeType: "video/webm", + }), ), ); }); + it("falls back when an avatar preload stalls", async () => { + vi.useFakeTimers(); + try { + render( + , + ); + const firstPreload = screen.getByTestId("agent-card-avatar-preload"); + expect(firstPreload).toHaveAttribute( + "src", + "https://example.com/avatar.png", + ); + + act(() => vi.advanceTimersByTime(10_000)); + + const fallbackPreload = screen.getByTestId("agent-card-avatar-preload"); + expect(fallbackPreload).not.toHaveAttribute( + "src", + "https://example.com/avatar.png", + ); + fireEvent.load(fallbackPreload); + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).toBeEnabled(); + } finally { + vi.useRealTimers(); + } + }); + + it("shows an empty-card error when every avatar source fails", async () => { + render( + , + ); + + fireEvent.error(screen.getByTestId("agent-card-avatar-preload")); + await waitFor(() => + expect( + screen.getByTestId("agent-card-avatar-preload"), + ).not.toHaveAttribute("src", "https://example.com/avatar.png"), + ); + fireEvent.error(screen.getByTestId("agent-card-avatar-preload")); + + const error = await screen.findByRole("status"); + expect(error).toHaveTextContent("share.avatarUnavailable"); + expect(error).toHaveClass("text-sm"); + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).toBeDisabled(); + }); + + it("uses the fetched animation MIME for extensionless URLs", async () => { + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "https://example.com/generated-avatar", + mediaType: "video", + posterSrc: "https://example.com/generated-avatar.png", + }; + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => { + const bytes = Uint8Array.from([0, 0, 0, 0, 102, 116, 121, 112]); + const blob = new Blob([bytes], { type: "video/mp4" }); + Object.defineProperty(blob, "arrayBuffer", { + value: async () => bytes.buffer, + }); + return { ok: true, blob: async () => blob } as Response; + }) as typeof fetch; + + try { + render( + , + ); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + + await waitFor(() => + expect(encodeAgentImage).toHaveBeenCalledWith( + expect.any(Uint8Array), + expect.anything(), + expect.objectContaining({ mimeType: "video/mp4" }), + ), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("falls back to a still card when animation fetching stalls", async () => { + vi.useFakeTimers(); + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "https://example.com/generated-avatar.webm", + mediaType: "video", + posterSrc: "https://example.com/generated-avatar.png", + }; + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn((input: string | URL | Request) => { + if (String(input).endsWith(".png")) { + const bytes = Uint8Array.from([137, 80, 78, 71]); + return Promise.resolve({ + ok: true, + headers: new Headers(), + blob: async () => new Blob([bytes], { type: "image/png" }), + } as Response); + } + return new Promise(() => undefined); + }) as typeof fetch; + + try { + render( + , + ); + fireEvent.load(screen.getByTestId("agent-card-avatar-preload")); + fireEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(AVATAR_ANIMATION_EMBED_TIMEOUT_MS); + await vi.runAllTimersAsync(); + }); + + expect(encodeAgentImage).toHaveBeenCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + expect(downloadBlob).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).not.toBeDisabled(); + } finally { + vi.useRealTimers(); + globalThis.fetch = originalFetch; + } + }); + + it("does not embed failed or non-video animation responses", async () => { + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "https://example.com/generated-avatar.webm", + mediaType: "video", + posterSrc: "https://example.com/generated-avatar.png", + }; + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + const originalFetch = globalThis.fetch; + let animationAttempt = 0; + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + if (String(input).endsWith(".png")) { + const bytes = Uint8Array.from([137, 80, 78, 71]); + const blob = new Blob([bytes], { type: "image/png" }); + Object.defineProperty(blob, "arrayBuffer", { + value: async () => bytes.buffer, + }); + return { + ok: true, + headers: new Headers(), + blob: async () => blob, + } as Response; + } + animationAttempt += 1; + return animationAttempt === 1 + ? ({ ok: false } as Response) + : ({ + ok: true, + blob: async () => new Blob(["error"], { type: "text/html" }), + } as Response); + }) as typeof fetch; + + try { + const { unmount } = render( + , + ); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1)); + expect(encodeAgentImage).toHaveBeenLastCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + unmount(); + + render( + , + ); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(2)); + expect(encodeAgentImage).toHaveBeenLastCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("retries without animation when the combined card exceeds the PNG limit", async () => { + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "https://example.com/generated-avatar.webm", + mediaType: "video", + posterSrc: "https://example.com/generated-avatar.png", + }; + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + vi.mocked(encodeAgentImage) + .mockImplementationOnce(() => { + throw new AgentSnapshotError("too large", "too-large"); + }) + .mockImplementationOnce((bytes) => bytes); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => { + const bytes = new Uint8Array([1, 2, 3]); + const blob = new Blob([bytes], { type: "video/webm" }); + Object.defineProperty(blob, "arrayBuffer", { + value: async () => bytes.buffer, + }); + return { ok: true, blob: async () => blob } as Response; + }) as typeof fetch; + + try { + render( + , + ); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + + await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1)); + expect(encodeAgentImage).toHaveBeenCalledTimes(2); + expect(encodeAgentImage).toHaveBeenLastCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("omits oversized avatar animation while still downloading the card", async () => { avatarHookMocks.image = undefined; avatarHookMocks.media = { @@ -185,6 +669,7 @@ describe("AgentShareDialog", () => { onDownloadAgent={vi.fn()} />, ); + await markCardReady(); await userEvent.click( screen.getByRole("button", { name: "share.downloadCard" }), ); @@ -201,6 +686,46 @@ describe("AgentShareDialog", () => { } }); + it("does not embed animation when the reviewed still is a fallback", async () => { + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "https://example.com/generated-avatar.webm", + mediaType: "video", + posterSrc: "https://example.com/generated-avatar.png", + }; + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + render( + , + ); + const poster = document.querySelector( + '[data-testid="agent-card-avatar-preload"]', + ); + fireEvent.error(poster as HTMLImageElement); + await markCardReady(); + await userEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + + await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1)); + expect(fetchSpy).not.toHaveBeenCalledWith( + "https://example.com/generated-avatar.webm", + ); + expect(encodeAgentImage).toHaveBeenCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + fetchSpy.mockRestore(); + }); + it("falls back locally when a configured avatar cannot resolve", async () => { avatarHookMocks.image = undefined; avatarHookMocks.media = undefined; @@ -214,19 +739,17 @@ describe("AgentShareDialog", () => { />, ); - const fallback = document.querySelector( - 'img[src*="goose-avatar"]', + const fallback = screen.getByTestId("agent-card-avatar-preload"); + expect(fallback).toHaveAttribute( + "src", + expect.stringContaining("goose-avatar"), ); - expect(fallback).not.toBeNull(); - fireEvent.load(fallback as HTMLImageElement); + fireEvent.load(fallback); await waitFor(() => expect( - screen.queryByLabelText("share.loadingCard"), - ).not.toBeInTheDocument(), + screen.getByRole("button", { name: "share.downloadCard" }), + ).toBeEnabled(), ); - expect( - screen.getByRole("button", { name: "share.downloadCard" }), - ).toBeEnabled(); }); it("suppresses an in-flight card when the avatar changes", async () => { @@ -241,6 +764,7 @@ describe("AgentShareDialog", () => { />, ); + await markCardReady(); act(() => { screen.getByRole("button", { name: "share.downloadCard" }).click(); }); @@ -259,6 +783,7 @@ describe("AgentShareDialog", () => { expect(downloadBlob).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled(); + await markCardReady(); const downloadButton = screen.getByRole("button", { name: "share.downloadCard", }); @@ -291,6 +816,7 @@ describe("AgentShareDialog", () => { />, ); + await markCardReady(); const downloadButton = screen.getByRole("button", { name: "share.downloadCard", }); @@ -319,6 +845,7 @@ describe("AgentShareDialog", () => { />, ); + await markCardReady(); await user.click( screen.getByRole("button", { name: "share.downloadCard" }), ); @@ -335,6 +862,19 @@ describe("AgentShareDialog", () => { await waitFor(() => expect(renderAgentShareCard).toHaveBeenCalledTimes(1)); expect(downloadBlob).not.toHaveBeenCalled(); expect(toast.success).not.toHaveBeenCalled(); + + rerender( + , + ); + await markCardReady(); + expect( + screen.getByRole("button", { name: "share.downloadCard" }), + ).toBeEnabled(); }); it("starts only one card download for rapid duplicate activation", async () => { @@ -349,6 +889,7 @@ describe("AgentShareDialog", () => { />, ); + await markCardReady(); const downloadButton = screen.getByRole("button", { name: "share.downloadCard", }); @@ -364,4 +905,61 @@ describe("AgentShareDialog", () => { }); await waitFor(() => expect(downloadBlob).toHaveBeenCalledTimes(1)); }); + + it("falls back when cached animation IPC stalls and ignores late bytes", async () => { + vi.useFakeTimers(); + avatarHookMocks.image = undefined; + avatarHookMocks.media = { + src: "asset://generated-avatar.webm", + mediaType: "video", + posterSrc: "asset://generated-avatar.png", + }; + const cachedAnimation = deferred<{ + bytes: number[]; + mimeType: string; + } | null>(); + vi.mocked(readCachedAvatarAnimation).mockReturnValue( + cachedAnimation.promise, + ); + vi.mocked(renderAgentShareCard).mockResolvedValue( + new Blob(["card"], { type: "image/png" }), + ); + + let unmount: () => void = () => undefined; + try { + ({ unmount } = render( + , + )); + fireEvent.load(screen.getByTestId("agent-card-avatar-preload")); + fireEvent.click( + screen.getByRole("button", { name: "share.downloadCard" }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(AVATAR_ANIMATION_EMBED_TIMEOUT_MS); + await vi.runAllTimersAsync(); + }); + + expect(encodeAgentImage).toHaveBeenCalledTimes(1); + expect(encodeAgentImage).toHaveBeenCalledWith( + expect.any(Uint8Array), + expect.anything(), + null, + ); + expect(downloadBlob).toHaveBeenCalledTimes(1); + + cachedAnimation.resolve({ bytes: [1, 2, 3], mimeType: "video/webm" }); + await cachedAnimation.promise; + await Promise.resolve(); + expect(encodeAgentImage).toHaveBeenCalledTimes(1); + expect(downloadBlob).toHaveBeenCalledTimes(1); + } finally { + unmount(); + vi.useRealTimers(); + } + }); }); diff --git a/src/features/agents/ui/share-card/AgentShareDialog.tsx b/src/features/agents/ui/share-card/AgentShareDialog.tsx index e901b1c16..e3e0c742c 100644 --- a/src/features/agents/ui/share-card/AgentShareDialog.tsx +++ b/src/features/agents/ui/share-card/AgentShareDialog.tsx @@ -10,8 +10,12 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import type { Persona } from "@/shared/types/agents"; -import { isSafePngAvatarDataUrl } from "@/shared/lib/avatarUrl"; import { + isSafePngAvatarDataUrl, + MAX_PNG_AVATAR_BYTES, +} from "@/shared/lib/avatarUrl"; +import { + AgentSnapshotError, encodeAgentImage, MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES, personaToSnapshot, @@ -21,6 +25,7 @@ import { useAvatarMediaState, } from "@/shared/hooks/useAvatarSrc"; import { resolveAgentIcon } from "@/features/agents/lib/resolveAgentIcon"; +import { readCachedAvatarAnimation } from "@/shared/api/avatars"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -30,10 +35,9 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { - HolographicAgentCard, - holographicCardPresets, -} from "./HolographicAgentCard"; +import { AgentShareCardPreview } from "./AgentShareCardPreview"; +import { AgentCardReveal } from "./AgentCardReveal"; +import { resolveAgentShareCardCopy } from "./agentShareCardCopy"; import { blobToBytes, createAvatarPoster, @@ -49,8 +53,20 @@ async function avatarSourceToDataUrl(source: string): Promise { try { const response = await fetch(source); if (!response.ok) return null; + const contentLength = Number(response.headers.get("content-length")); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_PNG_AVATAR_BYTES + ) { + return null; + } const blob = await response.blob(); - if (blob.type && blob.type !== "image/png") return null; + if ( + blob.size > MAX_PNG_AVATAR_BYTES || + (blob.type && blob.type !== "image/png") + ) { + return null; + } const bytes = await blobToBytes(blob); let binary = ""; for (let offset = 0; offset < bytes.length; offset += 0x8000) { @@ -63,6 +79,27 @@ async function avatarSourceToDataUrl(source: string): Promise { } } +export const AVATAR_ANIMATION_EMBED_TIMEOUT_MS = 5_000; + +async function withAnimationEmbedDeadline( + operation: (signal: AbortSignal) => Promise, + timeoutMs = AVATAR_ANIMATION_EMBED_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + let timeout: number | undefined; + const deadline = new Promise((_, reject) => { + timeout = window.setTimeout(() => { + controller.abort(); + reject(new Error("Avatar animation request timed out")); + }, timeoutMs); + }); + try { + return await Promise.race([operation(controller.signal), deadline]); + } finally { + if (timeout !== undefined) window.clearTimeout(timeout); + } +} + interface AgentShareDialogProps { open: boolean; persona: Persona; @@ -76,7 +113,7 @@ export function AgentShareDialog({ onOpenChange, onDownloadAgent, }: AgentShareDialogProps) { - const { t } = useTranslation("agents"); + const { t, i18n } = useTranslation("agents"); const shouldReduceMotion = useReducedMotion(); const [avatarReadySrc, setAvatarReadySrc] = useState(null); const [failedAvatarSources, setFailedAvatarSources] = useState>( @@ -88,6 +125,7 @@ export function AgentShareDialog({ avatar: Persona["avatar"]; src: string; } | null>(null); + const avatarPreloadRef = useRef(null); const cardDownloadInFlightRef = useRef(false); const agentDownloadInFlightRef = useRef(false); const cardOperationGenerationRef = useRef(0); @@ -113,12 +151,18 @@ export function AgentShareDialog({ const avatarSrc = avatarCandidates.find( (source) => !failedAvatarSources.has(source), ); + const avatarUnavailable = avatarCandidates.every((source) => + failedAvatarSources.has(source), + ); // The card can render as soon as the exact still image it will display has // decoded. Cached animation/poster resolution may continue independently. const cardReady = Boolean(avatarSrc && avatarReadySrc === avatarSrc); const cardBase = getAgentShareCardBase(persona.id); const description = getAgentShareDescription(persona); + const locale = i18n?.resolvedLanguage ?? i18n?.language ?? "en"; + const cardCopy = resolveAgentShareCardCopy(description, t); const cardContentIdentity = [ + locale, persona.id, persona.avatar, persona.displayName, @@ -129,6 +173,10 @@ export function AgentShareDialog({ useEffect(() => { if (!open) { cardOperationGenerationRef.current += 1; + cardDownloadInFlightRef.current = false; + agentDownloadInFlightRef.current = false; + setCardDownloadPending(false); + setAgentDownloadPending(false); } return () => { cardOperationGenerationRef.current += 1; @@ -146,6 +194,18 @@ export function AgentShareDialog({ setCardDownloadPending(false); }, [cardContentIdentity]); + useEffect(() => { + if (!open || !avatarSrc || cardReady) return; + const timeout = window.setTimeout(() => { + setFailedAvatarSources((current) => { + const next = new Set(current); + next.add(avatarSrc); + return next; + }); + }, 10_000); + return () => window.clearTimeout(timeout); + }, [avatarSrc, cardReady, open]); + useEffect(() => { if (!open || !cachedAvatar) return; let cancelled = false; @@ -173,11 +233,17 @@ export function AgentShareDialog({ try { // Render exactly what the reviewed card displays. Re-generating a second // poster here can produce a different or blank frame for stacked videos. - const cardAvatarSrc = avatarSrc; + const cardAvatarSrc = avatarReadySrc; if (!cardAvatarSrc) { throw new Error("Agent avatar is not ready"); } - const card = await renderAgentShareCard(persona, cardAvatarSrc, cardBase); + const card = await renderAgentShareCard( + persona, + cardAvatarSrc, + cardBase, + cardCopy, + locale, + ); if (operationGeneration !== cardOperationGenerationRef.current) return; const embeddedAvatar = await avatarSourceToDataUrl(cardAvatarSrc); if (operationGeneration !== cardOperationGenerationRef.current) return; @@ -186,30 +252,71 @@ export function AgentShareDialog({ avatar: embeddedAvatar ?? persona.avatar, }); let animation = null; - if (cachedAvatar?.mediaType === "video") { + const stillMatchesAnimation = Boolean( + cachedAvatar?.mediaType === "video" && + (cardAvatarSrc === currentGeneratedAvatarPoster || + cardAvatarSrc === cachedAvatar.posterSrc), + ); + if (cachedAvatar?.mediaType === "video" && stillMatchesAnimation) { try { - const animationBytes = await fetch(cachedAvatar.src) - .then((response) => response.blob()) - .then(blobToBytes); - if (animationBytes.length <= MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES) { - animation = { - bytes: animationBytes, - mimeType: cachedAvatar.src.toLowerCase().includes(".mp4") - ? ("video/mp4" as const) - : ("video/webm" as const), - alphaMode: cachedAvatar.alphaMode, - }; + if ( + /^asset:/u.test(cachedAvatar.src) && + typeof persona.avatar === "string" + ) { + const avatarRef = persona.avatar; + const cachedAnimation = await withAnimationEmbedDeadline(() => + readCachedAvatarAnimation({ avatarRef }), + ); + if (cachedAnimation) { + animation = { + bytes: new Uint8Array(cachedAnimation.bytes), + mimeType: + cachedAnimation.mimeType === "video/mp4" + ? ("video/mp4" as const) + : ("video/webm" as const), + alphaMode: cachedAnimation.alphaMode, + }; + } + } else if (/^(?:https?:|blob:|data:)/u.test(cachedAvatar.src)) { + const blob = await withAnimationEmbedDeadline(async (signal) => { + const response = await fetch(cachedAvatar.src, { signal }); + if (!response.ok) + throw new Error("Avatar animation request failed"); + return await response.blob(); + }); + if (blob.type !== "video/mp4" && blob.type !== "video/webm") { + throw new Error("Avatar animation has an unsupported media type"); + } + const animationBytes = await blobToBytes(blob); + const mimeType = blob.type as "video/mp4" | "video/webm"; + if (animationBytes.length <= MAX_SNAPSHOT_AVATAR_ANIMATION_BYTES) { + animation = { + bytes: animationBytes, + mimeType, + alphaMode: cachedAvatar.alphaMode, + }; + } } } catch (error) { console.warn("Could not embed animated avatar:", error); } } if (operationGeneration !== cardOperationGenerationRef.current) return; - const encodedCard = encodeAgentImage( - await blobToBytes(card), - snapshot, - animation, - ); + const cardBytes = await blobToBytes(card); + let encodedCard: Uint8Array; + try { + encodedCard = encodeAgentImage(cardBytes, snapshot, animation); + } catch (error) { + if ( + !(error instanceof AgentSnapshotError) || + error.code !== "too-large" + ) { + throw error; + } + // The still card remains portable when an otherwise-valid animation + // would push the combined PNG over the snapshot size limit. + encodedCard = encodeAgentImage(cardBytes, snapshot, null); + } if (operationGeneration !== cardOperationGenerationRef.current) return; const filename = getAgentShareFilename(persona.displayName); downloadBlob( @@ -227,7 +334,29 @@ export function AgentShareDialog({ setCardDownloadPending(false); } } - }, [avatarSrc, cachedAvatar, cardBase, persona, t]); + }, [ + avatarReadySrc, + cachedAvatar, + cardCopy, + cardBase, + currentGeneratedAvatarPoster, + locale, + persona, + t, + ]); + + const handleAvatarPreloadRef = useCallback( + (node: HTMLImageElement | null) => { + avatarPreloadRef.current = node; + if (!node || !avatarSrc) return; + // Run after layout effects reset readiness for changed card content. + queueMicrotask(() => { + if (avatarPreloadRef.current !== node || !node.complete) return; + if (node.naturalWidth > 0) setAvatarReadySrc(avatarSrc); + }); + }, + [avatarSrc], + ); const handleDownloadAgent = useCallback(async () => { if (agentDownloadInFlightRef.current) return; @@ -243,7 +372,11 @@ export function AgentShareDialog({ return ( - + {t("share.title", { name: persona.displayName })} @@ -251,13 +384,15 @@ export function AgentShareDialog({ {t("share.description")} -
+
{avatarSrc ? ( setAvatarReadySrc(avatarSrc)} onError={() => { @@ -271,14 +406,26 @@ export function AgentShareDialog({ /> ) : null} - {!cardReady ? ( + {avatarUnavailable ? ( + + {t("share.avatarUnavailable")} + + ) : !cardReady ? ( ) : ( - - + -
-

- {persona.displayName} -

-
- -
-

- {description} -

-
-
-
+ copy={cardCopy} + locale={locale} + /> + )}
@@ -338,7 +459,7 @@ export function AgentShareDialog({ ) } - disabled={cardDownloadPending} + disabled={cardDownloadPending || !cardReady} onClick={() => void handleDownloadCard()} > {t("share.downloadCard")} diff --git a/src/features/agents/ui/share-card/HolographicAgentCard.tsx b/src/features/agents/ui/share-card/HolographicAgentCard.tsx index 11fdfa210..9885083e5 100644 --- a/src/features/agents/ui/share-card/HolographicAgentCard.tsx +++ b/src/features/agents/ui/share-card/HolographicAgentCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import type { ReactNode } from "react"; import { cn } from "@/shared/lib/cn"; import { resetCardTilt, updateCardTilt } from "./cardTilt"; @@ -22,6 +22,16 @@ export interface HolographicAgentCardProps { children?: ReactNode; className?: string; settings?: HolographicCardSettings; + /** Preserve the source artwork's exact proportions when showing imports. */ + aspectRatio?: number; + /** Constrain extreme imported proportions without cropping their pixels. */ + containArtwork?: boolean; + /** Optional agent-derived color used to tint the card's ambient shadow. */ + shadowColor?: string; + /** Optional agent-derived color applied to the card's foil frame. */ + tintColor?: string; + /** Restrict the animated foil to the frame when artwork already contains its content panel. */ + frameOnly?: boolean; } export const holographicCardPresets = { @@ -37,11 +47,11 @@ export const holographicCardPresets = { }, rainbowPrism: { finish: "prismatic", - metalness: 38, - rainbow: 34, - glare: 36, + metalness: 30, + rainbow: 72, + glare: 72, grain: 14, - diffraction: 42, + diffraction: 80, speed: 20, tilt: 8, }, @@ -142,9 +152,15 @@ export function HolographicAgentCard({ children, className, settings = holographicCardPresets.rainbowPrism, + aspectRatio, + containArtwork = false, + shadowColor, + tintColor, + frameOnly = false, }: HolographicAgentCardProps) { const canvasRef = useRef(null); const cardRef = useRef(null); + const patternId = `agent-card-wave-${useId().replaceAll(":", "")}`; const [contextGeneration, setContextGeneration] = useState(0); const pointer = useRef({ x: 0, y: 0 }); const target = useRef({ x: 0, y: 0 }); @@ -173,15 +189,28 @@ export function HolographicAgentCard({ const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexShader); const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentShader); - if (!vertex || !fragment) return; + if (!vertex || !fragment) { + if (vertex) gl.deleteShader(vertex); + if (fragment) gl.deleteShader(fragment); + return; + } const program = gl.createProgram(); - if (!program) return; + if (!program) { + gl.deleteShader(vertex); + gl.deleteShader(fragment); + return; + } gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + gl.deleteProgram(program); + gl.deleteShader(vertex); + gl.deleteShader(fragment); + return; + } const activateProgram = gl.useProgram.bind(gl); activateProgram(program); @@ -322,6 +351,7 @@ export function HolographicAgentCard({ handlePointerTargetChange, ); image.onload = null; + image.removeAttribute("src"); gl.deleteTexture(texture); gl.deleteBuffer(buffer); gl.deleteProgram(program); @@ -333,20 +363,96 @@ export function HolographicAgentCard({ return (
{ hoverBoundsRef.current = event.currentTarget.getBoundingClientRect(); + event.currentTarget.style.setProperty( + "--agent-card-pattern-local-x", + `${event.nativeEvent.offsetX}px`, + ); + event.currentTarget.style.setProperty( + "--agent-card-pattern-local-y", + `${event.nativeEvent.offsetY}px`, + ); + const localX = event.nativeEvent.offsetX; + const localY = event.nativeEvent.offsetY; + if ( + Number.isFinite(localX) && + Number.isFinite(localY) && + event.currentTarget.clientWidth > 0 && + event.currentTarget.clientHeight > 0 + ) { + event.currentTarget.style.setProperty( + "--agent-card-pattern-svg-x", + `${(localX / event.currentTarget.clientWidth) * 1227}`, + ); + event.currentTarget.style.setProperty( + "--agent-card-pattern-svg-y", + `${(localY / event.currentTarget.clientHeight) * 1839}`, + ); + } + event.currentTarget.style.setProperty( + "--agent-card-pattern-opacity", + "0.52", + ); }} onPointerMove={(event) => { + // Re-read bounds after every tilt. The transformed card's client rect + // changes as it rotates, so enter-time bounds make cursor effects lag + // behind or drift away from the pointer. + const measuredBounds = event.currentTarget.getBoundingClientRect(); const bounds = - hoverBoundsRef.current ?? event.currentTarget.getBoundingClientRect(); + measuredBounds.width > 0 && measuredBounds.height > 0 + ? measuredBounds + : hoverBoundsRef.current; + if (!bounds || bounds.width <= 0 || bounds.height <= 0) return; + hoverBoundsRef.current = bounds; target.current = { x: ((event.clientX - bounds.left) / bounds.width - 0.5) * 2, y: ((event.clientY - bounds.top) / bounds.height - 0.5) * 2, }; + // Use local pixels from the transformed hit target. Percentages derived from + // an axis-aligned bounding box drift as the card rotates in 3D. + event.currentTarget.style.setProperty( + "--agent-card-pattern-local-x", + `${event.nativeEvent.offsetX}px`, + ); + event.currentTarget.style.setProperty( + "--agent-card-pattern-local-y", + `${event.nativeEvent.offsetY}px`, + ); + const localX = event.nativeEvent.offsetX; + const localY = event.nativeEvent.offsetY; + if ( + Number.isFinite(localX) && + Number.isFinite(localY) && + event.currentTarget.clientWidth > 0 && + event.currentTarget.clientHeight > 0 + ) { + event.currentTarget.style.setProperty( + "--agent-card-pattern-svg-x", + `${(localX / event.currentTarget.clientWidth) * 1227}`, + ); + event.currentTarget.style.setProperty( + "--agent-card-pattern-svg-y", + `${(localY / event.currentTarget.clientHeight) * 1839}`, + ); + } + event.currentTarget.style.setProperty( + "--agent-card-pattern-opacity", + "0.52", + ); updateCardTilt( event.currentTarget, event, @@ -358,32 +464,127 @@ export function HolographicAgentCard({ onPointerLeave={(event) => { hoverBoundsRef.current = null; target.current = { x: 0, y: 0 }; + event.currentTarget.style.removeProperty( + "--agent-card-pattern-local-x", + ); + event.currentTarget.style.removeProperty( + "--agent-card-pattern-local-y", + ); + event.currentTarget.style.removeProperty("--agent-card-pattern-svg-x"); + event.currentTarget.style.removeProperty("--agent-card-pattern-svg-y"); + event.currentTarget.style.setProperty( + "--agent-card-pattern-opacity", + "0", + ); resetCardTilt(event.currentTarget); event.currentTarget.dispatchEvent(new Event("holographic-card-move")); }} > - {alt} - - {children ? ( -
- {children} -
- ) : null} -