Add animated profile avatars (#1031)

This commit is contained in:
klopez4212
2026-06-15 22:52:50 +01:00
committed by GitHub
parent 89ae31d203
commit 116486592d
30 changed files with 4728 additions and 677 deletions

View File

@@ -26,6 +26,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@mediapipe/tasks-vision": "^0.10.35",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -70,6 +71,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"upng-js": "^2.1.0",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},

View File

@@ -41,6 +41,7 @@ export default defineConfig({
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/animated-avatar-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View File

@@ -8,5 +8,7 @@
<string>Buzz</string>
<key>NSMicrophoneUsageDescription</key>
<string>Buzz needs microphone access for voice huddles.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access to record animated avatars.</string>
</dict>
</plist>

View File

@@ -138,6 +138,8 @@ function AvatarStepActions({
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
return (
<AnimatePresence initial={false} mode="popLayout">
{hidden ? null : (
@@ -178,7 +180,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onSkipForNow}
type="button"
variant="ghost"
@@ -191,7 +193,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-next-without-saving"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
@@ -203,7 +205,7 @@ function AvatarStepActions({
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-back"
disabled={isSaving}
disabled={areNavigationActionsDisabled}
onClick={onBack}
type="button"
variant="ghost"
@@ -237,15 +239,58 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [avatarEditorMode, setAvatarEditorMode] =
React.useState<AvatarMode>("image");
const [animatedPreviewEl, setAnimatedPreviewEl] =
React.useState<HTMLDivElement | null>(null);
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
React.useState(false);
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
string | null
>(null);
const [pendingAnimatedAvatarUrl, setPendingAnimatedAvatarUrl] =
React.useState<string | null>(null);
const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] =
React.useState(false);
const canSubmit =
avatar.draftUrl.trim().length > 0 && !isSaving && !isUploadingAvatar;
const hasAvatarDraft = avatar.draftUrl.trim().length > 0;
const canSubmit = hasAvatarDraft && !isSaving && !isUploadingAvatar;
const isAutoAdvancingAnimatedAvatar = pendingAnimatedAvatarUrl !== null;
const shouldHideActionsForAnimatedAvatar =
avatarEditorMode === "animated" &&
(!hasAvatarDraft || isAutoAdvancingAnimatedAvatar);
const areActionsHidden =
isCustomColorPickerOpen || shouldHideActionsForAnimatedAvatar;
const previewName =
name.draftValue.trim() || name.savedValue.trim() || "Your avatar";
const animateEmojiAvatarChange = React.useCallback(() => {
setAvatarSquishKey((key) => key + 1);
}, []);
const handleAnimatedAvatarApply = React.useCallback((url: string) => {
setPendingAnimatedAvatarUrl(url);
}, []);
React.useEffect(() => {
if (!pendingAnimatedAvatarUrl) {
return;
}
if (avatar.draftUrl !== pendingAnimatedAvatarUrl) {
return;
}
if (saveRecovery.errorMessage) {
setPendingAnimatedAvatarUrl(null);
return;
}
if (isSaving || isUploadingAvatar) {
return;
}
submit();
}, [
avatar.draftUrl,
isSaving,
isUploadingAvatar,
pendingAnimatedAvatarUrl,
saveRecovery.errorMessage,
submit,
]);
return (
<OnboardingSlideTransition
@@ -270,12 +315,35 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
</p>
</div>
<div className="mt-12">
<AvatarPreview
avatarSquishKey={avatarSquishKey}
avatarUrl={avatar.draftUrl}
previewName={previewName}
/>
<div className="mt-12 grid justify-items-center gap-3 lg:justify-items-start">
<div className="relative h-48 w-48">
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="onboarding-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{isAnimatedPreviewActive ? null : (
<AvatarPreview
avatarSquishKey={avatarSquishKey}
avatarUrl={avatar.draftUrl}
previewName={previewName}
/>
)}
</div>
<AnimatePresence initial={false}>
{animatedPreviewCaption ? (
<motion.p
animate={{ opacity: 1, y: 0 }}
className="w-48 text-center text-sm font-medium text-muted-foreground"
exit={{ opacity: 0, y: -4 }}
initial={{ opacity: 0, y: 4 }}
transition={AVATAR_ACTIONS_MOTION_TRANSITION}
>
{animatedPreviewCaption}
</motion.p>
) : null}
</AnimatePresence>
</div>
</div>
@@ -286,10 +354,14 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
transition={AVATAR_POSITION_MOTION_TRANSITION}
>
<ProfileAvatarEditor
animatedPreviewContainer={animatedPreviewEl}
avatarUrl={avatar.draftUrl}
disabled={isSaving}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onAnimatedAvatarApply={handleAnimatedAvatarApply}
onAnimatedPreviewActiveChange={setIsAnimatedPreviewActive}
onAnimatedPreviewCaptionChange={setAnimatedPreviewCaption}
onCustomColorPickerOpenChange={setIsCustomColorPickerOpen}
onEmojiAvatarChange={animateEmojiAvatarChange}
onModeChange={setAvatarEditorMode}
@@ -305,7 +377,7 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
<AvatarStepActions
canSubmit={canSubmit}
hidden={isCustomColorPickerOpen}
hidden={areActionsHidden}
isSaving={isSaving}
isUploadingAvatar={isUploadingAvatar}
onBack={back}

View File

@@ -17,6 +17,7 @@ import type {
UsersBatchResponse,
} from "@/shared/api/types";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
SELF_PROFILE_CACHE_EVENT,
@@ -49,9 +50,10 @@ async function persistSelfProfile(
profile: Profile,
): Promise<void> {
const existing = readSelfProfileCache(relayUrl, pubkey);
const avatarSnapshotUrl = getAvatarSnapshotUrl(profile.avatarUrl);
const fetched =
shouldFetchAvatar(profile.avatarUrl, existing) && profile.avatarUrl !== null
? await fetchAvatarDataUrl(rewriteRelayUrl(profile.avatarUrl))
shouldFetchAvatar(profile.avatarUrl, existing) && avatarSnapshotUrl !== null
? await fetchAvatarDataUrl(rewriteRelayUrl(avatarSnapshotUrl))
: null;
const avatarDataUrl = resolveAvatarDataUrl(
profile.avatarUrl,

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { listAvatarCameras } from "./animatedAvatarCapture.ts";
function mockMediaDevices(devices) {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
mediaDevices: {
enumerateDevices: async () => devices,
},
},
});
}
test("listAvatarCameras: preserves blank pre-permission camera labels", async () => {
mockMediaDevices([
{
deviceId: "default",
kind: "videoinput",
label: "",
},
{
deviceId: "continuity",
kind: "videoinput",
label: "",
},
{
deviceId: "microphone",
kind: "audioinput",
label: "Studio Mic",
},
]);
const cameras = await listAvatarCameras();
assert.deepEqual(cameras, [
{ deviceId: "default", label: "" },
{ deviceId: "continuity", label: "" },
]);
assert.equal(
cameras.some((device) => device.label.trim().length > 0),
false,
);
});

View File

@@ -0,0 +1,718 @@
/**
* Animated avatar capture pipeline.
*
* Records a short clip from the user's camera, removes the background per
* frame with MediaPipe selfie segmentation (so only the person remains, with
* soft alpha edges), and composes each frame as a "sticker": the person pops
* out of a colored backdrop disc that sits inside the avatar circle. The
* result is encoded as a ping-pong looping animated PNG (APNG) — full 24-bit
* color and 8-bit alpha, unlike GIF — plus a static poster frame.
*
* Segmentation assets (wasm + model) are fetched lazily from public CDNs the
* first time the feature is used. If they can't be loaded (e.g. offline),
* recording still works — the background just isn't removed.
*/
import type { ImageSegmenter } from "@mediapipe/tasks-vision";
import UPNG from "upng-js";
export const ANIMATED_AVATAR_SIZE = 256;
const ANIMATED_AVATAR_CAPTURE_SIZE = 512;
export const ANIMATED_AVATAR_FPS = 12;
export const ANIMATED_AVATAR_DURATION_MS = 3000;
export const ANIMATED_AVATAR_FRAME_COUNT = Math.round(
(ANIMATED_AVATAR_DURATION_MS / 1000) * ANIMATED_AVATAR_FPS,
);
export const ANIMATED_AVATAR_FRAME_DELAY_MS = Math.round(
1000 / ANIMATED_AVATAR_FPS,
);
// Preserve truecolor pixels in the final APNG. Palette quantization made
// high-detail dark areas, like hair, shimmer between frames.
const APNG_COLOR_COUNT = 0;
// Soft alpha ramp for the segmentation confidence: fully transparent below
// the low bound, fully opaque above the high bound, feathered in between.
const PERSON_CONFIDENCE_LOW = 0.32;
const PERSON_CONFIDENCE_HIGH = 0.7;
// Dark camera pixels tend to carry sensor noise, which reads as shimmer once
// looped. Blend only low-luma pixels that barely changed from the previous
// frame, so hair noise settles without smearing real motion.
const DARK_DETAIL_DENOISE_LUMA_LOW = 24;
const DARK_DETAIL_DENOISE_LUMA_HIGH = 150;
const DARK_DETAIL_DENOISE_DIFF_LOW = 4;
const DARK_DETAIL_DENOISE_DIFF_HIGH = 32;
const DARK_DETAIL_DENOISE_MAX_BLEND = 0.52;
// Stabilize tiny frame-to-frame mask confidence changes around fine edges
// (hair especially), while letting larger movement update immediately.
const MASK_TEMPORAL_MAX_BLEND = 0.64;
const MASK_TEMPORAL_MOTION_THRESHOLD = 0.26;
/**
* How the recorded person and the backdrop circle are placed in the frame.
* Offsets are in 256x256 frame coordinates. The person's scale multiplies
* the frame size (the default draws them slightly oversized and
* bottom-anchored so their head pops above the backdrop); the circle's
* scale multiplies its base geometry around its own center.
*/
export type AvatarComposition = {
backdropColor: string | null;
offsetX: number;
offsetY: number;
personOutline: boolean;
scale: number;
shapeOffsetX: number;
shapeOffsetY: number;
shapeScale: number;
};
// Default framing tuned by hand against real recordings.
export const DEFAULT_PERSON_SCALE = 1.26;
export const DEFAULT_PERSON_OFFSET_X = 1;
export const DEFAULT_PERSON_OFFSET_Y = 7;
export const DEFAULT_PERSON_OUTLINE = true;
export const MIN_PERSON_SCALE = 0.7;
export const MAX_PERSON_SCALE = 2;
export const DEFAULT_SHAPE_SCALE = 1.12;
export const DEFAULT_SHAPE_OFFSET_X = 0;
export const DEFAULT_SHAPE_OFFSET_Y = -7;
export const MIN_SHAPE_SCALE = 0;
export const MAX_SHAPE_SCALE = 1.5;
// Backdrop circle geometry (in 256x256 frame coordinates). The circle sits
// low in the frame and stays inside the inscribed circle that avatar
// components crop to; the pop-out clip extends a column from the circle's
// midline to the frame top so the person can rise out of the circle's top
// without spilling past its sides.
const CIRCLE_GEOMETRY = {
centerX: 128,
centerY: 152,
radius: 100,
};
const PERSON_OUTLINE_ALPHA = 0.92;
const PERSON_OUTLINE_RADIUS = 2.75;
const PERSON_OUTLINE_OFFSETS = [
[0, -PERSON_OUTLINE_RADIUS],
[PERSON_OUTLINE_RADIUS, 0],
[0, PERSON_OUTLINE_RADIUS],
[-PERSON_OUTLINE_RADIUS, 0],
[PERSON_OUTLINE_RADIUS * 0.72, -PERSON_OUTLINE_RADIUS * 0.72],
[PERSON_OUTLINE_RADIUS * 0.72, PERSON_OUTLINE_RADIUS * 0.72],
[-PERSON_OUTLINE_RADIUS * 0.72, PERSON_OUTLINE_RADIUS * 0.72],
[-PERSON_OUTLINE_RADIUS * 0.72, -PERSON_OUTLINE_RADIUS * 0.72],
] as const;
// Pinned to the installed @mediapipe/tasks-vision version so the wasm loader
// always matches the JS API.
const MEDIAPIPE_WASM_BASE =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
const SELFIE_SEGMENTER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
export type AnimatedAvatarRecording = {
/** Square RGBA cut-out frames, mirrored like a selfie preview. */
frames: ImageData[];
/** False when the segmentation model couldn't be loaded. */
backgroundRemoved: boolean;
};
export type AvatarCameraDevice = {
deviceId: string;
label: string;
};
export async function listAvatarCameras(): Promise<AvatarCameraDevice[]> {
if (!navigator.mediaDevices?.enumerateDevices) {
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices
.filter((device) => device.kind === "videoinput" && device.deviceId)
.map((device) => ({
deviceId: device.deviceId,
label: device.label,
}));
}
export async function openAvatarCamera(
deviceId?: string | null,
): Promise<MediaStream> {
const video: MediaTrackConstraints = {
facingMode: { ideal: "user" },
frameRate: { ideal: 30 },
height: { ideal: 1080 },
width: { ideal: 1080 },
};
if (deviceId) {
video.deviceId = { exact: deviceId };
}
return navigator.mediaDevices.getUserMedia({
audio: false,
video,
});
}
export function stopAvatarCamera(stream: MediaStream): void {
for (const track of stream.getTracks()) {
track.stop();
}
}
type SegmenterHandle = {
segmenter: ImageSegmenter;
/** Which confidence-mask channel holds the person. */
personChannel: number;
/** True when the channel is a background mask and must be inverted. */
invert: boolean;
};
let segmenterPromise: Promise<SegmenterHandle | null> | null = null;
/**
* Lazily create the selfie segmenter. Resolves to null when the CDN assets
* are unreachable; a failed load is retried on the next call.
*/
function loadSegmenter(): Promise<SegmenterHandle | null> {
if (!segmenterPromise) {
segmenterPromise = createSegmenter().catch(() => {
segmenterPromise = null;
return null;
});
}
return segmenterPromise;
}
async function createSegmenter(): Promise<SegmenterHandle> {
const vision = await import("@mediapipe/tasks-vision");
const fileset =
await vision.FilesetResolver.forVisionTasks(MEDIAPIPE_WASM_BASE);
const segmenter = await vision.ImageSegmenter.createFromOptions(fileset, {
baseOptions: { modelAssetPath: SELFIE_SEGMENTER_MODEL_URL },
outputCategoryMask: false,
outputConfidenceMasks: true,
runningMode: "VIDEO",
});
// The selfie model labels vary across releases ("background"/"person" vs a
// single foreground channel) — resolve which channel is the person once.
const labels = segmenter.getLabels().map((label) => label.toLowerCase());
let personChannel = labels.findIndex(
(label) =>
label.includes("person") ||
label.includes("selfie") ||
label.includes("foreground"),
);
let invert = false;
if (personChannel < 0) {
personChannel = 0;
invert =
labels.length === 1 && (labels[0]?.includes("background") ?? false);
}
return { invert, personChannel, segmenter };
}
/** Warm the segmentation model while the user lines up their shot. */
export function preloadAvatarSegmenter(): void {
void loadSegmenter();
}
export async function recordAnimatedAvatarFrames(
video: HTMLVideoElement,
options: {
signal?: AbortSignal;
onProgress?: (fraction: number) => void;
} = {},
): Promise<AnimatedAvatarRecording> {
const { onProgress, signal } = options;
const handle = await loadSegmenter();
const canvas = document.createElement("canvas");
canvas.width = ANIMATED_AVATAR_CAPTURE_SIZE;
canvas.height = ANIMATED_AVATAR_CAPTURE_SIZE;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Could not create a drawing context for recording.");
}
const frames: ImageData[] = [];
let lastTimestamp = 0;
let previousDenoisedFrame: Uint8ClampedArray | null = null;
let previousMask: Float32Array | null = null;
for (let i = 0; i < ANIMATED_AVATAR_FRAME_COUNT; i++) {
if (signal?.aborted) {
throw new DOMException("Recording cancelled", "AbortError");
}
const frameStart = performance.now();
drawMirroredCenterCrop(context, video);
const frame = context.getImageData(
0,
0,
ANIMATED_AVATAR_CAPTURE_SIZE,
ANIMATED_AVATAR_CAPTURE_SIZE,
);
previousDenoisedFrame = stabilizeDarkDetailNoise(
frame,
previousDenoisedFrame,
);
if (handle) {
// Video-mode segmentation requires strictly increasing timestamps.
let timestamp = performance.now();
if (timestamp <= lastTimestamp) {
timestamp = lastTimestamp + 1;
}
lastTimestamp = timestamp;
const result = handle.segmenter.segmentForVideo(canvas, timestamp);
try {
previousMask = applyPersonMask(
frame,
result.confidenceMasks,
handle,
previousMask,
);
} finally {
result.close();
}
}
frames.push(frame);
onProgress?.((i + 1) / ANIMATED_AVATAR_FRAME_COUNT);
const elapsed = performance.now() - frameStart;
await sleep(Math.max(0, ANIMATED_AVATAR_FRAME_DELAY_MS - elapsed));
}
return { backgroundRemoved: handle !== null, frames };
}
type ConfidenceMasks =
| readonly {
getAsFloat32Array(): Float32Array;
width: number;
height: number;
}[]
| undefined;
function applyPersonMask(
frame: ImageData,
masks: ConfidenceMasks,
handle: SegmenterHandle,
previousMask: Float32Array | null,
): Float32Array | null {
const mask = masks?.[handle.personChannel] ?? masks?.[0];
if (!mask) {
return previousMask;
}
const values = mask.getAsFloat32Array();
const pixels = frame.data;
const scaleX = mask.width / frame.width;
const scaleY = mask.height / frame.height;
const rampRange = PERSON_CONFIDENCE_HIGH - PERSON_CONFIDENCE_LOW;
const nextMask = new Float32Array(frame.width * frame.height);
const canSmoothMask = previousMask?.length === nextMask.length;
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
const confidence = sampleMaskConfidence(
values,
mask.width,
mask.height,
x * scaleX,
y * scaleY,
);
let person = handle.invert ? 1 - confidence : confidence;
const pixelIndex = y * frame.width + x;
if (canSmoothMask) {
person = stabilizeMaskConfidence(
person,
previousMask[pixelIndex] ?? person,
);
}
nextMask[pixelIndex] = person;
const offset = (y * frame.width + x) * 4;
if (person <= PERSON_CONFIDENCE_LOW) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
} else if (person < PERSON_CONFIDENCE_HIGH) {
// Feathered edge — APNG keeps the 8-bit alpha, so the cut-out blends
// smoothly instead of GIF-style jagged edges.
const alpha = (person - PERSON_CONFIDENCE_LOW) / rampRange;
pixels[offset + 3] = Math.round((pixels[offset + 3] ?? 255) * alpha);
}
}
}
return nextMask;
}
function stabilizeDarkDetailNoise(
frame: ImageData,
previousFrame: Uint8ClampedArray | null,
): Uint8ClampedArray {
const pixels = frame.data;
const nextFrame = new Uint8ClampedArray(pixels);
if (!previousFrame || previousFrame.length !== pixels.length) {
return nextFrame;
}
for (let offset = 0; offset < pixels.length; offset += 4) {
const red = pixels[offset] ?? 0;
const green = pixels[offset + 1] ?? 0;
const blue = pixels[offset + 2] ?? 0;
const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
const darkWeight = clamp01(
(DARK_DETAIL_DENOISE_LUMA_HIGH - luma) /
(DARK_DETAIL_DENOISE_LUMA_HIGH - DARK_DETAIL_DENOISE_LUMA_LOW),
);
if (darkWeight <= 0) {
continue;
}
const previousRed = previousFrame[offset] ?? red;
const previousGreen = previousFrame[offset + 1] ?? green;
const previousBlue = previousFrame[offset + 2] ?? blue;
const colorDiff =
(Math.abs(red - previousRed) +
Math.abs(green - previousGreen) +
Math.abs(blue - previousBlue)) /
3;
const stabilityWeight = clamp01(
(DARK_DETAIL_DENOISE_DIFF_HIGH - colorDiff) /
(DARK_DETAIL_DENOISE_DIFF_HIGH - DARK_DETAIL_DENOISE_DIFF_LOW),
);
if (stabilityWeight <= 0) {
continue;
}
const blend = DARK_DETAIL_DENOISE_MAX_BLEND * darkWeight * stabilityWeight;
pixels[offset] = Math.round(red * (1 - blend) + previousRed * blend);
pixels[offset + 1] = Math.round(
green * (1 - blend) + previousGreen * blend,
);
pixels[offset + 2] = Math.round(blue * (1 - blend) + previousBlue * blend);
nextFrame[offset] = pixels[offset] ?? red;
nextFrame[offset + 1] = pixels[offset + 1] ?? green;
nextFrame[offset + 2] = pixels[offset + 2] ?? blue;
}
return nextFrame;
}
function stabilizeMaskConfidence(current: number, previous: number): number {
const delta = Math.abs(current - previous);
const blend =
MASK_TEMPORAL_MAX_BLEND *
Math.max(0, 1 - delta / MASK_TEMPORAL_MOTION_THRESHOLD);
return previous * blend + current * (1 - blend);
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function drawMirroredCenterCrop(
context: CanvasRenderingContext2D,
video: HTMLVideoElement,
): void {
const sourceSize = Math.min(video.videoWidth, video.videoHeight) || 1;
const sourceX = (video.videoWidth - sourceSize) / 2;
const sourceY = (video.videoHeight - sourceSize) / 2;
context.save();
context.clearRect(
0,
0,
ANIMATED_AVATAR_CAPTURE_SIZE,
ANIMATED_AVATAR_CAPTURE_SIZE,
);
// Mirror horizontally so the capture matches the selfie-style live preview.
context.translate(ANIMATED_AVATAR_CAPTURE_SIZE, 0);
context.scale(-1, 1);
context.drawImage(
video,
sourceX,
sourceY,
sourceSize,
sourceSize,
0,
0,
ANIMATED_AVATAR_CAPTURE_SIZE,
ANIMATED_AVATAR_CAPTURE_SIZE,
);
context.restore();
}
/** Convert recorded frames into bitmaps for fast repeated composition. */
export function createAvatarFrameBitmaps(
frames: ImageData[],
): Promise<ImageBitmap[]> {
return Promise.all(frames.map((frame) => createImageBitmap(frame)));
}
/**
* Draw one composed avatar frame: the cut-out person, placed per the
* composition's offset/scale, popping out of the backdrop circle. With no
* backdrop color the placed cut-out renders on its own. The context must
* belong to a 256x256 canvas.
*/
export function composeAvatarFrame(
context: CanvasRenderingContext2D,
person: CanvasImageSource,
composition: AvatarComposition,
): void {
const size = ANIMATED_AVATAR_SIZE;
context.clearRect(0, 0, size, size);
// Person placement: centered horizontally and bottom-anchored at the
// default, then shifted/scaled by the user's framing choices.
const personSize = size * composition.scale;
const personX = (size - personSize) / 2 + composition.offsetX;
const personY = size - personSize + composition.offsetY;
if (!composition.backdropColor || composition.shapeScale <= 0) {
drawPersonWithOutline(
context,
person,
composition,
personX,
personY,
personSize,
);
return;
}
// The circle scales around its own center, then shifts by the user's
// offsets.
const geometry = CIRCLE_GEOMETRY;
const shapeScale = composition.shapeScale;
const circleX = geometry.centerX + composition.shapeOffsetX;
const circleY = geometry.centerY + composition.shapeOffsetY;
const circleRadius = geometry.radius * shapeScale;
context.fillStyle = composition.backdropColor;
context.beginPath();
context.arc(circleX, circleY, circleRadius, 0, Math.PI * 2);
context.fill();
// Clip to the circle plus the column above it, so the person rises out of
// the circle's top without spilling past its sides.
context.save();
context.beginPath();
context.arc(circleX, circleY, circleRadius, 0, Math.PI * 2);
context.rect(
circleX - circleRadius,
0,
circleRadius * 2,
Math.max(0, circleY),
);
context.clip();
drawPersonWithOutline(
context,
person,
composition,
personX,
personY,
personSize,
);
context.restore();
}
function drawPersonWithOutline(
context: CanvasRenderingContext2D,
person: CanvasImageSource,
composition: AvatarComposition,
personX: number,
personY: number,
personSize: number,
): void {
if (composition.personOutline) {
drawPersonOutline(
context,
person,
composition.backdropColor,
personX,
personY,
personSize,
);
}
context.drawImage(person, personX, personY, personSize, personSize);
}
function drawPersonOutline(
context: CanvasRenderingContext2D,
person: CanvasImageSource,
backdropColor: string | null,
personX: number,
personY: number,
personSize: number,
): void {
const outline = document.createElement("canvas");
outline.width = ANIMATED_AVATAR_SIZE;
outline.height = ANIMATED_AVATAR_SIZE;
const outlineContext = outline.getContext("2d");
if (!outlineContext) {
return;
}
outlineContext.drawImage(person, personX, personY, personSize, personSize);
outlineContext.globalCompositeOperation = "source-in";
outlineContext.fillStyle = personOutlineColor(backdropColor);
outlineContext.fillRect(0, 0, ANIMATED_AVATAR_SIZE, ANIMATED_AVATAR_SIZE);
context.save();
context.globalAlpha = PERSON_OUTLINE_ALPHA;
context.imageSmoothingEnabled = false;
for (const [offsetX, offsetY] of PERSON_OUTLINE_OFFSETS) {
context.drawImage(outline, offsetX, offsetY);
}
context.restore();
}
function personOutlineColor(backdropColor: string | null): string {
const color = backdropColor ? parseHexColor(backdropColor) : null;
if (!color) {
return "#ffffff";
}
const luma =
(0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255;
return luma > 0.74 ? "#111111" : "#ffffff";
}
function parseHexColor(
value: string,
): { red: number; green: number; blue: number } | null {
const match = /^#?([0-9a-f]{6})$/i.exec(value);
if (!match) {
return null;
}
const hex = match[1];
if (!hex) {
return null;
}
return {
blue: Number.parseInt(hex.slice(4, 6), 16),
green: Number.parseInt(hex.slice(2, 4), 16),
red: Number.parseInt(hex.slice(0, 2), 16),
};
}
/** Compose every recorded frame with the chosen backdrop and framing. */
export function composeAvatarFrames(
bitmaps: ImageBitmap[],
composition: AvatarComposition,
): ImageData[] {
const canvas = document.createElement("canvas");
canvas.width = ANIMATED_AVATAR_SIZE;
canvas.height = ANIMATED_AVATAR_SIZE;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
throw new Error("Could not create a drawing context.");
}
return bitmaps.map((bitmap) => {
composeAvatarFrame(context, bitmap, composition);
return context.getImageData(
0,
0,
ANIMATED_AVATAR_SIZE,
ANIMATED_AVATAR_SIZE,
);
});
}
/** Mirror the sequence so the animation plays forward, then backward. */
export function buildPingPongAvatarFrames<T>(frames: T[]): T[] {
if (frames.length <= 2) {
return frames;
}
return frames.concat(frames.slice(1, -1).reverse());
}
/** Encode composed frames as an infinitely looping ping-pong animated PNG. */
export function encodeAvatarAnimation(frames: ImageData[]): Uint8Array {
const animationFrames = buildPingPongAvatarFrames(frames);
const buffers = animationFrames.map(
(frame) =>
frame.data.buffer.slice(
frame.data.byteOffset,
frame.data.byteOffset + frame.data.byteLength,
) as ArrayBuffer,
);
const delays = animationFrames.map(() => ANIMATED_AVATAR_FRAME_DELAY_MS);
const encoded = UPNG.encode(
buffers,
ANIMATED_AVATAR_SIZE,
ANIMATED_AVATAR_SIZE,
APNG_COLOR_COUNT,
delays,
);
return new Uint8Array(encoded);
}
function sampleMaskConfidence(
values: Float32Array,
width: number,
height: number,
x: number,
y: number,
): number {
const sampleX = Math.min(Math.max(x, 0), Math.max(0, width - 1));
const sampleY = Math.min(Math.max(y, 0), Math.max(0, height - 1));
const x0 = Math.floor(sampleX);
const y0 = Math.floor(sampleY);
const x1 = Math.min(width - 1, x0 + 1);
const y1 = Math.min(height - 1, y0 + 1);
const tx = sampleX - x0;
const ty = sampleY - y0;
const topLeft = values[y0 * width + x0] ?? 0;
const topRight = values[y0 * width + x1] ?? topLeft;
const bottomLeft = values[y1 * width + x0] ?? topLeft;
const bottomRight = values[y1 * width + x1] ?? bottomLeft;
const top = topLeft + (topRight - topLeft) * tx;
const bottom = bottomLeft + (bottomRight - bottomLeft) * tx;
return top + (bottom - top) * ty;
}
export async function renderAvatarPosterPng(
frame: ImageData,
): Promise<Uint8Array> {
const blob = await new Promise<Blob | null>((resolve) => {
frameToCanvas(frame).toBlob(resolve, "image/png");
});
if (!blob) {
throw new Error("Could not render the poster frame.");
}
return new Uint8Array(await blob.arrayBuffer());
}
function frameToCanvas(frame: ImageData): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = frame.width;
canvas.height = frame.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not create a drawing context.");
}
context.putImageData(frame, 0, 0);
return canvas;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,90 @@
import {
AVATAR_COLOR_SWATCHES,
CUSTOM_AVATAR_COLOR_SWATCH,
contrastColorForBackground,
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
type AnimatedAvatarBackdropPanelProps = {
backdropColor: string | null;
disabled?: boolean;
isCustomBackdropSelected: boolean;
isSaving: boolean;
onOpenCustomPicker: () => void;
onSelectColor: (color: string) => void;
testIdPrefix: string;
};
export function AnimatedAvatarBackdropPanel({
backdropColor,
disabled = false,
isCustomBackdropSelected,
isSaving,
onOpenCustomPicker,
onSelectColor,
testIdPrefix,
}: AnimatedAvatarBackdropPanelProps) {
return (
<div
className="grid gap-4 rounded-xl bg-muted p-4 transition-colors duration-[250ms] ease-out"
data-testid={`${testIdPrefix}-animated-color-panel`}
>
<div
className="grid grid-cols-8 justify-items-center gap-3"
data-testid={`${testIdPrefix}-animated-backdrop-grid`}
>
{AVATAR_COLOR_SWATCHES.map((swatch) => {
const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH;
const isSelected = isCustomSwatch
? isCustomBackdropSelected
: backdropColor !== null &&
swatch.toUpperCase() === backdropColor.toUpperCase();
return (
<button
aria-label={
isCustomSwatch
? "Choose custom backdrop color"
: `Use ${swatch} backdrop`
}
aria-pressed={isSelected}
className="relative h-10 w-10 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={
isCustomSwatch
? `${testIdPrefix}-animated-backdrop-custom`
: undefined
}
disabled={disabled || isSaving}
key={swatch}
onClick={() => {
if (isCustomSwatch) {
onOpenCustomPicker();
return;
}
onSelectColor(swatch);
}}
style={{
background: isCustomSwatch
? isSelected && backdropColor
? backdropColor
: "conic-gradient(from 0deg, #ff4d4d, #ffe75c, #73ef75, #63c6f2, #b141ff, #ff4d4d)"
: swatch,
}}
type="button"
>
{isSelected ? (
<span
className="absolute inset-1 rounded-full border-[3px]"
style={{
borderColor: contrastColorForBackground(
isCustomSwatch && backdropColor ? backdropColor : swatch,
),
}}
/>
) : null}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { Smartphone, Webcam } from "lucide-react";
import type { CameraSource } from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import { cn } from "@/shared/lib/cn";
type AnimatedAvatarCameraPickerProps = {
activeCameraSource: CameraSource | null;
computerDisabled: boolean;
disabled?: boolean;
iphoneDisabled: boolean;
onSelectSource: (source: CameraSource) => void;
testIdPrefix: string;
};
export function AnimatedAvatarCameraPicker({
activeCameraSource,
computerDisabled,
disabled = false,
iphoneDisabled,
onSelectSource,
testIdPrefix,
}: AnimatedAvatarCameraPickerProps) {
return (
<div className="grid grid-cols-2 gap-3">
{[
{
disabled: iphoneDisabled,
icon: Smartphone,
label: "Use iPhone",
source: "iphone" as const,
},
{
disabled: computerDisabled,
icon: Webcam,
label: "Use this computer",
source: "computer" as const,
},
].map((option) => {
const Icon = option.icon;
const isSelected = activeCameraSource === option.source;
const isDisabled = disabled || option.disabled;
return (
<button
aria-pressed={isSelected}
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,color,opacity] duration-[250ms] ease-out hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
isDisabled && "cursor-not-allowed opacity-45 hover:bg-muted",
)}
data-testid={`${testIdPrefix}-animated-camera-${option.source}`}
disabled={isDisabled}
key={option.source}
onClick={() => onSelectSource(option.source)}
type="button"
>
<Icon
aria-hidden="true"
className={cn(
"h-5 w-5 text-foreground transition-colors duration-[250ms] ease-out",
isSelected && "text-primary",
)}
/>
<span
className={cn(
"text-sm font-medium text-foreground transition-colors duration-[250ms] ease-out",
isSelected && "text-primary",
)}
>
{option.label}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import {
ANIMATED_AVATAR_DURATION_MS,
ANIMATED_AVATAR_SIZE,
type AvatarCameraDevice,
type AvatarComposition,
composeAvatarFrame,
DEFAULT_PERSON_SCALE,
} from "@/features/profile/lib/animatedAvatarCapture";
import { AVATAR_COLORS } from "@/features/profile/ui/ProfileAvatarEditor.utils";
export type CapturePhase =
| "idle"
| "starting"
| "live"
| "recording"
| "processing"
| "review";
export type CameraSource = "computer" | "iphone";
const FILMSTRIP_FRAME_SIZE = 48;
const SLIDER_TICK_STEP = 10;
const PHONE_DEFAULT_PERSON_SCALE =
(Math.round(DEFAULT_PERSON_SCALE * 100) - SLIDER_TICK_STEP) / 100;
export const RECORD_SECONDS = ANIMATED_AVATAR_DURATION_MS / 1000;
export const PERSON_SIZE_TIP =
"Scale just past the color circle for a pop-out look.";
export const ENTRANCE_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
/** Keep things draggable but never fully lost off-frame. */
const MAX_OFFSET = 192;
export function clampOffset(value: number): number {
return Math.min(MAX_OFFSET, Math.max(-MAX_OFFSET, value));
}
export function randomBackdropColor(): string {
return (
AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ??
AVATAR_COLORS[0] ??
"#FFFFFF"
);
}
function isIPhoneCamera(device: AvatarCameraDevice): boolean {
return /\b(continuity|ios|iphone|mobile|phone)\b/i.test(device.label);
}
function isFrontCamera(device: AvatarCameraDevice): boolean {
return /\b(front|facetime|user)\b/i.test(device.label);
}
export function cameraLabelsAreAvailable(
devices: AvatarCameraDevice[],
): boolean {
return devices.some((device) => device.label.trim().length > 0);
}
export function preferredCameraDevice(
devices: AvatarCameraDevice[],
source: CameraSource,
): AvatarCameraDevice | null {
const candidates =
source === "iphone"
? devices.filter(isIPhoneCamera)
: devices.filter((device) => !isIPhoneCamera(device));
return (
candidates.find(isFrontCamera) ??
candidates[0] ??
(source === "computer" ? devices[0] : null) ??
null
);
}
export function defaultPersonScaleForSource(
source: CameraSource | null,
): number {
return source === "iphone"
? PHONE_DEFAULT_PERSON_SCALE
: DEFAULT_PERSON_SCALE;
}
export function clampFrameIndex(value: number, frameCount: number): number {
return Math.min(Math.max(0, frameCount - 1), Math.max(0, value));
}
export function buildFilmstripFrames(
bitmaps: ImageBitmap[],
composition: AvatarComposition,
): string[] {
const composed = document.createElement("canvas");
composed.width = ANIMATED_AVATAR_SIZE;
composed.height = ANIMATED_AVATAR_SIZE;
const composedContext = composed.getContext("2d");
const thumbnail = document.createElement("canvas");
thumbnail.width = FILMSTRIP_FRAME_SIZE;
thumbnail.height = FILMSTRIP_FRAME_SIZE;
const thumbnailContext = thumbnail.getContext("2d");
if (!composedContext || !thumbnailContext) {
return [];
}
const urls: string[] = [];
for (const bitmap of bitmaps) {
composeAvatarFrame(composedContext, bitmap, composition);
thumbnailContext.clearRect(
0,
0,
FILMSTRIP_FRAME_SIZE,
FILMSTRIP_FRAME_SIZE,
);
thumbnailContext.drawImage(
composed,
0,
0,
FILMSTRIP_FRAME_SIZE,
FILMSTRIP_FRAME_SIZE,
);
urls.push(thumbnail.toDataURL("image/png"));
}
return urls;
}

View File

@@ -0,0 +1,998 @@
import { Camera, Video } from "lucide-react";
import { motion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import {
type AnimatedAvatarRecording,
ANIMATED_AVATAR_FRAME_DELAY_MS,
ANIMATED_AVATAR_SIZE,
type AvatarCameraDevice,
type AvatarComposition,
buildPingPongAvatarFrames,
composeAvatarFrame,
composeAvatarFrames,
createAvatarFrameBitmaps,
DEFAULT_PERSON_OFFSET_X,
DEFAULT_PERSON_OFFSET_Y,
DEFAULT_PERSON_OUTLINE,
DEFAULT_PERSON_SCALE,
DEFAULT_SHAPE_OFFSET_X,
DEFAULT_SHAPE_OFFSET_Y,
DEFAULT_SHAPE_SCALE,
encodeAvatarAnimation,
MAX_PERSON_SCALE,
MAX_SHAPE_SCALE,
MIN_PERSON_SCALE,
MIN_SHAPE_SCALE,
listAvatarCameras,
openAvatarCamera,
preloadAvatarSegmenter,
recordAnimatedAvatarFrames,
renderAvatarPosterPng,
stopAvatarCamera,
} from "@/features/profile/lib/animatedAvatarCapture";
import { AnimatedAvatarBackdropPanel } from "@/features/profile/ui/AnimatedAvatarBackdropPanel";
import { AnimatedAvatarCameraPicker } from "@/features/profile/ui/AnimatedAvatarCameraPicker";
import {
AvatarFilmstripPicker,
AvatarFramingSlider,
AvatarOutlineToggle,
} from "@/features/profile/ui/AnimatedAvatarControls";
import {
buildFilmstripFrames,
cameraLabelsAreAvailable,
type CapturePhase,
type CameraSource,
clampFrameIndex,
clampOffset,
defaultPersonScaleForSource,
ENTRANCE_TRANSITION,
PERSON_SIZE_TIP,
preferredCameraDevice,
randomBackdropColor,
RECORD_SECONDS,
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import {
AnimatedAvatarReviewNav,
type ReviewSection,
} from "@/features/profile/ui/AnimatedAvatarReviewNav";
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
import {
AVATAR_COLORS,
hexToHsv,
hsvToHex,
normalizeHue,
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
import { uploadMediaBytes } from "@/shared/api/tauri";
import { buildAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
type AnimatedAvatarCaptureProps = {
disabled?: boolean;
testIdPrefix: string;
/** Receives the composed animated avatar URL after upload. */
onApply: (avatarUrl: string) => void;
/**
* Host element inside the page's main avatar preview. When provided, the
* camera feed, recording ring, and composed preview render there (via a
* portal) instead of inside the tab — so edits show exactly where the
* avatar will live. Pair with `onPreviewActiveChange` so the host can
* hide its regular preview while the capture content is showing.
*/
previewContainer?: HTMLElement | null;
onPreviewActiveChange?: (active: boolean) => void;
onPreviewCaptionChange?: (caption: string | null) => void;
onApplyPendingChange?: (isPending: boolean) => void;
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
/**
* Receives the current apply function (or null when there is nothing to
* apply) so the host's Done button can upload-and-apply in one step.
*/
registerApply?: (apply: (() => Promise<boolean>) | null) => void;
/** Show the in-tab "Use as avatar" button (hosts without a Done button). */
showApplyButton?: boolean;
};
export function AnimatedAvatarCapture({
disabled = false,
testIdPrefix,
onApply,
onApplyPendingChange,
onCustomColorPickerOpenChange,
previewContainer = null,
onPreviewActiveChange,
onPreviewCaptionChange,
registerApply,
showApplyButton = true,
}: AnimatedAvatarCaptureProps) {
const [phase, setPhase] = React.useState<CapturePhase>("idle");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [cameraDevices, setCameraDevices] = React.useState<
AvatarCameraDevice[]
>([]);
const [selectedCameraSource, setSelectedCameraSource] =
React.useState<CameraSource | null>(null);
const [selectedCameraId, setSelectedCameraId] = React.useState<string | null>(
null,
);
const [recordProgress, setRecordProgress] = React.useState(0);
const [recording, setRecording] =
React.useState<AnimatedAvatarRecording | null>(null);
const [bitmaps, setBitmaps] = React.useState<ImageBitmap[]>([]);
const [filmstripFrames, setFilmstripFrames] = React.useState<string[]>([]);
const [posterIndex, setPosterIndex] = React.useState(0);
const [backdropColor, setBackdropColor] = React.useState<string | null>(
randomBackdropColor,
);
const [personOffset, setPersonOffset] = React.useState({
x: DEFAULT_PERSON_OFFSET_X,
y: DEFAULT_PERSON_OFFSET_Y,
});
const [personScale, setPersonScale] = React.useState(DEFAULT_PERSON_SCALE);
const [personOutline, setPersonOutline] = React.useState(
DEFAULT_PERSON_OUTLINE,
);
const [shapeOffset, setShapeOffset] = React.useState({
x: DEFAULT_SHAPE_OFFSET_X,
y: DEFAULT_SHAPE_OFFSET_Y,
});
const [shapeScale, setShapeScale] = React.useState(DEFAULT_SHAPE_SCALE);
const [activeSection, setActiveSection] =
React.useState<ReviewSection>("person");
const [isPreviewPlaying, setIsPreviewPlaying] = React.useState(false);
const [isDraggingPerson, setIsDraggingPerson] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
// Drag, arrow keys, and the size slider adjust whichever framing section
// is open; the color/poster sections leave the person active.
const editTarget = activeSection === "shape" ? "shape" : "person";
const activeOffset = editTarget === "shape" ? shapeOffset : personOffset;
const setActiveOffset =
editTarget === "shape" ? setShapeOffset : setPersonOffset;
const activeScale = editTarget === "shape" ? shapeScale : personScale;
const setActiveScale =
editTarget === "shape" ? setShapeScale : setPersonScale;
const activeScaleMin =
editTarget === "shape" ? MIN_SHAPE_SCALE : MIN_PERSON_SCALE;
const activeScaleMax =
editTarget === "shape" ? MAX_SHAPE_SCALE : MAX_PERSON_SCALE;
const activeScaleReset =
editTarget === "shape" ? DEFAULT_SHAPE_SCALE : DEFAULT_PERSON_SCALE;
const resetActiveFraming = React.useCallback(() => {
if (activeSection === "shape") {
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
setShapeScale(DEFAULT_SHAPE_SCALE);
return;
}
setPersonOffset({
x: DEFAULT_PERSON_OFFSET_X,
y: DEFAULT_PERSON_OFFSET_Y,
});
setPersonScale(DEFAULT_PERSON_SCALE);
}, [activeSection]);
const resetAllFraming = React.useCallback(() => {
setPersonOffset({
x: DEFAULT_PERSON_OFFSET_X,
y: DEFAULT_PERSON_OFFSET_Y,
});
setPersonScale(DEFAULT_PERSON_SCALE);
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
setShapeScale(DEFAULT_SHAPE_SCALE);
}, []);
// Custom backdrop color picker (shared HSV panel).
const [isCustomPickerOpen, setIsCustomPickerOpen] = React.useState(false);
const [customHue, setCustomHue] = React.useState(210);
const [customSaturation, setCustomSaturation] = React.useState(80);
const [customValue, setCustomValue] = React.useState(90);
const customColorDraft = React.useMemo(
() => hsvToHex(customHue, customSaturation, customValue),
[customHue, customSaturation, customValue],
);
const isCustomPickerVisible = isCustomPickerOpen && phase === "review";
const visibleBackdropColor = isCustomPickerVisible
? customColorDraft
: backdropColor;
const composition = React.useMemo<AvatarComposition>(
() => ({
backdropColor: visibleBackdropColor,
offsetX: personOffset.x,
offsetY: personOffset.y,
personOutline,
scale: personScale,
shapeOffsetX: shapeOffset.x,
shapeOffsetY: shapeOffset.y,
shapeScale,
}),
[
visibleBackdropColor,
personOffset,
personOutline,
personScale,
shapeOffset,
shapeScale,
],
);
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const previewCanvasRef = React.useRef<HTMLCanvasElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const activeStreamSourceRef = React.useRef<CameraSource | null>(null);
const recordAbortRef = React.useRef<AbortController | null>(null);
const dragStateRef = React.useRef<{
pointerId: number;
startClientX: number;
startClientY: number;
baseOffsetX: number;
baseOffsetY: number;
} | null>(null);
const releaseCamera = React.useCallback(() => {
if (streamRef.current) {
stopAvatarCamera(streamRef.current);
streamRef.current = null;
}
activeStreamSourceRef.current = null;
if (videoRef.current) {
videoRef.current.srcObject = null;
}
}, []);
const releaseBitmaps = React.useCallback(() => {
setBitmaps((previous) => {
for (const bitmap of previous) {
bitmap.close();
}
return [];
});
}, []);
const refreshCameraDevices = React.useCallback(async () => {
try {
const devices = await listAvatarCameras();
setCameraDevices(devices);
setSelectedCameraId((current) => {
if (current && devices.some((device) => device.deviceId === current)) {
return current;
}
return selectedCameraSource
? (preferredCameraDevice(devices, selectedCameraSource)?.deviceId ??
null)
: null;
});
} catch {
setCameraDevices([]);
setSelectedCameraId(null);
}
}, [selectedCameraSource]);
React.useEffect(() => {
return () => {
recordAbortRef.current?.abort();
releaseCamera();
};
}, [releaseCamera]);
// Close bitmaps on unmount only — phase changes manage them explicitly.
React.useEffect(() => releaseBitmaps, [releaseBitmaps]);
React.useEffect(() => {
void refreshCameraDevices();
const mediaDevices = navigator.mediaDevices;
if (!mediaDevices?.addEventListener) {
return;
}
const handleDeviceChange = () => {
void refreshCameraDevices();
};
mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}, [refreshCameraDevices]);
// Review preview mirrors the final avatar: the selected poster frame is
// shown as a still, and hovering plays the animation.
React.useEffect(() => {
if (phase !== "review" || bitmaps.length === 0) {
return;
}
const canvas = previewCanvasRef.current;
const context = canvas?.getContext("2d");
if (!context) {
return;
}
if (!isPreviewPlaying) {
const poster = bitmaps[Math.min(posterIndex, bitmaps.length - 1)];
if (poster) {
composeAvatarFrame(context, poster, composition);
}
return;
}
const previewFrames = buildPingPongAvatarFrames(bitmaps);
let frameIndex = 0;
let lastDrawn = 0;
let animationFrame = 0;
const tick = (now: number) => {
if (now - lastDrawn >= ANIMATED_AVATAR_FRAME_DELAY_MS) {
const bitmap = previewFrames[frameIndex];
if (bitmap) {
composeAvatarFrame(context, bitmap, composition);
}
frameIndex = (frameIndex + 1) % previewFrames.length;
lastDrawn = now;
}
animationFrame = requestAnimationFrame(tick);
};
animationFrame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animationFrame);
}, [phase, bitmaps, composition, isPreviewPlaying, posterIndex]);
// Filmstrip frames track the composition. Debounced so dragging or
// scrubbing the size slider doesn't re-render the strip per tick.
React.useEffect(() => {
if (phase !== "review" || bitmaps.length === 0) {
setFilmstripFrames([]);
return;
}
const handle = setTimeout(() => {
setFilmstripFrames(buildFilmstripFrames(bitmaps, composition));
}, 200);
return () => clearTimeout(handle);
}, [phase, bitmaps, composition]);
const startCamera = React.useCallback(
async (cameraId = selectedCameraId, source = selectedCameraSource) => {
setErrorMessage(null);
setPhase("starting");
releaseCamera();
// Warm the segmentation model while the user lines up their shot.
preloadAvatarSegmenter();
try {
let stream = await openAvatarCamera(cameraId || null);
let resolvedCameraId = cameraId ?? null;
if (source === "iphone" && !cameraId) {
const devicesAfterPermission = await listAvatarCameras().catch(
() => [],
);
setCameraDevices(devicesAfterPermission);
const preferredIphone = preferredCameraDevice(
devicesAfterPermission,
"iphone",
);
if (!preferredIphone) {
stopAvatarCamera(stream);
setSelectedCameraSource(null);
setSelectedCameraId(null);
setErrorMessage(
"Could not find an iPhone camera. Make sure Continuity Camera is available, then try again.",
);
setPhase("idle");
return;
}
resolvedCameraId = preferredIphone.deviceId;
const activeDeviceId =
stream.getVideoTracks()[0]?.getSettings().deviceId ?? null;
if (activeDeviceId !== preferredIphone.deviceId) {
stopAvatarCamera(stream);
stream = await openAvatarCamera(preferredIphone.deviceId);
}
}
streamRef.current = stream;
activeStreamSourceRef.current = source;
setSelectedCameraId(resolvedCameraId);
const video = videoRef.current;
if (!video) {
stopAvatarCamera(stream);
activeStreamSourceRef.current = null;
setPhase("idle");
return;
}
video.srcObject = stream;
await video.play();
setPhase("live");
void refreshCameraDevices();
} catch {
releaseCamera();
setErrorMessage(
"Could not access the camera. Check Buzz's camera permission and try again.",
);
setPhase("idle");
}
},
[
refreshCameraDevices,
releaseCamera,
selectedCameraId,
selectedCameraSource,
],
);
const selectCameraSource = React.useCallback(
(source: CameraSource) => {
const cameraId =
preferredCameraDevice(cameraDevices, source)?.deviceId ?? null;
const hasDeviceLabels = cameraLabelsAreAvailable(cameraDevices);
if (source === "iphone" && !cameraId && hasDeviceLabels) {
return;
}
setSelectedCameraSource(source);
setSelectedCameraId(cameraId);
if (phase === "idle" || phase === "live") {
void startCamera(cameraId, source);
}
},
[cameraDevices, phase, startCamera],
);
const record = React.useCallback(async () => {
const video = videoRef.current;
if (!video || phase !== "live") {
return;
}
setErrorMessage(null);
setRecordProgress(0);
setPhase("recording");
const abort = new AbortController();
recordAbortRef.current = abort;
try {
const captured = await recordAnimatedAvatarFrames(video, {
onProgress: setRecordProgress,
signal: abort.signal,
});
const recordingSource =
activeStreamSourceRef.current ?? selectedCameraSource;
setPhase("processing");
releaseCamera();
const nextBitmaps = await createAvatarFrameBitmaps(captured.frames);
releaseBitmaps();
setBitmaps(nextBitmaps);
setPosterIndex(0);
setRecording(captured);
setPersonOffset({
x: DEFAULT_PERSON_OFFSET_X,
y: DEFAULT_PERSON_OFFSET_Y,
});
setPersonScale(defaultPersonScaleForSource(recordingSource));
setPhase("review");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
setErrorMessage(
error instanceof Error ? error.message : "Recording failed. Try again.",
);
releaseCamera();
setPhase("idle");
} finally {
recordAbortRef.current = null;
}
}, [phase, releaseBitmaps, releaseCamera, selectedCameraSource]);
const retake = React.useCallback(() => {
setRecording(null);
setFilmstripFrames([]);
setIsCustomPickerOpen(false);
setIsPreviewPlaying(false);
setActiveSection("person");
resetAllFraming();
releaseBitmaps();
void startCamera(selectedCameraId, selectedCameraSource);
}, [
releaseBitmaps,
resetAllFraming,
selectedCameraId,
selectedCameraSource,
startCamera,
]);
const apply = React.useCallback(async (): Promise<boolean> => {
if (bitmaps.length === 0 || isSaving) {
return false;
}
setIsSaving(true);
setErrorMessage(null);
try {
const composed = composeAvatarFrames(bitmaps, composition);
const posterFrame = composed[posterIndex] ?? composed[0];
if (!posterFrame) {
throw new Error("No frames were recorded.");
}
const animationBytes = encodeAvatarAnimation(composed);
const posterBytes = await renderAvatarPosterPng(posterFrame);
const [animationUpload, posterUpload] = await Promise.all([
uploadMediaBytes([...animationBytes], "animated-avatar.png"),
uploadMediaBytes([...posterBytes], "animated-avatar-poster.png"),
]);
if (
!animationUpload.type.startsWith("image/") ||
!posterUpload.type.startsWith("image/")
) {
setErrorMessage("The relay rejected the recording. Try again.");
return false;
}
onApply(buildAnimatedAvatarUrl(posterUpload.url, animationUpload.url));
return true;
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Could not upload the animated avatar.",
);
return false;
} finally {
setIsSaving(false);
}
}, [bitmaps, composition, isSaving, onApply, posterIndex]);
// Hand the host's Done button the current apply function whenever a
// recording is ready to upload.
React.useEffect(() => {
if (!registerApply) {
return;
}
registerApply(phase === "review" && bitmaps.length > 0 ? apply : null);
return () => registerApply(null);
}, [apply, bitmaps.length, phase, registerApply]);
const openCustomPicker = React.useCallback(() => {
const baseColor = hexToHsv(backdropColor ?? customColorDraft);
setCustomHue(normalizeHue(baseColor.hue));
setCustomSaturation(baseColor.saturation);
setCustomValue(baseColor.value);
setIsCustomPickerOpen(true);
}, [backdropColor, customColorDraft]);
const isCameraVisible = phase === "live" || phase === "recording";
const isCameraStageVisible =
phase === "starting" || phase === "live" || phase === "recording";
const computerCamera = React.useMemo(
() => preferredCameraDevice(cameraDevices, "computer"),
[cameraDevices],
);
const iphoneCamera = React.useMemo(
() => preferredCameraDevice(cameraDevices, "iphone"),
[cameraDevices],
);
const hasCameraLabels = cameraLabelsAreAvailable(cameraDevices);
const activeCameraSource = selectedCameraSource;
const isCustomBackdropSelected =
backdropColor !== null &&
!AVATAR_COLORS.some(
(color) => color.toUpperCase() === backdropColor.toUpperCase(),
);
const isFramingSection =
activeSection === "person" || activeSection === "shape";
// With a host preview container, the camera feed / recording ring /
// composed preview render inside the page's main avatar preview (via a
// portal) so edits show exactly where the avatar will live.
const usePortal = previewContainer !== null;
const reviewWarning =
phase === "review" && recording && !recording.backgroundRemoved
? "Background removal model couldn't be loaded, so the background was kept. Retake while online to remove it."
: null;
const captureHelpText =
phase === "idle"
? null
: phase === "starting"
? null
: phase === "live"
? "Line up your shot."
: phase === "recording"
? "Recording... hold still-ish."
: phase === "processing"
? "Cutting you out of the background..."
: null;
const previewCaption =
usePortal && (phase === "live" || phase === "recording")
? captureHelpText
: usePortal && phase === "review"
? "Hover to play"
: null;
const inlineCaptureHelpText =
usePortal && (phase === "live" || phase === "recording")
? null
: captureHelpText;
const showCaptureCard = !usePortal && phase !== "review";
const showCameraPicker = ["idle", "starting", "live"].includes(phase);
React.useEffect(() => {
onPreviewActiveChange?.(usePortal);
return () => onPreviewActiveChange?.(false);
}, [onPreviewActiveChange, usePortal]);
React.useLayoutEffect(() => {
onApplyPendingChange?.(isSaving);
return () => onApplyPendingChange?.(false);
}, [isSaving, onApplyPendingChange]);
React.useEffect(() => {
onPreviewCaptionChange?.(previewCaption);
return () => onPreviewCaptionChange?.(null);
}, [onPreviewCaptionChange, previewCaption]);
React.useLayoutEffect(() => {
onCustomColorPickerOpenChange?.(isCustomPickerVisible);
return () => {
onCustomColorPickerOpenChange?.(false);
};
}, [isCustomPickerVisible, onCustomColorPickerOpenChange]);
const stageContent = (
<div className={cn("relative", usePortal ? "h-full w-full" : "h-44 w-44")}>
{/* Live camera preview — kept mounted so the stream can attach. */}
<div
className={cn(
"h-full w-full overflow-hidden rounded-full bg-background/60 shadow-inner",
!isCameraStageVisible && "hidden",
)}
>
<video
autoPlay
className={cn(
"h-full w-full -scale-x-100 object-cover",
!isCameraVisible && "opacity-0",
)}
data-testid={`${testIdPrefix}-animated-preview`}
muted
playsInline
ref={videoRef}
/>
</div>
{/* Recording timer: a stroke that sweeps around the capture circle. */}
{phase === "recording" ? (
<svg
aria-hidden="true"
className="pointer-events-none absolute -inset-1 h-[calc(100%+8px)] w-[calc(100%+8px)] -rotate-90"
data-testid={`${testIdPrefix}-animated-timer-ring`}
viewBox="0 0 100 100"
>
<circle
className="stroke-foreground"
cx="50"
cy="50"
fill="none"
pathLength={100}
r="48"
strokeDasharray={100}
strokeDashoffset={100 - recordProgress * 100}
strokeLinecap="round"
strokeWidth={2.5}
/>
</svg>
) : null}
{/* Review preview: circle-cropped like the real avatar, shows the
selected poster frame as a still, and plays the composed
animation on hover — exactly how the avatar behaves in the app.
Dragging repositions the active framing target. */}
<div
aria-label="Avatar preview — drag or use arrow keys to position"
className={cn(
// Transparent like the real avatar container — only a faint
// ring marks the circular crop boundary. pointer-events-auto
// re-enables interaction inside the host's inert overlay when
// portaled.
"pointer-events-auto h-full w-full touch-none overflow-hidden rounded-full ring-1 ring-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isDraggingPerson ? "cursor-grabbing" : "cursor-grab",
phase !== "review" && "hidden",
)}
onKeyDown={(event) => {
if (phase !== "review" || disabled || isSaving) {
return;
}
const step = event.shiftKey ? 16 : 4;
const moves: Record<string, [number, number]> = {
ArrowDown: [0, step],
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
};
const move = moves[event.key];
if (!move) {
return;
}
event.preventDefault();
setActiveOffset((previous) => ({
x: clampOffset(previous.x + move[0]),
y: clampOffset(previous.y + move[1]),
}));
}}
onMouseEnter={() => setIsPreviewPlaying(true)}
onMouseLeave={() => setIsPreviewPlaying(false)}
onPointerCancel={() => {
dragStateRef.current = null;
setIsDraggingPerson(false);
}}
onPointerDown={(event) => {
if (phase !== "review" || disabled || isSaving) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
baseOffsetX: activeOffset.x,
baseOffsetY: activeOffset.y,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
};
setIsDraggingPerson(true);
}}
onPointerMove={(event) => {
const drag = dragStateRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const toFrame = ANIMATED_AVATAR_SIZE / Math.max(rect.width, 1);
setActiveOffset({
x: clampOffset(
drag.baseOffsetX + (event.clientX - drag.startClientX) * toFrame,
),
y: clampOffset(
drag.baseOffsetY + (event.clientY - drag.startClientY) * toFrame,
),
});
}}
onPointerUp={() => {
dragStateRef.current = null;
setIsDraggingPerson(false);
}}
role="application"
tabIndex={phase === "review" ? 0 : -1}
>
<canvas
className="pointer-events-none h-full w-full"
data-playing={isPreviewPlaying ? "true" : undefined}
data-testid={`${testIdPrefix}-animated-review-preview`}
height={ANIMATED_AVATAR_SIZE}
ref={previewCanvasRef}
width={ANIMATED_AVATAR_SIZE}
/>
</div>
{phase === "idle" ? (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-dashed border-border bg-background text-primary shadow-xs">
<Camera className="h-10 w-10" />
</div>
) : phase === "starting" ? (
<div className="absolute inset-0 grid place-items-center rounded-full bg-background/70 text-center shadow-inner">
<div className="grid justify-items-center gap-2 px-4">
<Spinner aria-label="Starting camera" className="h-5 w-5" />
<span className="text-xs font-medium text-muted-foreground">
Starting camera
</span>
</div>
</div>
) : phase === "processing" ? (
<div className="grid h-full w-full place-items-center rounded-full bg-background/60 shadow-inner">
<Spinner aria-label="Processing recording" className="h-6 w-6" />
</div>
) : null}
</div>
);
return (
<div
className={cn(
"relative grid content-start",
phase === "review" ? "gap-7 pb-9 pt-2" : "gap-4 pb-5",
phase === "review" && !showApplyButton && "mb-5",
isCustomPickerVisible && "min-h-[504px]",
)}
data-testid={`${testIdPrefix}-animated`}
>
{usePortal && previewContainer
? createPortal(stageContent, previewContainer)
: null}
{showCaptureCard ? (
<div className="relative grid place-items-center rounded-xl bg-muted px-4 py-6">
{usePortal ? null : stageContent}
{inlineCaptureHelpText ? (
<p
className={cn(
"text-center text-sm text-muted-foreground",
!usePortal && "mt-4",
)}
>
{inlineCaptureHelpText}
</p>
) : null}
{reviewWarning ? (
<p className="mt-2 text-center text-xs text-muted-foreground">
{reviewWarning}
</p>
) : null}
</div>
) : null}
{!showCaptureCard && reviewWarning ? (
<p className="rounded-xl bg-muted px-4 py-3 text-center text-xs text-muted-foreground">
{reviewWarning}
</p>
) : null}
{phase === "review" ? (
<AnimatedAvatarReviewNav
activeSection={activeSection}
disabled={disabled}
isSaving={isSaving}
onRetake={retake}
onSectionChange={setActiveSection}
testIdPrefix={testIdPrefix}
/>
) : null}
{phase === "review" && isFramingSection ? (
<div className="flex items-start gap-2">
<AvatarFramingSlider
disabled={disabled || isSaving}
max={Math.round(activeScaleMax * 100)}
min={Math.round(activeScaleMin * 100)}
onChange={(value) => {
if (activeSection === "shape" && value > 0) {
setBackdropColor((current) => current ?? randomBackdropColor());
}
setActiveScale(value / 100);
}}
onReset={resetActiveFraming}
resetValue={Math.round(activeScaleReset * 100)}
resetTestId={`${testIdPrefix}-animated-reset-framing`}
testId={`${testIdPrefix}-animated-size`}
tipText={activeSection === "person" ? PERSON_SIZE_TIP : null}
value={Math.round(activeScale * 100)}
/>
{activeSection === "person" ? (
<AvatarOutlineToggle
disabled={disabled || isSaving}
enabled={personOutline}
onChange={setPersonOutline}
testIdPrefix={testIdPrefix}
/>
) : null}
</div>
) : null}
{phase === "review" && activeSection === "color" ? (
<AnimatedAvatarBackdropPanel
backdropColor={backdropColor}
disabled={disabled}
isCustomBackdropSelected={isCustomBackdropSelected}
isSaving={isSaving}
onOpenCustomPicker={openCustomPicker}
onSelectColor={setBackdropColor}
testIdPrefix={testIdPrefix}
/>
) : null}
{phase === "review" && activeSection === "poster" ? (
<AvatarFilmstripPicker
disabled={disabled || isSaving}
frameCount={bitmaps.length}
frames={filmstripFrames}
helpTestId={`${testIdPrefix}-animated-review-help`}
helpText="Pick the still shown before hover."
onSelectFrame={(index) =>
setPosterIndex(clampFrameIndex(index, bitmaps.length))
}
selectedFrame={posterIndex}
testIdPrefix={testIdPrefix}
/>
) : null}
{showCameraPicker ? (
<div className="grid gap-4">
<AnimatedAvatarCameraPicker
activeCameraSource={activeCameraSource}
computerDisabled={cameraDevices.length > 0 && !computerCamera}
disabled={disabled || phase === "starting"}
iphoneDisabled={
cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
}
onSelectSource={selectCameraSource}
testIdPrefix={testIdPrefix}
/>
{usePortal && inlineCaptureHelpText ? (
<p className="px-1 text-center text-sm text-muted-foreground">
{inlineCaptureHelpText}
</p>
) : null}
<div className="h-14 pt-2">
{phase === "live" ? (
<Button
asChild
className="h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-animated-record`}
disabled={disabled}
onClick={() => void record()}
type="button"
>
<motion.button
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={ENTRANCE_TRANSITION}
>
<Video aria-hidden="true" className="mr-2 h-4 w-4" />
Record {RECORD_SECONDS} seconds
</motion.button>
</Button>
) : null}
</div>
</div>
) : usePortal && inlineCaptureHelpText ? (
<p className="px-1 text-center text-sm text-muted-foreground">
{inlineCaptureHelpText}
</p>
) : null}
{phase === "review" && showApplyButton ? (
<Button
className="h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-animated-apply`}
disabled={disabled || isSaving}
onClick={() => void apply()}
type="button"
>
{isSaving ? (
<Spinner
aria-label="Uploading animated avatar"
className="h-4 w-4"
/>
) : (
"Use as avatar"
)}
</Button>
) : null}
{errorMessage ? (
<p
className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive"
data-testid={`${testIdPrefix}-animated-error`}
role="alert"
>
{errorMessage}
</p>
) : null}
<AvatarCustomColorPanel
colorDraft={customColorDraft}
hue={customHue}
onCommit={() => {
setBackdropColor(customColorDraft);
setIsCustomPickerOpen(false);
}}
onHueChange={setCustomHue}
onSaturationValueChange={(nextSaturation, nextValue) => {
setCustomSaturation(nextSaturation);
setCustomValue(nextValue);
}}
saturation={customSaturation}
className="h-[504px]"
testIdPrefix={`${testIdPrefix}-animated`}
value={customValue}
visible={isCustomPickerVisible}
/>
</div>
);
}

View File

@@ -0,0 +1,470 @@
import { Circle, CircleDashed } from "lucide-react";
import * as React from "react";
import { clampFrameIndex } from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import { cn } from "@/shared/lib/cn";
import { performDefaultHaptic } from "@/shared/lib/haptics";
import { Spinner } from "@/shared/ui/spinner";
const FILMSTRIP_SELECTOR_SIZE = 48;
const SLIDER_TICK_STEP = 10;
function percentFromSliderValue(
value: number,
min: number,
max: number,
): number {
if (max === min) {
return 0;
}
return ((value - min) / (max - min)) * 100;
}
function buildAnchoredSliderTicks(
min: number,
max: number,
resetValue: number,
): number[] {
const ticks = new Set<number>();
for (let tick = resetValue; tick >= min; tick -= SLIDER_TICK_STEP) {
ticks.add(Math.round(tick));
}
for (
let tick = resetValue + SLIDER_TICK_STEP;
tick <= max;
tick += SLIDER_TICK_STEP
) {
ticks.add(Math.round(tick));
}
return [...ticks].sort((first, second) => first - second);
}
function findCrossedSliderTick(
previousValue: number,
nextValue: number,
ticks: number[],
): number | null {
if (previousValue === nextValue) {
return null;
}
if (nextValue > previousValue) {
return (
ticks.find((tick) => tick > previousValue && tick <= nextValue) ?? null
);
}
return (
[...ticks]
.reverse()
.find((tick) => tick < previousValue && tick >= nextValue) ?? null
);
}
type AvatarFramingSliderProps = {
disabled?: boolean;
helpText?: string | null;
helpTestId?: string;
max: number;
min: number;
onChange: (value: number) => void;
onReset: () => void;
resetValue: number;
resetTestId: string;
testId: string;
tipText?: string | null;
value: number;
};
export function AvatarFramingSlider({
disabled = false,
helpText = null,
helpTestId,
max,
min,
onChange,
onReset,
resetValue,
resetTestId,
testId,
tipText = null,
value,
}: AvatarFramingSliderProps) {
const sliderRef = React.useRef<HTMLDivElement | null>(null);
const activePointerRef = React.useRef<number | null>(null);
const valueRef = React.useRef(value);
const lastHapticTickRef = React.useRef<number | null>(null);
const [isHovered, setIsHovered] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false);
const [isInteracting, setIsInteracting] = React.useState(false);
const fill = percentFromSliderValue(value, min, max);
const isActive = isHovered || isFocused || isInteracting;
const tipId = React.useId();
const ticks = React.useMemo(
() => buildAnchoredSliderTicks(min, max, resetValue),
[max, min, resetValue],
);
React.useEffect(() => {
valueRef.current = value;
}, [value]);
const commitValue = React.useCallback(
(nextValue: number) => {
const clampedValue = Math.min(max, Math.max(min, Math.round(nextValue)));
const crossedTick = findCrossedSliderTick(
valueRef.current,
clampedValue,
ticks,
);
valueRef.current = clampedValue;
onChange(clampedValue);
if (crossedTick !== null && crossedTick !== lastHapticTickRef.current) {
lastHapticTickRef.current = crossedTick;
performDefaultHaptic();
} else if (crossedTick === null) {
lastHapticTickRef.current = null;
}
},
[max, min, onChange, ticks],
);
const commitPointerValue = React.useCallback(
(clientX: number) => {
const slider = sliderRef.current;
if (!slider) {
return;
}
const rect = slider.getBoundingClientRect();
const progress = Math.min(
1,
Math.max(0, (clientX - rect.left) / Math.max(rect.width, 1)),
);
commitValue(min + progress * (max - min));
},
[commitValue, max, min],
);
const nudgeValue = React.useCallback(
(delta: number) => {
commitValue(value + delta);
},
[commitValue, value],
);
const resetTickStyle = {
left: `${percentFromSliderValue(resetValue, min, max)}%`,
};
const sliderControl = (
<div className="buzz-avatar-framing-slider-wrapper">
<div
aria-label="Avatar size"
aria-describedby={tipText ? tipId : undefined}
aria-valuemax={max}
aria-valuemin={min}
aria-valuenow={value}
className="buzz-avatar-framing-slider"
data-active={isActive ? "true" : undefined}
data-testid={testId}
onKeyDown={(event) => {
if (disabled) {
return;
}
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
nudgeValue(-step);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
nudgeValue(step);
} else if (event.key === "Home") {
event.preventDefault();
commitValue(min);
} else if (event.key === "End") {
event.preventDefault();
commitValue(max);
}
}}
onBlur={() => setIsFocused(false)}
onFocus={() => setIsFocused(true)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onPointerCancel={(event) => {
if (activePointerRef.current === event.pointerId) {
activePointerRef.current = null;
setIsInteracting(false);
}
}}
onPointerDown={(event) => {
if (disabled) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
activePointerRef.current = event.pointerId;
setIsInteracting(true);
commitPointerValue(event.clientX);
}}
onPointerMove={(event) => {
if (activePointerRef.current !== event.pointerId) {
return;
}
commitPointerValue(event.clientX);
}}
onPointerUp={(event) => {
if (activePointerRef.current === event.pointerId) {
activePointerRef.current = null;
setIsInteracting(false);
}
}}
ref={sliderRef}
role="slider"
style={
{
"--buzz-avatar-framing-slider-fill": `${fill}%`,
} as React.CSSProperties
}
tabIndex={disabled ? -1 : 0}
>
<div className="buzz-avatar-framing-slider-hashmarks">
{ticks.map((tick) => (
<span
aria-hidden="true"
className="buzz-avatar-framing-slider-hashmark"
key={tick}
style={{
left: `${percentFromSliderValue(tick, min, max)}%`,
}}
/>
))}
</div>
<div aria-hidden="true" className="buzz-avatar-framing-slider-fill" />
<div aria-hidden="true" className="buzz-avatar-framing-slider-handle" />
</div>
<button
aria-label="Reset avatar size"
className="buzz-avatar-framing-slider-hashmark"
data-reset="true"
data-testid={resetTestId}
disabled={disabled}
onClick={(event) => {
event.preventDefault();
valueRef.current = resetValue;
lastHapticTickRef.current = resetValue;
performDefaultHaptic();
onReset();
}}
style={resetTickStyle}
title="Reset avatar size"
type="button"
/>
{tipText ? (
<p
className="buzz-avatar-framing-slider-tip"
data-visible={isActive ? "true" : undefined}
id={tipId}
>
{tipText}
</p>
) : null}
</div>
);
return helpText ? (
<div className="grid gap-2">
{sliderControl}
<p
className="px-1 text-center text-sm text-muted-foreground"
data-testid={helpTestId}
>
{helpText}
</p>
</div>
) : (
sliderControl
);
}
type AvatarOutlineToggleProps = {
disabled?: boolean;
enabled: boolean;
onChange: (enabled: boolean) => void;
testIdPrefix: string;
};
export function AvatarOutlineToggle({
disabled = false,
enabled,
onChange,
testIdPrefix,
}: AvatarOutlineToggleProps) {
const Icon = enabled ? Circle : CircleDashed;
return (
<button
aria-label={enabled ? "Turn outline off" : "Turn outline on"}
aria-pressed={enabled}
className={cn(
"grid h-12 w-12 shrink-0 place-items-center rounded-full border border-foreground/10 bg-background text-foreground transition-[background-color,box-shadow,color] duration-150 ease-out hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
enabled ? "shadow-xs" : "text-muted-foreground",
)}
data-testid={`${testIdPrefix}-animated-outline-toggle`}
disabled={disabled}
onClick={() => onChange(!enabled)}
title={enabled ? "Outline on" : "Outline off"}
type="button"
>
<Icon aria-hidden="true" className="h-5 w-5" />
</button>
);
}
type AvatarFilmstripPickerProps = {
disabled?: boolean;
frameCount: number;
frames: string[];
helpText?: string;
helpTestId?: string;
onSelectFrame: (index: number) => void;
selectedFrame: number;
testIdPrefix: string;
};
export function AvatarFilmstripPicker({
disabled = false,
frameCount,
frames,
helpText,
helpTestId,
onSelectFrame,
selectedFrame,
testIdPrefix,
}: AvatarFilmstripPickerProps) {
const stripRef = React.useRef<HTMLDivElement | null>(null);
const maxFrameIndex = Math.max(0, frameCount - 1);
const safeSelectedFrame = clampFrameIndex(selectedFrame, frameCount);
const selectedFrameProgress =
maxFrameIndex === 0 ? 0 : safeSelectedFrame / maxFrameIndex;
const selectFromClientX = React.useCallback(
(clientX: number) => {
if (disabled) {
return;
}
const strip = stripRef.current;
if (!strip) {
return;
}
const rect = strip.getBoundingClientRect();
const nextProgress = Math.min(
1,
Math.max(0, (clientX - rect.left) / Math.max(rect.width, 1)),
);
onSelectFrame(Math.round(nextProgress * maxFrameIndex));
},
[disabled, maxFrameIndex, onSelectFrame],
);
const nudge = React.useCallback(
(delta: number) => {
if (disabled) {
return;
}
onSelectFrame(clampFrameIndex(safeSelectedFrame + delta, frameCount));
},
[disabled, frameCount, onSelectFrame, safeSelectedFrame],
);
return (
<div
className="grid gap-2"
data-testid={`${testIdPrefix}-animated-poster-strip`}
>
<div
aria-label="Choose still frame"
aria-valuemax={maxFrameIndex}
aria-valuemin={0}
aria-valuenow={safeSelectedFrame}
className="relative h-12 min-w-0 touch-none rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`${testIdPrefix}-animated-poster-scrubber`}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
nudge(event.shiftKey ? -3 : -1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
nudge(event.shiftKey ? 3 : 1);
} else if (event.key === "Home") {
event.preventDefault();
onSelectFrame(0);
} else if (event.key === "End") {
event.preventDefault();
onSelectFrame(maxFrameIndex);
}
}}
onPointerDown={(event) => {
if (disabled) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
selectFromClientX(event.clientX);
}}
onPointerMove={(event) => {
if (event.buttons !== 1) {
return;
}
selectFromClientX(event.clientX);
}}
ref={stripRef}
role="slider"
tabIndex={disabled ? -1 : 0}
>
<div className="absolute inset-0 overflow-hidden rounded-md border border-foreground/10 bg-background/70 shadow-inner">
{frames.length === 0 ? (
<div className="grid h-full w-full place-items-center">
<Spinner
aria-label="Generating frame thumbnails"
className="h-5 w-5"
/>
</div>
) : (
<div aria-hidden="true" className="absolute inset-0 flex h-full">
{frames.map((frame, index) => (
<img
alt=""
className="h-full min-w-0 flex-1 object-cover"
draggable={false}
// biome-ignore lint/suspicious/noArrayIndexKey: filmstrip frames are regenerated as a fixed, ordered capture sequence.
key={`filmstrip-frame-${index}`}
src={frame}
/>
))}
</div>
)}
</div>
{frames.length > 0 ? (
<div
aria-hidden="true"
className="pointer-events-none absolute top-1/2 z-10 h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-lg border-[3px] border-white bg-white/10 shadow-[0_4px_16px_rgba(0,0,0,0.32)] ring-1 ring-black/20 transition-[left] duration-75 ease-out"
data-testid={`${testIdPrefix}-animated-poster-selector`}
style={{
left: `clamp(${FILMSTRIP_SELECTOR_SIZE / 2}px, ${
selectedFrameProgress * 100
}%, calc(100% - ${FILMSTRIP_SELECTOR_SIZE / 2}px))`,
}}
/>
) : null}
</div>
{helpText ? (
<p
className="px-1 text-center text-sm text-muted-foreground"
data-testid={helpTestId}
>
{helpText}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import {
Camera,
Circle,
GalleryThumbnails,
Palette,
UserRound,
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
export type ReviewSection = "person" | "shape" | "color" | "poster";
const REVIEW_SECTIONS: {
key: ReviewSection;
label: string;
caption: string;
hidden?: boolean;
icon: typeof UserRound;
}[] = [
{
caption: "You",
icon: UserRound,
key: "person",
label: "Position yourself",
},
{
caption: "Circle",
hidden: true,
icon: Circle,
key: "shape",
label: "Adjust the circle",
},
{ caption: "Background", icon: Palette, key: "color", label: "Background" },
{
caption: "Frame",
icon: GalleryThumbnails,
key: "poster",
label: "Still frame",
},
];
type AnimatedAvatarReviewNavProps = {
activeSection: ReviewSection;
disabled?: boolean;
isSaving: boolean;
onRetake: () => void;
onSectionChange: (section: ReviewSection) => void;
testIdPrefix: string;
};
export function AnimatedAvatarReviewNav({
activeSection,
disabled = false,
isSaving,
onRetake,
onSectionChange,
testIdPrefix,
}: AnimatedAvatarReviewNavProps) {
const controlsDisabled = disabled || isSaving;
return (
<div
className="flex items-start justify-center gap-7"
data-testid={`${testIdPrefix}-animated-sections`}
>
{REVIEW_SECTIONS.filter((section) => !section.hidden).map((section) => {
const Icon = section.icon;
return (
<button
aria-label={section.label}
aria-pressed={activeSection === section.key}
className="group flex flex-col items-center gap-1.5 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`${testIdPrefix}-animated-section-${section.key}`}
disabled={controlsDisabled}
key={section.key}
onClick={() => onSectionChange(section.key)}
title={section.label}
type="button"
>
<span
className={cn(
"grid h-12 w-12 place-items-center rounded-full transition-[background-color,color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none motion-safe:group-hover:scale-[1.04] motion-safe:group-active:scale-[0.98] group-disabled:scale-100",
activeSection === section.key
? "bg-foreground text-background"
: "bg-muted text-muted-foreground/70 group-hover:bg-muted/80 group-hover:text-muted-foreground group-disabled:bg-muted group-disabled:text-muted-foreground/70",
)}
>
<Icon
aria-hidden="true"
className="h-5 w-5 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none motion-safe:group-hover:rotate-[5deg] motion-safe:group-hover:scale-[1.12] motion-safe:group-active:scale-[0.98]"
/>
</span>
<span
className={cn(
"text-sm transition-colors duration-150 ease-out",
activeSection === section.key
? "text-foreground"
: "text-muted-foreground",
)}
>
{section.caption}
</span>
</button>
);
})}
<span
aria-hidden="true"
className="h-12 w-px shrink-0 rounded-full bg-border/70"
/>
<button
aria-label="Retake the recording"
className="group flex flex-col items-center gap-1.5 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`${testIdPrefix}-animated-retake`}
disabled={controlsDisabled}
key="retake"
onClick={onRetake}
title="Retake the recording"
type="button"
>
<span className="grid h-12 w-12 place-items-center rounded-full bg-muted text-muted-foreground/70 transition-[background-color,color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none group-hover:bg-muted/80 group-hover:text-muted-foreground motion-safe:group-hover:scale-[1.04] motion-safe:group-active:scale-[0.98] group-disabled:bg-muted group-disabled:text-muted-foreground/70 group-disabled:scale-100">
<Camera
aria-hidden="true"
className="h-5 w-5 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none motion-safe:group-hover:rotate-[5deg] motion-safe:group-hover:scale-[1.12] motion-safe:group-active:scale-[0.98]"
/>
</span>
<span className="text-sm text-muted-foreground">Retake</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { motion } from "motion/react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
CUSTOM_COLOR_GRID_COLUMNS,
CUSTOM_COLOR_GRID_HORIZONTAL_INSET,
CUSTOM_COLOR_GRID_ROWS,
CUSTOM_COLOR_GRID_VERTICAL_INSET,
CUSTOM_HUE_SCRUBBER_INSET,
clampPercent,
gridInsetPosition,
hueScrubberPosition,
normalizeHue,
snapToGrid,
} from "./ProfileAvatarEditor.utils";
const PANEL_MOTION_TRANSITION = {
duration: 0.25,
ease: "easeOut",
} as const;
type AvatarCustomColorPanelProps = {
visible: boolean;
hue: number;
/** 0-100 */
saturation: number;
/** 0-100 */
value: number;
/** Hex color derived from hue/saturation/value. */
colorDraft: string;
onHueChange: (hue: number) => void;
onSaturationValueChange: (saturation: number, value: number) => void;
onCommit: () => void;
testIdPrefix: string;
className?: string;
};
/**
* The HSV custom color picker overlay shared by the emoji and animated
* avatar tabs: a saturation/value spectrum grid, a hue scrubber, and a
* commit button. The parent owns the HSV state and positions the panel
* (it fills its nearest relative ancestor).
*/
export function AvatarCustomColorPanel({
visible,
hue,
saturation,
value,
colorDraft,
onHueChange,
onSaturationValueChange,
onCommit,
testIdPrefix,
className,
}: AvatarCustomColorPanelProps) {
const hueDragUserSelectRef = React.useRef<string | null>(null);
const unlockHueDragSelection = React.useCallback(() => {
if (hueDragUserSelectRef.current === null) {
return;
}
document.body.style.userSelect = hueDragUserSelectRef.current;
hueDragUserSelectRef.current = null;
}, []);
const lockHueDragSelection = React.useCallback(() => {
if (hueDragUserSelectRef.current !== null) {
return;
}
hueDragUserSelectRef.current = document.body.style.userSelect;
document.body.style.userSelect = "none";
}, []);
const updateColorFromPointer = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const width = Math.max(
rect.width - CUSTOM_COLOR_GRID_HORIZONTAL_INSET * 2,
1,
);
const height = Math.max(
rect.height - CUSTOM_COLOR_GRID_VERTICAL_INSET * 2,
1,
);
const rawSaturation = clampPercent(
((event.clientX - rect.left - CUSTOM_COLOR_GRID_HORIZONTAL_INSET) /
width) *
100,
);
const rawValue = clampPercent(
(1 -
(event.clientY - rect.top - CUSTOM_COLOR_GRID_VERTICAL_INSET) /
height) *
100,
);
const nextSaturation = Math.round(
snapToGrid(rawSaturation, CUSTOM_COLOR_GRID_COLUMNS),
);
const nextValue = Math.round(
snapToGrid(rawValue, CUSTOM_COLOR_GRID_ROWS),
);
onSaturationValueChange(nextSaturation, nextValue);
},
[onSaturationValueChange],
);
const updateHueFromPointer = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const trackWidth = Math.max(
rect.width - CUSTOM_HUE_SCRUBBER_INSET * 2,
1,
);
const nextPercent = clampPercent(
((event.clientX - rect.left - CUSTOM_HUE_SCRUBBER_INSET) / trackWidth) *
100,
);
onHueChange(Math.round((nextPercent / 100) * 360));
},
[onHueChange],
);
const adjustHue = React.useCallback(
(delta: number) => {
onHueChange(normalizeHue(hue + delta));
},
[hue, onHueChange],
);
return (
<motion.div
aria-hidden={!visible}
className={cn(
"absolute inset-0 z-40 flex flex-col rounded-xl bg-muted p-4",
visible ? "pointer-events-auto" : "pointer-events-none",
className,
)}
animate={visible ? { opacity: 1 } : { opacity: 0 }}
inert={visible ? undefined : true}
initial={false}
transition={PANEL_MOTION_TRANSITION}
>
<div
className="relative min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-xl shadow-[inset_0_-18px_34px_rgba(0,0,0,0.18)]"
data-testid={`${testIdPrefix}-custom-color-spectrum`}
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateColorFromPointer(event);
}}
onPointerMove={(event) => {
if (event.buttons === 1) {
event.preventDefault();
updateColorFromPointer(event);
}
}}
onPointerUp={(event) => {
event.preventDefault();
updateColorFromPointer(event);
}}
style={{
backgroundColor: `hsl(${hue}, 100%, 50%)`,
backgroundImage:
"linear-gradient(to bottom, transparent 0%, #000000 100%), linear-gradient(to right, #ffffff 0%, rgba(255,255,255,0) 100%)",
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute"
style={{
inset: `${CUSTOM_COLOR_GRID_VERTICAL_INSET}px ${CUSTOM_COLOR_GRID_HORIZONTAL_INSET}px`,
}}
>
{Array.from({
length: CUSTOM_COLOR_GRID_COLUMNS * CUSTOM_COLOR_GRID_ROWS,
}).map((_, index) => {
const column = index % CUSTOM_COLOR_GRID_COLUMNS;
const row = Math.floor(index / CUSTOM_COLOR_GRID_COLUMNS);
const gridSaturation = Math.round(
(column / (CUSTOM_COLOR_GRID_COLUMNS - 1)) * 100,
);
const gridValue = Math.round(
100 - (row / (CUSTOM_COLOR_GRID_ROWS - 1)) * 100,
);
const isSelectedGridDot =
gridSaturation === saturation && gridValue === value;
return (
<span
className={cn(
"absolute h-1 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/60 shadow-[0_0_4px_rgba(255,255,255,0.24)]",
isSelectedGridDot &&
"h-3 w-3 border-2 border-white shadow-[0_2px_10px_rgba(0,0,0,0.24)]",
)}
key={`${column}-${row}`}
style={{
backgroundColor: isSelectedGridDot ? colorDraft : undefined,
left: `${(column / (CUSTOM_COLOR_GRID_COLUMNS - 1)) * 100}%`,
top: `${(row / (CUSTOM_COLOR_GRID_ROWS - 1)) * 100}%`,
}}
/>
);
})}
</div>
<div
className="pointer-events-none absolute h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_5px_16px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(0,0,0,0.06)]"
style={{
backgroundColor: colorDraft,
left: gridInsetPosition(
saturation,
CUSTOM_COLOR_GRID_HORIZONTAL_INSET,
),
top: gridInsetPosition(
100 - value,
CUSTOM_COLOR_GRID_VERTICAL_INSET,
),
}}
/>
</div>
<div
aria-label="Choose custom avatar color hue"
aria-valuemax={360}
aria-valuemin={0}
aria-valuenow={hue}
className="buzz-avatar-hue-scrubber relative mt-3 h-10 w-full cursor-pointer select-none rounded-full touch-none"
data-testid={`${testIdPrefix}-custom-color-hue`}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
adjustHue(-6);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
adjustHue(6);
} else if (event.key === "Home") {
event.preventDefault();
onHueChange(0);
} else if (event.key === "End") {
event.preventDefault();
onHueChange(360);
}
}}
onPointerDown={(event) => {
event.preventDefault();
lockHueDragSelection();
event.currentTarget.setPointerCapture(event.pointerId);
updateHueFromPointer(event);
}}
onPointerMove={(event) => {
if (event.buttons === 1) {
event.preventDefault();
updateHueFromPointer(event);
}
}}
onPointerCancel={unlockHueDragSelection}
onPointerUp={unlockHueDragSelection}
onLostPointerCapture={unlockHueDragSelection}
role="slider"
tabIndex={visible ? 0 : -1}
>
<div
aria-hidden="true"
className="absolute top-1 h-8 w-8 -translate-x-1/2 rounded-full"
data-testid={`${testIdPrefix}-custom-color-hue-thumb`}
style={{
left: hueScrubberPosition((hue / 360) * 100),
}}
>
<div className="h-full w-full rounded-full bg-white shadow-[0_5px_18px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(0,0,0,0.06)]" />
</div>
</div>
<Button
className="mt-3 h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-custom-color-done`}
onClick={onCommit}
tabIndex={visible ? 0 : -1}
type="button"
>
Use color
</Button>
</motion.div>
);
}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { UserRound } from "lucide-react";
import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -27,53 +28,73 @@ export function ProfileAvatar({
}: ProfileAvatarProps) {
const initials = getInitials(label);
// Compute the live (proxied) source and reset failure state when the URL changes.
const liveSrc = avatarUrl ? rewriteRelayUrl(avatarUrl) : null;
const [liveFailed, setLiveFailed] = React.useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: avatarUrl is the trigger — reset liveFailed when the URL changes even though the effect body doesn't reference it directly.
React.useEffect(() => {
setLiveFailed(false);
}, [avatarUrl]);
// Animated avatars show their static poster frame until hovered, then play
// the animation.
const animated = parseAnimatedAvatarUrl(avatarUrl);
const [isHovered, setIsHovered] = React.useState(false);
const baseUrl = animated
? isHovered
? animated.animationUrl
: animated.posterUrl
: avatarUrl;
// Compute the live (proxied) source. Failures are tracked per resolved URL so
// the poster and hover animation can recover independently.
const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null;
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
const liveFailed = liveSrc !== null && failedSrc === liveSrc;
// When the relay is unreachable the proxied avatar URL 404s/times out; fall
// back to the locally cached data URL instead of dropping to initials.
const src = liveFailed
? (avatarDataUrl ?? undefined)
: (liveSrc ?? avatarDataUrl ?? undefined);
const shouldShowFallback = src === undefined || (!animated && liveFailed);
return (
<Avatar
className={cn(
"shrink-0 text-primary shadow-xs",
plain ? "bg-transparent shadow-none" : "bg-primary/20",
// Animated avatars carry their own backdrop disc and transparent
// surroundings — any container fill would flatten the pop-out.
plain || animated ? "bg-transparent shadow-none" : "bg-primary/20",
className,
)}
data-testid={testId}
onMouseEnter={animated ? () => setIsHovered(true) : undefined}
onMouseLeave={animated ? () => setIsHovered(false) : undefined}
>
{src !== undefined ? (
<AvatarImage
alt={`${label} avatar`}
className="object-cover"
data-testid={testId ? `${testId}-image` : undefined}
onLoadingStatusChange={(status) => {
if (status === "error") setLiveFailed(true);
if (status === "error") setFailedSrc(liveSrc);
if (status === "loaded" && src === liveSrc) {
setFailedSrc(null);
}
}}
referrerPolicy="no-referrer"
src={src}
/>
) : null}
<AvatarFallback
className={cn(
"font-semibold text-primary",
plain ? "bg-transparent" : "bg-primary/20",
)}
delayMs={src === undefined ? undefined : 200}
>
{initials.length > 0 ? (
initials
) : (
<UserRound className={iconClassName} />
)}
</AvatarFallback>
{shouldShowFallback ? (
<AvatarFallback
className={cn(
"font-semibold text-primary",
plain || animated ? "bg-transparent" : "bg-primary/20",
)}
data-testid={testId ? `${testId}-fallback` : undefined}
delayMs={src === undefined ? undefined : 200}
>
{initials.length > 0 ? (
initials
) : (
<UserRound className={iconClassName} />
)}
</AvatarFallback>
) : null}
</Avatar>
);
}

View File

@@ -1,9 +1,12 @@
import emojiData from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { Link2, UploadCloud } from "lucide-react";
import { motion } from "motion/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal, flushSync } from "react-dom";
import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCapture";
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -14,36 +17,54 @@ import {
AVATAR_COLORS,
AVATAR_COLOR_SWATCHES,
CUSTOM_AVATAR_COLOR_SWATCH,
CUSTOM_COLOR_GRID_COLUMNS,
CUSTOM_COLOR_GRID_HORIZONTAL_INSET,
CUSTOM_COLOR_GRID_ROWS,
CUSTOM_COLOR_GRID_VERTICAL_INSET,
CUSTOM_HUE_SCRUBBER_INSET,
DEFAULT_CUSTOM_HUE,
DEFAULT_CUSTOM_SATURATION,
DEFAULT_CUSTOM_VALUE,
DEFAULT_EMOJI_AVATAR_COLOR,
EMOJI_MART_CATEGORIES,
type AvatarColorSwatch,
clampPercent,
contrastColorForBackground,
dataTransferHasImage,
emojiAvatarDataUrl,
gridInsetPosition,
hexToHsv,
hsvToHex,
hueScrubberPosition,
normalizeHue,
parseEmojiAvatarDataUrl,
snapToGrid,
useEmojiMartStyles,
useEmojiMartThemeVars,
visibleUrlDraft,
} from "./ProfileAvatarEditor.utils";
export { parseEmojiAvatarDataUrl } from "./ProfileAvatarEditor.utils";
export type AvatarMode = "image" | "emoji";
export type AvatarMode = "image" | "emoji" | "animated";
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
const DONE_BUTTON_CONTENT_TRANSITION = {
duration: 0.14,
ease: [0.23, 1, 0.32, 1],
} as const;
const DONE_BUTTON_SHELL_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
function waitForPendingButtonPaint() {
return new Promise<void>((resolve) => {
if (
typeof window === "undefined" ||
typeof window.requestAnimationFrame !== "function"
) {
setTimeout(resolve, 0);
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
});
});
}
type ProfileAvatarEditorProps = {
avatarUrl: string;
@@ -56,22 +77,26 @@ type ProfileAvatarEditorProps = {
onModeChange?: (mode: AvatarMode) => void;
onUploadedAvatarChange?: (url: string | null) => void;
onUploadingChange?: (isUploading: boolean) => void;
onAnimatedAvatarApply?: (url: string) => void;
onDone?: () => void;
donePending?: boolean;
hiddenAvatarUrl?: string | null;
showEmojiColorControlsWhenEmpty?: boolean;
disabled?: boolean;
testIdPrefix?: string;
/** Host element the animated tab renders its live preview into. */
animatedPreviewContainer?: HTMLElement | null;
/** Optional host element for the mode tabs; undefined keeps them inline. */
modeTabsContainer?: HTMLElement | null;
/** Fires when the animated tab starts/stops occupying the host preview. */
onAnimatedPreviewActiveChange?: (active: boolean) => void;
/** Caption shown under the host preview while animated capture is active. */
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
};
type EmojiMartEmoji = {
native?: string;
};
const AVATAR_EDITOR_MOTION_TRANSITION = {
duration: 0.25,
ease: "easeOut",
} as const;
const INITIAL_EMOJI_AVATAR_COLORS = AVATAR_COLORS.filter(
(color) => color !== DEFAULT_EMOJI_AVATAR_COLOR,
);
@@ -92,28 +117,31 @@ export function ProfileAvatarEditor({
donePending = false,
emojiPickerTheme = "dark",
emojiPickerThemeVars,
hiddenAvatarUrl,
onCustomColorPickerOpenChange,
onEmojiAvatarChange,
onModeChange,
onUploadedAvatarChange,
onUrlChange,
onAnimatedAvatarApply,
onDone,
onUploadingChange,
showEmojiColorControlsWhenEmpty = false,
disabled,
testIdPrefix = "profile-avatar",
animatedPreviewContainer = null,
modeTabsContainer,
onAnimatedPreviewActiveChange,
onAnimatedPreviewCaptionChange,
}: ProfileAvatarEditorProps) {
const { burstEmoji } = useEmojiBurst();
const shouldReduceMotion = useReducedMotion();
const initialEmojiAvatar = React.useMemo(
() => parseEmojiAvatarDataUrl(avatarUrl),
[avatarUrl],
);
const [mode, setMode] = React.useState<AvatarMode>("image");
const [isDragging, setIsDragging] = React.useState(false);
const [urlDraft, setUrlDraft] = React.useState(() =>
visibleUrlDraft(avatarUrl, hiddenAvatarUrl),
);
const [urlDraft, setUrlDraft] = React.useState("");
const [selectedEmoji, setSelectedEmoji] = React.useState<string | null>(
() => initialEmojiAvatar?.emoji ?? null,
);
@@ -127,9 +155,10 @@ export function ProfileAvatarEditor({
const [customValue, setCustomValue] = React.useState(DEFAULT_CUSTOM_VALUE);
const [isCustomColorPickerOpen, setIsCustomColorPickerOpen] =
React.useState(false);
const [isAnimatedCustomColorPickerOpen, setIsAnimatedCustomColorPickerOpen] =
React.useState(false);
const dragDepthRef = React.useRef(0);
const emojiPickerContainerRef = React.useRef<HTMLDivElement | null>(null);
const hueDragUserSelectRef = React.useRef<string | null>(null);
const modeContentRef = React.useRef<HTMLDivElement | null>(null);
const isUrlInputFocusedRef = React.useRef(false);
const hasUserEditedUrlDraftRef = React.useRef(false);
@@ -147,6 +176,8 @@ export function ProfileAvatarEditor({
(selectedEmoji !== null || showEmojiColorControlsWhenEmpty);
const isCustomColorPickerVisible =
isCustomColorPickerOpen && shouldShowColorControls;
const isAnyCustomColorPickerVisible =
isCustomColorPickerVisible || isAnimatedCustomColorPickerOpen;
const updateMode = React.useCallback(
(nextMode: AvatarMode) => {
if (mode === nextMode) {
@@ -167,6 +198,8 @@ export function ProfileAvatarEditor({
},
[onUploadedAvatarChange, onUrlChange, updateMode],
);
const [isAnimatedApplyPending, setIsAnimatedApplyPending] =
React.useState(false);
const {
clearError: clearUploadError,
errorMessage: uploadErrorMessage,
@@ -176,7 +209,71 @@ export function ProfileAvatarEditor({
openPicker,
uploadFile,
} = useAvatarUpload({ onUploadSuccess: handleUploadSuccess });
const isInputDisabled = disabled || isUploading;
const isInputDisabled = disabled || isUploading || isAnimatedApplyPending;
const handleAnimatedApply = React.useCallback(
(animatedUrl: string) => {
clearUploadError();
setUrlDraft("");
onUploadedAvatarChange?.(animatedUrl);
onUrlChange(animatedUrl);
onAnimatedAvatarApply?.(animatedUrl);
},
[
clearUploadError,
onAnimatedAvatarApply,
onUploadedAvatarChange,
onUrlChange,
],
);
// Done on the animated tab uploads the pending recording first, then
// saves. The save is queued through state so it runs on the next render,
// after the freshly applied avatar URL has propagated into the host's
// drafts (calling onDone directly would read stale state).
const animatedApplyRef = React.useRef<(() => Promise<boolean>) | null>(null);
const [hasAnimatedApply, setHasAnimatedApply] = React.useState(false);
const registerAnimatedApply = React.useCallback(
(apply: (() => Promise<boolean>) | null) => {
animatedApplyRef.current = apply;
setHasAnimatedApply(apply !== null);
},
[],
);
const [isAnimatedDoneQueued, setIsAnimatedDoneQueued] = React.useState(false);
const isDoneButtonPending =
donePending ||
isUploading ||
isAnimatedApplyPending ||
isAnimatedDoneQueued;
const handleDoneClick = React.useCallback(() => {
const applyAnimated = mode === "animated" ? animatedApplyRef.current : null;
if (applyAnimated) {
flushSync(() => {
setIsAnimatedApplyPending(true);
});
void waitForPendingButtonPaint()
.then(() => applyAnimated())
.then((applied) => {
if (applied) {
setIsAnimatedDoneQueued(true);
return;
}
})
.catch(() => {})
.finally(() => {
setIsAnimatedApplyPending(false);
});
return;
}
onDone?.();
}, [mode, onDone]);
React.useEffect(() => {
if (!isAnimatedDoneQueued) {
return;
}
setIsAnimatedDoneQueued(false);
onDone?.();
}, [isAnimatedDoneQueued, onDone]);
useEmojiMartStyles(emojiPickerContainerRef, mode === "emoji");
@@ -199,15 +296,8 @@ export function ProfileAvatarEditor({
}, []);
React.useLayoutEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
React.useLayoutEffect(() => {
if (isUrlInputFocusedRef.current || hasUserEditedUrlDraftRef.current) {
return;
}
setUrlDraft(visibleUrlDraft(avatarUrl, hiddenAvatarUrl));
}, [avatarUrl, hiddenAvatarUrl]);
onUploadingChange?.(isUploading || (!onDone && isAnimatedApplyPending));
}, [isAnimatedApplyPending, isUploading, onDone, onUploadingChange]);
React.useEffect(() => {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
@@ -229,12 +319,12 @@ export function ProfileAvatarEditor({
}, [shouldShowColorControls]);
React.useLayoutEffect(() => {
onCustomColorPickerOpenChange?.(isCustomColorPickerVisible);
onCustomColorPickerOpenChange?.(isAnyCustomColorPickerVisible);
return () => {
onCustomColorPickerOpenChange?.(false);
};
}, [isCustomColorPickerVisible, onCustomColorPickerOpenChange]);
}, [isAnyCustomColorPickerVisible, onCustomColorPickerOpenChange]);
React.useEffect(() => {
if (!isCustomColorPickerOpen || !selectedEmoji) {
@@ -257,24 +347,6 @@ export function ProfileAvatarEditor({
selectedEmoji,
]);
const unlockHueDragSelection = React.useCallback(() => {
if (hueDragUserSelectRef.current === null) {
return;
}
document.body.style.userSelect = hueDragUserSelectRef.current;
hueDragUserSelectRef.current = null;
}, []);
const lockHueDragSelection = React.useCallback(() => {
if (hueDragUserSelectRef.current !== null) {
return;
}
hueDragUserSelectRef.current = document.body.style.userSelect;
document.body.style.userSelect = "none";
}, []);
const handleFiles = React.useCallback(
(files: FileList | null) => {
const file = files?.[0];
@@ -311,6 +383,8 @@ export function ProfileAvatarEditor({
const applyEmojiAvatar = React.useCallback(
(emoji: string, color = selectedColor) => {
setUrlDraft("");
hasUserEditedUrlDraftRef.current = false;
onUploadedAvatarChange?.(null);
onUrlChange(emojiAvatarDataUrl(emoji, color));
onEmojiAvatarChange?.();
@@ -326,61 +400,6 @@ export function ProfileAvatarEditor({
setIsCustomColorPickerOpen(true);
}, [selectedColor]);
const updateCustomColorFromPointer = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const width = Math.max(
rect.width - CUSTOM_COLOR_GRID_HORIZONTAL_INSET * 2,
1,
);
const height = Math.max(
rect.height - CUSTOM_COLOR_GRID_VERTICAL_INSET * 2,
1,
);
const rawSaturation = clampPercent(
((event.clientX - rect.left - CUSTOM_COLOR_GRID_HORIZONTAL_INSET) /
width) *
100,
);
const rawValue = clampPercent(
(1 -
(event.clientY - rect.top - CUSTOM_COLOR_GRID_VERTICAL_INSET) /
height) *
100,
);
const nextSaturation = Math.round(
snapToGrid(rawSaturation, CUSTOM_COLOR_GRID_COLUMNS),
);
const nextValue = Math.round(
snapToGrid(rawValue, CUSTOM_COLOR_GRID_ROWS),
);
setCustomSaturation(nextSaturation);
setCustomValue(nextValue);
},
[],
);
const updateCustomHueFromPointer = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const trackWidth = Math.max(
rect.width - CUSTOM_HUE_SCRUBBER_INSET * 2,
1,
);
const nextPercent = clampPercent(
((event.clientX - rect.left - CUSTOM_HUE_SCRUBBER_INSET) / trackWidth) *
100,
);
setCustomHue(Math.round((nextPercent / 100) * 360));
},
[],
);
const adjustCustomHue = React.useCallback((delta: number) => {
setCustomHue((current) => normalizeHue(current + delta));
}, []);
const commitCustomColor = React.useCallback(() => {
setSelectedColor(customColorDraft);
if (selectedEmoji) {
@@ -449,6 +468,63 @@ export function ProfileAvatarEditor({
}, [isDragging, resetDragState]);
const isImageDropActive = mode === "image" && isDragging;
const shouldShowDoneButton =
onDone &&
!isAnyCustomColorPickerVisible &&
(mode !== "animated" || hasAnimatedApply || isDoneButtonPending);
const modeTabs = (
<Tabs
className="w-full"
onValueChange={(nextMode) => {
if (isInputDisabled) {
return;
}
updateMode(nextMode as AvatarMode);
}}
value={mode}
>
<TabsList
aria-label="Avatar type"
className="relative isolate grid h-14 w-full grid-cols-3 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground"
>
<div
aria-hidden="true"
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
style={{
transform: `translateX(${MODE_TAB_ORDER.indexOf(mode) * 100}%)`,
width: "calc((100% - 8px) / 3)",
}}
/>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="image"
>
Image
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="emoji"
>
Emoji
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="animated"
>
Animated
</TabsTrigger>
</TabsList>
</Tabs>
);
const modeTabsContent =
modeTabsContainer === undefined
? modeTabs
: modeTabsContainer
? createPortal(modeTabs, modeTabsContainer)
: null;
return (
<fieldset
@@ -508,44 +584,7 @@ export function ProfileAvatarEditor({
<legend className="sr-only">Avatar image picker</legend>
<div className="relative">
<div className="relative grid w-full gap-4">
<Tabs
className="w-full"
onValueChange={(nextMode) => {
if (isInputDisabled) {
return;
}
updateMode(nextMode as AvatarMode);
}}
value={mode}
>
<TabsList
aria-label="Avatar type"
className="relative isolate grid h-14 w-full grid-cols-2 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground"
>
<div
aria-hidden="true"
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
style={{
transform: `translateX(${mode === "emoji" ? "100%" : "0"})`,
width: "calc((100% - 8px) / 2)",
}}
/>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="image"
>
Image
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="emoji"
>
Emoji
</TabsTrigger>
</TabsList>
</Tabs>
{modeTabsContent}
<div
className="overflow-hidden transition-[height] duration-[250ms] ease-out"
@@ -657,6 +696,21 @@ export function ProfileAvatarEditor({
</p>
) : null}
</div>
) : mode === "animated" ? (
<AnimatedAvatarCapture
disabled={isInputDisabled}
onCustomColorPickerOpenChange={
setIsAnimatedCustomColorPickerOpen
}
onApply={handleAnimatedApply}
onApplyPendingChange={setIsAnimatedApplyPending}
onPreviewActiveChange={onAnimatedPreviewActiveChange}
onPreviewCaptionChange={onAnimatedPreviewCaptionChange}
previewContainer={animatedPreviewContainer}
registerApply={registerAnimatedApply}
showApplyButton={!onDone}
testIdPrefix={testIdPrefix}
/>
) : (
<div className="relative grid content-start gap-3">
<div
@@ -778,203 +832,98 @@ export function ProfileAvatarEditor({
</div>
</div>
<motion.div
aria-hidden={!isCustomColorPickerVisible}
className={cn(
"absolute inset-0 z-40 flex flex-col rounded-xl bg-muted p-4",
isCustomColorPickerVisible
? "pointer-events-auto"
: "pointer-events-none",
)}
animate={
isCustomColorPickerVisible
? { opacity: 1 }
: { opacity: 0 }
}
inert={isCustomColorPickerVisible ? undefined : true}
initial={false}
transition={AVATAR_EDITOR_MOTION_TRANSITION}
>
<div
className="relative min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-xl shadow-[inset_0_-18px_34px_rgba(0,0,0,0.18)]"
data-testid={`${testIdPrefix}-custom-color-spectrum`}
onPointerDown={(event) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateCustomColorFromPointer(event);
}}
onPointerMove={(event) => {
if (event.buttons === 1) {
event.preventDefault();
updateCustomColorFromPointer(event);
}
}}
onPointerUp={(event) => {
event.preventDefault();
updateCustomColorFromPointer(event);
}}
style={{
backgroundColor: `hsl(${customHue}, 100%, 50%)`,
backgroundImage:
"linear-gradient(to bottom, transparent 0%, #000000 100%), linear-gradient(to right, #ffffff 0%, rgba(255,255,255,0) 100%)",
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute"
style={{
inset: `${CUSTOM_COLOR_GRID_VERTICAL_INSET}px ${CUSTOM_COLOR_GRID_HORIZONTAL_INSET}px`,
}}
>
{Array.from({
length:
CUSTOM_COLOR_GRID_COLUMNS * CUSTOM_COLOR_GRID_ROWS,
}).map((_, index) => {
const column = index % CUSTOM_COLOR_GRID_COLUMNS;
const row = Math.floor(
index / CUSTOM_COLOR_GRID_COLUMNS,
);
const gridSaturation = Math.round(
(column / (CUSTOM_COLOR_GRID_COLUMNS - 1)) * 100,
);
const gridValue = Math.round(
100 - (row / (CUSTOM_COLOR_GRID_ROWS - 1)) * 100,
);
const isSelectedGridDot =
gridSaturation === customSaturation &&
gridValue === customValue;
return (
<span
className={cn(
"absolute h-1 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/60 shadow-[0_0_4px_rgba(255,255,255,0.24)]",
isSelectedGridDot &&
"h-3 w-3 border-2 border-white shadow-[0_2px_10px_rgba(0,0,0,0.24)]",
)}
key={`${column}-${row}`}
style={{
backgroundColor: isSelectedGridDot
? customColorDraft
: undefined,
left: `${
(column / (CUSTOM_COLOR_GRID_COLUMNS - 1)) *
100
}%`,
top: `${
(row / (CUSTOM_COLOR_GRID_ROWS - 1)) * 100
}%`,
}}
/>
);
})}
</div>
<div
className="pointer-events-none absolute h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_5px_16px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(0,0,0,0.06)]"
style={{
backgroundColor: customColorDraft,
left: gridInsetPosition(
customSaturation,
CUSTOM_COLOR_GRID_HORIZONTAL_INSET,
),
top: gridInsetPosition(
100 - customValue,
CUSTOM_COLOR_GRID_VERTICAL_INSET,
),
}}
/>
</div>
<div
aria-label="Choose custom avatar color hue"
aria-valuemax={360}
aria-valuemin={0}
aria-valuenow={customHue}
className="buzz-avatar-hue-scrubber relative mt-3 h-10 w-full cursor-pointer select-none rounded-full touch-none"
data-testid={`${testIdPrefix}-custom-color-hue`}
onKeyDown={(event) => {
if (
event.key === "ArrowLeft" ||
event.key === "ArrowDown"
) {
event.preventDefault();
adjustCustomHue(-6);
} else if (
event.key === "ArrowRight" ||
event.key === "ArrowUp"
) {
event.preventDefault();
adjustCustomHue(6);
} else if (event.key === "Home") {
event.preventDefault();
setCustomHue(0);
} else if (event.key === "End") {
event.preventDefault();
setCustomHue(360);
}
}}
onPointerDown={(event) => {
event.preventDefault();
lockHueDragSelection();
event.currentTarget.setPointerCapture(event.pointerId);
updateCustomHueFromPointer(event);
}}
onPointerMove={(event) => {
if (event.buttons === 1) {
event.preventDefault();
updateCustomHueFromPointer(event);
}
}}
onPointerCancel={unlockHueDragSelection}
onPointerUp={unlockHueDragSelection}
onLostPointerCapture={unlockHueDragSelection}
role="slider"
tabIndex={isCustomColorPickerVisible ? 0 : -1}
>
<div
aria-hidden="true"
className="absolute top-1 h-8 w-8 -translate-x-1/2 rounded-full"
data-testid={`${testIdPrefix}-custom-color-hue-thumb`}
style={{
left: hueScrubberPosition((customHue / 360) * 100),
}}
>
<div className="h-full w-full rounded-full bg-white shadow-[0_5px_18px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(0,0,0,0.06)]" />
</div>
</div>
<Button
className="mt-3 h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-custom-color-done`}
onClick={commitCustomColor}
tabIndex={isCustomColorPickerVisible ? 0 : -1}
type="button"
>
Use color
</Button>
</motion.div>
<AvatarCustomColorPanel
colorDraft={customColorDraft}
hue={customHue}
onCommit={commitCustomColor}
onHueChange={setCustomHue}
onSaturationValueChange={(nextSaturation, nextValue) => {
setCustomSaturation(nextSaturation);
setCustomValue(nextValue);
}}
saturation={customSaturation}
testIdPrefix={testIdPrefix}
value={customValue}
visible={isCustomColorPickerVisible}
/>
</div>
)}
</div>
</div>
{onDone && !isCustomColorPickerOpen ? (
<Button
className="h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-done`}
disabled={disabled || donePending || isUploading}
onClick={onDone}
type="button"
>
{donePending ? (
<Spinner
aria-label="Saving avatar"
className="h-4 w-4 border-2"
/>
) : (
"Done"
)}
</Button>
) : null}
<AnimatePresence initial={false}>
{shouldShowDoneButton ? (
<Button asChild className="mt-2 h-12 w-full rounded-xl">
<motion.button
animate={{ opacity: 1, scale: 1 }}
data-testid={`${testIdPrefix}-done`}
disabled={disabled || isDoneButtonPending}
exit={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96 }
}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.98 }
}
key="done"
onClick={handleDoneClick}
transition={DONE_BUTTON_SHELL_TRANSITION}
type="button"
>
<span className="grid place-items-center">
<AnimatePresence initial={false}>
{isDoneButtonPending ? (
<motion.span
animate={{ opacity: 1, y: 0 }}
className="col-start-1 row-start-1 inline-flex items-center justify-center gap-2"
exit={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: -3 }
}
initial={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: 3 }
}
key="pending"
transition={DONE_BUTTON_CONTENT_TRANSITION}
>
<Spinner
aria-label="Saving avatar"
className="h-4 w-4"
/>
<span>Saving</span>
</motion.span>
) : (
<motion.span
animate={{ opacity: 1, y: 0 }}
className="col-start-1 row-start-1"
exit={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: -3 }
}
initial={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: 3 }
}
key="ready"
transition={DONE_BUTTON_CONTENT_TRANSITION}
>
Done
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
</Button>
) : null}
</AnimatePresence>
</div>
</div>

View File

@@ -430,17 +430,6 @@ export function dataTransferHasImage(dataTransfer: DataTransfer | null) {
);
}
export function visibleUrlDraft(
avatarUrl: string,
hiddenAvatarUrl?: string | null,
) {
if (avatarUrl.startsWith("data:") || avatarUrl === hiddenAvatarUrl) {
return "";
}
return avatarUrl;
}
export function useEmojiMartStyles(
containerRef: React.RefObject<HTMLDivElement | null>,
enabled: boolean,

View File

@@ -13,6 +13,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks";
import { useChannelsQuery } from "@/features/channels/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
@@ -187,7 +188,12 @@ export function UserProfilePopover({
alt={profile.displayName ?? "User avatar"}
className="h-10 w-10 shrink-0 rounded-lg object-cover shadow-xs"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(profile.avatarUrl)}
// The popover only shows while hovering, so animated avatars
// play their animation here instead of the static poster frame.
src={rewriteRelayUrl(
parseAnimatedAvatarUrl(profile.avatarUrl)?.animationUrl ??
profile.avatarUrl,
)}
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-xs font-semibold text-secondary-foreground shadow-xs">

View File

@@ -1,4 +1,10 @@
import { Check, ChevronDown, Copy, Pencil } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import * as React from "react";
import { toast } from "sonner";
@@ -13,6 +19,7 @@ import {
} from "@/features/profile/ui/ProfileAvatarEditor";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { Textarea } from "@/shared/ui/textarea";
type ProfileSettingsCardProps = {
@@ -20,7 +27,19 @@ type ProfileSettingsCardProps = {
fallbackDisplayName?: string;
};
const AVATAR_EDITOR_TRANSITION_MS = 150;
const AVATAR_EDITOR_TRANSITION_MS = 240;
const AVATAR_PREVIEW_CAPTION_TRANSITION = {
duration: 0.18,
ease: [0.23, 1, 0.32, 1],
} as const;
const AVATAR_MODE_TABS_TRANSITION = {
duration: 0.2,
ease: [0.23, 1, 0.32, 1],
} as const;
const AVATAR_EDITOR_LAYOUT_TRANSITION = {
duration: 0.3,
ease: [0.23, 1, 0.32, 1],
} as const;
function IdentityRow({
label,
@@ -107,6 +126,7 @@ export function ProfileSettingsCard({
currentPubkey,
fallbackDisplayName,
}: ProfileSettingsCardProps) {
const shouldReduceMotion = useReducedMotion();
const profileQuery = useProfileQuery();
const updateProfileMutation = useUpdateProfileMutation();
const profile = profileQuery.data;
@@ -122,6 +142,19 @@ export function ProfileSettingsCard({
>(null);
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isAvatarEditorFinishing, setIsAvatarEditorFinishing] =
React.useState(false);
// The animated avatar tab portals its camera feed / composed preview into
// the main avatar preview above, replacing the regular preview while live.
const [animatedPreviewEl, setAnimatedPreviewEl] =
React.useState<HTMLDivElement | null>(null);
const [avatarModeTabsEl, setAvatarModeTabsEl] =
React.useState<HTMLDivElement | null>(null);
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
React.useState(false);
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
string | null
>(null);
const [isEditingProfileMetadata, setIsEditingProfileMetadata] =
React.useState(false);
const [shouldRenderAvatarEditor, setShouldRenderAvatarEditor] =
@@ -131,6 +164,7 @@ export function ProfileSettingsCard({
const aboutTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const isEditingProfileMetadataRef = React.useRef(false);
const avatarEditorOpenFrameRef = React.useRef<number | null>(null);
const avatarEditorFinishTimeoutRef = React.useRef<number | null>(null);
const avatarEditClipId = React.useId().replace(/:/g, "");
isEditingProfileMetadataRef.current = isEditingProfileMetadata;
@@ -170,7 +204,11 @@ export function ProfileSettingsCard({
}, [isEditingProfileMetadata]);
React.useEffect(() => {
if (isAvatarEditorOpen || !shouldRenderAvatarEditor) {
if (
isAvatarEditorOpen ||
!shouldRenderAvatarEditor ||
isAvatarEditorFinishing
) {
return;
}
@@ -179,13 +217,22 @@ export function ProfileSettingsCard({
}, AVATAR_EDITOR_TRANSITION_MS);
return () => window.clearTimeout(timeoutId);
}, [isAvatarEditorOpen, shouldRenderAvatarEditor]);
}, [isAvatarEditorFinishing, isAvatarEditorOpen, shouldRenderAvatarEditor]);
React.useEffect(() => {
if (!shouldRenderAvatarEditor) {
setIsAvatarEditorFinishing(false);
}
}, [shouldRenderAvatarEditor]);
React.useEffect(() => {
return () => {
if (avatarEditorOpenFrameRef.current !== null) {
window.cancelAnimationFrame(avatarEditorOpenFrameRef.current);
}
if (avatarEditorFinishTimeoutRef.current !== null) {
window.clearTimeout(avatarEditorFinishTimeoutRef.current);
}
};
}, []);
@@ -228,12 +275,15 @@ export function ProfileSettingsCard({
const hasProfileChanges = Object.keys(updatePayload).length > 0;
const canSave =
hasProfileChanges && !updateProfileMutation.isPending && !isUploadingAvatar;
const isAvatarEditorSaving =
isAvatarEditorFinishing ||
(shouldRenderAvatarEditor && updateProfileMutation.isPending);
const shouldShowSaveArea = hasPendingClearRequest;
const readOnlyContentMotionClassName = cn(
"min-w-0 w-full origin-top overflow-hidden transition-[opacity,scale] duration-150 ease-out",
"min-w-0 w-full origin-top overflow-hidden transition-[opacity,scale] duration-200 ease-out will-change-[opacity,transform]",
shouldRenderAvatarEditor ? "absolute inset-x-0 top-0" : "relative",
isAvatarEditorOpen
? "pointer-events-none scale-[0.94] opacity-0"
? "pointer-events-none scale-[0.98] opacity-0"
: "scale-100 opacity-100",
);
@@ -248,6 +298,14 @@ export function ProfileSettingsCard({
() => parseEmojiAvatarDataUrl(avatarUrlDraft),
[avatarUrlDraft],
);
const shouldShowAnimatedPreview =
isAvatarEditorOpen && isAnimatedPreviewActive;
const visibleAnimatedPreviewCaption = isAvatarEditorOpen
? animatedPreviewCaption
: null;
const avatarEditorLayoutTransition = shouldReduceMotion
? { duration: 0 }
: AVATAR_EDITOR_LAYOUT_TRANSITION;
const avatarEditShellClassName = cn(
"absolute right-0 bottom-0 z-10 flex h-[54px] w-[54px] items-center justify-center rounded-full bg-background opacity-100 transition-[opacity,scale,transform] duration-150 ease-out",
isAvatarEditorOpen
@@ -255,7 +313,7 @@ export function ProfileSettingsCard({
: "scale-100 opacity-100",
);
const avatarEditButtonClassName = cn(
"flex h-11 w-11 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,opacity,scale,transform] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"flex h-11 w-11 items-center justify-center rounded-full bg-sidebar-active text-sidebar-active-foreground shadow-lg transition-[background-color,opacity,scale,transform] duration-150 ease-out hover:scale-[1.04] hover:bg-sidebar-active focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-90 disabled:hover:scale-100",
);
const avatarClipStyle = React.useMemo<React.CSSProperties | undefined>(
() =>
@@ -267,9 +325,40 @@ export function ProfileSettingsCard({
: undefined,
[avatarEditClipId, isAvatarEditorOpen],
);
const clearAvatarEditorFinishTimeout = React.useCallback(() => {
if (avatarEditorFinishTimeoutRef.current === null) {
return;
}
window.clearTimeout(avatarEditorFinishTimeoutRef.current);
avatarEditorFinishTimeoutRef.current = null;
}, []);
const closeAvatarEditor = React.useCallback(() => {
clearAvatarEditorFinishTimeout();
setIsAvatarEditorOpen(false);
setIsAvatarEditorFinishing(false);
}, [clearAvatarEditorFinishTimeout]);
const completeAvatarEditorClose = React.useCallback(() => {
setIsAvatarEditorOpen(false);
clearAvatarEditorFinishTimeout();
avatarEditorFinishTimeoutRef.current = window.setTimeout(
() => {
avatarEditorFinishTimeoutRef.current = null;
setIsAvatarEditorFinishing(false);
},
shouldReduceMotion ? 0 : AVATAR_EDITOR_TRANSITION_MS,
);
}, [clearAvatarEditorFinishTimeout, shouldReduceMotion]);
const reopenAvatarEditorAfterClose = React.useCallback(() => {
clearAvatarEditorFinishTimeout();
setShouldRenderAvatarEditor(true);
setIsAvatarEditorFinishing(false);
setIsAvatarEditorOpen(true);
}, [clearAvatarEditorFinishTimeout]);
const openAvatarEditor = React.useCallback(() => {
setShouldRenderAvatarEditor(true);
setIsAvatarEditorFinishing(false);
clearAvatarEditorFinishTimeout();
if (avatarEditorOpenFrameRef.current !== null) {
window.cancelAnimationFrame(avatarEditorOpenFrameRef.current);
@@ -279,7 +368,7 @@ export function ProfileSettingsCard({
avatarEditorOpenFrameRef.current = null;
setIsAvatarEditorOpen(true);
});
}, []);
}, [clearAvatarEditorFinishTimeout]);
const saveProfile = React.useCallback(async () => {
if (!canSave) {
@@ -335,19 +424,30 @@ export function ProfileSettingsCard({
if (hasPendingAvatarClearRequest) {
setAvatarUrlDraft(currentAvatarUrl);
}
setIsAvatarEditorOpen(false);
closeAvatarEditor();
return;
}
void saveProfile().then((didSave) => {
if (didSave) {
setIsAvatarEditorOpen(false);
}
});
setIsAvatarEditorFinishing(true);
void saveProfile()
.then((didSave) => {
if (didSave) {
completeAvatarEditorClose();
return;
}
reopenAvatarEditorAfterClose();
})
.catch(() => {
reopenAvatarEditorAfterClose();
});
}, [
closeAvatarEditor,
completeAvatarEditorClose,
currentAvatarUrl,
hasPendingAvatarClearRequest,
hasProfileChanges,
reopenAvatarEditorAfterClose,
saveProfile,
]);
@@ -387,250 +487,349 @@ export function ProfileSettingsCard({
void saveProfile();
}}
>
<div className="flex min-w-0 flex-col items-center gap-12">
<div
className="relative h-48 w-48"
data-testid="profile-avatar-clip-frame"
<LayoutGroup id="profile-avatar-editor-layout">
<motion.div
className="flex min-w-0 flex-col items-center gap-12"
layout="position"
transition={avatarEditorLayoutTransition}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
fill="none"
height="192"
viewBox="0 0 192 192"
width="192"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath
clipPathUnits="userSpaceOnUse"
id={avatarEditClipId}
>
<path
clipRule="evenodd"
d="M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
fillRule="evenodd"
transform="translate(-34.5 -34.5) scale(2.1)"
<AnimatePresence initial={false} mode="popLayout">
{isAvatarEditorOpen ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="relative z-20 -mb-14 grid h-48 w-full max-w-[576px] origin-center place-items-center"
data-testid="profile-avatar-mode-tabs-slot"
exit={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96 }
}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96 }
}
key="profile-avatar-mode-tabs-slot"
layout="position"
ref={setAvatarModeTabsEl}
transition={AVATAR_MODE_TABS_TRANSITION}
/>
</clipPath>
</svg>
) : null}
</AnimatePresence>
<div
className="h-full w-full"
data-testid="profile-avatar-preview-clip"
style={avatarClipStyle}
<motion.div
className="flex flex-col items-center gap-3"
layout="position"
transition={avatarEditorLayoutTransition}
>
{emojiAvatarPreview ? (
<div
aria-label={`${resolvedName} avatar`}
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-full shadow-xs"
data-testid="profile-avatar-preview"
role="img"
style={{ backgroundColor: emojiAvatarPreview.color }}
>
<span
className={cn(
"buzz-avatar-emoji-glyph flex h-full w-full items-center justify-center text-[6rem] leading-[100px]",
avatarSquishKey > 0 && "buzz-avatar-squish",
)}
data-testid="profile-avatar-preview-emoji"
key={avatarSquishKey}
>
{emojiAvatarPreview.emoji}
</span>
</div>
) : (
<ProfileAvatar
avatarUrl={avatarUrlDraft || null}
className="h-full w-full rounded-full text-5xl"
iconClassName="h-14 w-14"
label={resolvedName}
testId="profile-avatar-preview"
/>
)}
</div>
<div
className={avatarEditShellClassName}
data-testid="profile-avatar-edit-shell"
>
<button
aria-expanded={isAvatarEditorOpen}
aria-label="Edit profile photo"
className={avatarEditButtonClassName}
data-testid="profile-avatar-edit"
onClick={openAvatarEditor}
title="Edit profile photo"
type="button"
>
<Pencil className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative min-w-0 w-full">
<div
className={readOnlyContentMotionClassName}
data-testid="profile-readonly-content"
inert={isAvatarEditorOpen ? true : undefined}
>
<div className="space-y-12">
<div
className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55"
data-testid="profile-metadata-card"
>
<div className="flex min-h-14 items-center justify-between gap-4 px-4 py-3">
<h3 className="text-sm font-medium">Profile info</h3>
<EditProfileMetadataButton
disabled={updateProfileMutation.isPending}
isEditing={isEditingProfileMetadata}
label="profile info"
onClick={handleProfileMetadataEdit}
testId="profile-metadata-edit"
/>
</div>
<div className="flex min-h-16 items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<label
className="block text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
{isEditingProfileMetadata ? (
<Input
className="h-auto border-0 bg-transparent px-0 py-0 text-sm text-muted-foreground shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) =>
setDisplayNameDraft(event.target.value)
}
placeholder="Display name"
ref={displayNameInputRef}
value={displayNameDraft}
/>
) : (
<p
className="min-w-0 truncate text-sm text-muted-foreground"
data-testid="profile-display-name-value"
title={displayNameDraft || "Not set"}
>
{displayNameDraft || "Not set"}
</p>
)}
</div>
</div>
<div className="flex min-h-16 items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<label
className="block text-sm font-medium"
htmlFor="profile-about"
>
Profile description
</label>
{isEditingProfileMetadata ? (
<Textarea
className="min-h-[72px] resize-none border-0 bg-transparent px-0 py-0 text-sm leading-6 text-muted-foreground shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) =>
setAboutDraft(event.target.value)
}
placeholder="Profile description"
ref={aboutTextareaRef}
value={aboutDraft}
/>
) : (
<p
className={cn(
"min-w-0 break-words text-sm",
aboutDraft
? "text-muted-foreground"
: "text-muted-foreground/55",
)}
data-testid="profile-about-value"
title={aboutDraft || "Not set"}
>
{aboutDraft || "Not set"}
</p>
)}
</div>
</div>
</div>
<div>
<details
className="group overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs"
data-testid="profile-identity-card"
>
<summary
className="group/identity flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm transition-colors duration-150 ease-out hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden"
data-testid="profile-identity-toggle"
>
<div className="min-w-0">
<h3 className="text-sm font-medium">Identity</h3>
<p className="mt-1 text-sm font-normal text-muted-foreground">
Your keypair and NIP-05 handle are fixed for
this device.
</p>
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-[color,transform] duration-150 ease-out group-open:rotate-180 group-hover/identity:text-foreground group-focus-visible/identity:text-foreground" />
</summary>
<div
className="border-t border-border/55 divide-y divide-border/55"
data-testid="profile-identity-details"
>
<IdentityRow
copyValue={
profile?.pubkey ?? currentPubkey ?? undefined
}
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<IdentityRow
copyValue={profile?.nip05Handle ?? undefined}
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</details>
</div>
</div>
</div>
{shouldRenderAvatarEditor ? (
<div
className={cn(
"relative origin-top transition-[opacity,scale] duration-150 ease-out",
isAvatarEditorOpen
? "scale-100 opacity-100"
: "pointer-events-none scale-[0.94] opacity-0",
)}
data-testid="profile-avatar-editor-shell"
inert={isAvatarEditorOpen ? undefined : true}
className="relative h-48 w-48"
data-testid="profile-avatar-clip-frame"
>
<ProfileAvatarEditor
avatarUrl={avatarUrlDraft}
disabled={updateProfileMutation.isPending}
donePending={updateProfileMutation.isPending}
hiddenAvatarUrl={uploadedAvatarUrlDraft}
onDone={handleAvatarEditorDone}
onEmojiAvatarChange={animateEmojiAvatarChange}
onUploadedAvatarChange={setUploadedAvatarUrlDraft}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={(url) => setAvatarUrlDraft(url)}
previewName={resolvedName}
testIdPrefix="profile-avatar"
/>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
fill="none"
height="192"
viewBox="0 0 192 192"
width="192"
xmlns="http://www.w3.org/2000/svg"
>
<clipPath
clipPathUnits="userSpaceOnUse"
id={avatarEditClipId}
>
<path
clipRule="evenodd"
d="M100.734 83.3298C102.415 84.1574 104.616 83.8757 105.495 82.2207C109.647 74.3981 112 65.4738 112 56C112 25.0721 86.9279 0 56 0C25.0721 0 0 25.0721 0 56C0 86.9279 25.0721 112 56 112C65.4738 112 74.3981 109.647 82.2207 105.495C83.8757 104.616 84.1574 102.415 83.3298 100.734C82.4783 99.0047 82 97.0582 82 95C82 87.8203 87.8203 82 95 82C97.0582 82 99.0047 82.4783 100.734 83.3298Z"
fillRule="evenodd"
transform="translate(-34.5 -34.5) scale(2.1)"
/>
</clipPath>
</svg>
<div
className="relative h-full w-full"
data-testid="profile-avatar-preview-clip"
style={avatarClipStyle}
>
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="profile-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{shouldShowAnimatedPreview ? null : emojiAvatarPreview ? (
<div
aria-label={`${resolvedName} avatar`}
className="relative flex h-full w-full shrink-0 items-center justify-center overflow-hidden rounded-full shadow-xs"
data-testid="profile-avatar-preview"
role="img"
style={{
backgroundColor: emojiAvatarPreview.color,
}}
>
<span
className={cn(
"buzz-avatar-emoji-glyph flex h-full w-full items-center justify-center text-[6rem] leading-[100px]",
avatarSquishKey > 0 && "buzz-avatar-squish",
)}
data-testid="profile-avatar-preview-emoji"
key={avatarSquishKey}
>
{emojiAvatarPreview.emoji}
</span>
</div>
) : (
<ProfileAvatar
avatarUrl={avatarUrlDraft || null}
className="h-full w-full rounded-full text-5xl"
iconClassName="h-14 w-14"
label={resolvedName}
testId="profile-avatar-preview"
/>
)}
</div>
<div
className={avatarEditShellClassName}
data-testid="profile-avatar-edit-shell"
>
<button
aria-expanded={isAvatarEditorOpen}
aria-label={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
className={avatarEditButtonClassName}
data-testid="profile-avatar-edit"
disabled={isAvatarEditorSaving}
onClick={openAvatarEditor}
title={
isAvatarEditorSaving
? "Saving profile photo"
: "Edit profile photo"
}
type="button"
>
{isAvatarEditorSaving && !isAvatarEditorOpen ? (
<Spinner
aria-label="Saving avatar"
className="h-4 w-4"
/>
) : (
<Pencil className="h-4 w-4" />
)}
</button>
</div>
</div>
) : null}
</div>
</div>
<AnimatePresence initial={false} mode="wait">
{visibleAnimatedPreviewCaption ? (
<motion.p
animate={{ opacity: 1, y: 0 }}
className="w-48 text-center text-sm text-muted-foreground"
exit={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: -4 }
}
initial={
shouldReduceMotion
? { opacity: 0, y: 0 }
: { opacity: 0, y: 6 }
}
key={visibleAnimatedPreviewCaption}
transition={AVATAR_PREVIEW_CAPTION_TRANSITION}
>
{visibleAnimatedPreviewCaption}
</motion.p>
) : null}
</AnimatePresence>
</motion.div>
<motion.div
className="relative min-w-0 w-full"
layout="position"
transition={avatarEditorLayoutTransition}
>
<div
className={readOnlyContentMotionClassName}
data-testid="profile-readonly-content"
inert={isAvatarEditorOpen ? true : undefined}
>
<div className="space-y-12">
<div
className="overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55"
data-testid="profile-metadata-card"
>
<div className="flex min-h-14 items-center justify-between gap-4 px-4 py-3">
<h3 className="text-sm font-medium">
Profile info
</h3>
<EditProfileMetadataButton
disabled={updateProfileMutation.isPending}
isEditing={isEditingProfileMetadata}
label="profile info"
onClick={handleProfileMetadataEdit}
testId="profile-metadata-edit"
/>
</div>
<div className="flex min-h-16 items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<label
className="block text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
{isEditingProfileMetadata ? (
<Input
className="h-auto border-0 bg-transparent px-0 py-0 text-sm text-muted-foreground shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) =>
setDisplayNameDraft(event.target.value)
}
placeholder="Display name"
ref={displayNameInputRef}
value={displayNameDraft}
/>
) : (
<p
className="min-w-0 truncate text-sm text-muted-foreground"
data-testid="profile-display-name-value"
title={displayNameDraft || "Not set"}
>
{displayNameDraft || "Not set"}
</p>
)}
</div>
</div>
<div className="flex min-h-16 items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<label
className="block text-sm font-medium"
htmlFor="profile-about"
>
Profile description
</label>
{isEditingProfileMetadata ? (
<Textarea
className="min-h-[72px] resize-none border-0 bg-transparent px-0 py-0 text-sm leading-6 text-muted-foreground shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) =>
setAboutDraft(event.target.value)
}
placeholder="Profile description"
ref={aboutTextareaRef}
value={aboutDraft}
/>
) : (
<p
className={cn(
"min-w-0 break-words text-sm",
aboutDraft
? "text-muted-foreground"
: "text-muted-foreground/55",
)}
data-testid="profile-about-value"
title={aboutDraft || "Not set"}
>
{aboutDraft || "Not set"}
</p>
)}
</div>
</div>
</div>
<div>
<details
className="group overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs"
data-testid="profile-identity-card"
>
<summary
className="group/identity flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm transition-colors duration-150 ease-out hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden"
data-testid="profile-identity-toggle"
>
<div className="min-w-0">
<h3 className="text-sm font-medium">
Identity
</h3>
<p className="mt-1 text-sm font-normal text-muted-foreground">
Your keypair and NIP-05 handle are fixed for
this device.
</p>
</div>
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground transition-[color,transform] duration-150 ease-out group-open:rotate-180 group-hover/identity:text-foreground group-focus-visible/identity:text-foreground" />
</summary>
<div
className="border-t border-border/55 divide-y divide-border/55"
data-testid="profile-identity-details"
>
<IdentityRow
copyValue={
profile?.pubkey ?? currentPubkey ?? undefined
}
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<IdentityRow
copyValue={profile?.nip05Handle ?? undefined}
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</details>
</div>
</div>
</div>
{shouldRenderAvatarEditor ? (
<div
className={cn(
"relative origin-top transition-[opacity,scale] duration-200 ease-out will-change-[opacity,transform]",
isAvatarEditorOpen
? "scale-100 opacity-100"
: "pointer-events-none scale-[0.98] opacity-0",
isAvatarEditorFinishing ? "pointer-events-none" : "",
)}
aria-busy={isAvatarEditorSaving ? true : undefined}
data-testid="profile-avatar-editor-shell"
inert={isAvatarEditorOpen ? undefined : true}
>
<ProfileAvatarEditor
animatedPreviewContainer={animatedPreviewEl}
avatarUrl={avatarUrlDraft}
disabled={isAvatarEditorSaving}
donePending={isAvatarEditorSaving}
modeTabsContainer={avatarModeTabsEl}
onAnimatedPreviewActiveChange={
setIsAnimatedPreviewActive
}
onAnimatedPreviewCaptionChange={
setAnimatedPreviewCaption
}
onDone={handleAvatarEditorDone}
onEmojiAvatarChange={animateEmojiAvatarChange}
onUploadedAvatarChange={setUploadedAvatarUrlDraft}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={(url) => setAvatarUrlDraft(url)}
previewName={resolvedName}
testIdPrefix="profile-avatar"
/>
</div>
) : null}
</motion.div>
</motion.div>
</LayoutGroup>
{shouldShowSaveArea && !isAvatarEditorOpen ? (
<div className="mx-auto w-full max-w-[576px] space-y-2">

View File

@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildAnimatedAvatarUrl,
getAvatarSnapshotUrl,
parseAnimatedAvatarUrl,
} from "./animatedAvatar.ts";
const POSTER = "https://relay.example.com/media/aa.png";
const GIF = "https://relay.example.com/media/bb.gif";
// ── round trip ───────────────────────────────────────────────────────────────
test("build + parse round-trips poster and gif URLs", () => {
const url = buildAnimatedAvatarUrl(POSTER, GIF);
const parsed = parseAnimatedAvatarUrl(url);
assert.deepEqual(parsed, { posterUrl: POSTER, animationUrl: GIF });
});
test("build encodes the gif URL so the fragment has no reserved chars", () => {
const gif = "https://relay.example.com/media/bb.gif?x=1&y=2#frag";
const url = buildAnimatedAvatarUrl(POSTER, gif);
const fragment = url.slice(url.indexOf("#buzz-anim=") + "#buzz-anim=".length);
assert.ok(!fragment.includes("#"), "fragment should not contain raw #");
assert.ok(!fragment.includes("&"), "fragment should not contain raw &");
assert.equal(parseAnimatedAvatarUrl(url)?.animationUrl, gif);
});
// ── parse rejects non-animated URLs ──────────────────────────────────────────
test("parse returns null for plain image URLs", () => {
assert.equal(parseAnimatedAvatarUrl(POSTER), null);
});
test("parse returns null for null/undefined/empty", () => {
assert.equal(parseAnimatedAvatarUrl(null), null);
assert.equal(parseAnimatedAvatarUrl(undefined), null);
assert.equal(parseAnimatedAvatarUrl(""), null);
});
test("parse returns null for emoji data-url avatars", () => {
assert.equal(
parseAnimatedAvatarUrl("data:image/svg+xml,%3Csvg%3E%3C/svg%3E"),
null,
);
});
test("parse returns null when fragment marker has no poster prefix", () => {
assert.equal(parseAnimatedAvatarUrl(`#buzz-anim=${GIF}`), null);
});
test("parse returns null when gif part is empty", () => {
assert.equal(parseAnimatedAvatarUrl(`${POSTER}#buzz-anim=`), null);
});
test("parse returns null when gif part is not an http(s) URL", () => {
assert.equal(
parseAnimatedAvatarUrl(`${POSTER}#buzz-anim=javascript%3Aalert(1)`),
null,
);
});
test("parse returns null when poster part is not an http(s) URL", () => {
assert.equal(
parseAnimatedAvatarUrl(
`data:image/png;base64,xx#buzz-anim=${encodeURIComponent(GIF)}`,
),
null,
);
});
test("parse returns null on malformed percent-encoding", () => {
assert.equal(parseAnimatedAvatarUrl(`${POSTER}#buzz-anim=%E0%A4%A`), null);
});
// -- snapshot URL -------------------------------------------------------------
test("getAvatarSnapshotUrl strips animated avatars to their poster URL", () => {
assert.equal(
getAvatarSnapshotUrl(buildAnimatedAvatarUrl(POSTER, GIF)),
POSTER,
);
});
test("getAvatarSnapshotUrl preserves plain avatar URLs", () => {
assert.equal(getAvatarSnapshotUrl(POSTER), POSTER);
});
test("getAvatarSnapshotUrl returns null for nullish avatar URLs", () => {
assert.equal(getAvatarSnapshotUrl(null), null);
assert.equal(getAvatarSnapshotUrl(undefined), null);
});

View File

@@ -0,0 +1,77 @@
/**
* Animated avatar URL scheme.
*
* An animated avatar is persisted in the kind-0 `picture` field as a single
* string so it round-trips through any Nostr client:
*
* <posterUrl>#buzz-anim=<encodeURIComponent(animationUrl)>
*
* The poster is a static image (the frame the user picked) and the fragment
* carries the animation (an animated PNG) to play on hover. Clients that
* don't understand the scheme load the whole string as an image URL — the
* fragment is never sent over HTTP, so they simply render the poster.
*/
const ANIMATED_AVATAR_SEPARATOR = "#buzz-anim=";
export type AnimatedAvatarDescriptor = {
/** Static image shown when the avatar is idle. */
posterUrl: string;
/** Animated image played while the avatar is hovered. */
animationUrl: string;
};
export function buildAnimatedAvatarUrl(
posterUrl: string,
animationUrl: string,
): string {
return `${posterUrl}${ANIMATED_AVATAR_SEPARATOR}${encodeURIComponent(animationUrl)}`;
}
export function parseAnimatedAvatarUrl(
url: string | null | undefined,
): AnimatedAvatarDescriptor | null {
if (!url) {
return null;
}
const separatorIndex = url.indexOf(ANIMATED_AVATAR_SEPARATOR);
if (separatorIndex <= 0) {
return null;
}
const posterUrl = url.slice(0, separatorIndex);
const encodedAnimationUrl = url.slice(
separatorIndex + ANIMATED_AVATAR_SEPARATOR.length,
);
if (encodedAnimationUrl.length === 0) {
return null;
}
let animationUrl: string;
try {
animationUrl = decodeURIComponent(encodedAnimationUrl);
} catch {
return null;
}
if (!isHttpUrl(posterUrl) || !isHttpUrl(animationUrl)) {
return null;
}
return { animationUrl, posterUrl };
}
export function getAvatarSnapshotUrl(
url: string | null | undefined,
): string | null {
if (!url) {
return null;
}
return parseAnimatedAvatarUrl(url)?.posterUrl ?? url;
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}

View File

@@ -1,9 +1,13 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
export function performSidebarDefaultHaptic() {
export function performDefaultHaptic() {
if (!isTauri()) {
return;
}
void invoke("perform_sidebar_default_haptic").catch(() => {});
}
export function performSidebarDefaultHaptic() {
performDefaultHaptic();
}

View File

@@ -1446,6 +1446,152 @@
box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
}
/* Animated avatar framing slider: a local DialKit-style control. */
.buzz-avatar-framing-slider-wrapper {
position: relative;
flex: 1;
}
.buzz-avatar-framing-slider {
--buzz-avatar-framing-slider-fill: 50%;
--buzz-avatar-framing-slider-fill-color: color-mix(
in oklab,
hsl(var(--foreground)) 10.5%,
hsl(var(--background))
);
position: relative;
height: 48px;
width: 100%;
overflow: visible;
border: 1px solid hsl(var(--foreground) / 0.08);
border-radius: 8px;
background: hsl(var(--background));
cursor: pointer;
touch-action: none;
user-select: none;
}
.buzz-avatar-framing-slider:focus-visible {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.42);
}
.buzz-avatar-framing-slider-hashmarks {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.buzz-avatar-framing-slider-hashmark {
position: absolute;
top: 50%;
padding: 0;
height: 4px;
width: 4px;
border: 0;
border-radius: 9999px;
background: hsl(var(--foreground) / 0.14);
transform: translate(-50%, -50%);
transition: background 180ms ease;
}
.buzz-avatar-framing-slider-hashmark[data-reset="true"] {
top: 24px;
z-index: 3;
height: 4px;
width: 4px;
background: transparent;
cursor: pointer;
pointer-events: auto;
}
.buzz-avatar-framing-slider[data-active="true"]
.buzz-avatar-framing-slider-hashmark[data-reset="true"],
.buzz-avatar-framing-slider-hashmark[data-reset="true"]:focus-visible {
background: transparent;
}
.buzz-avatar-framing-slider-hashmark[data-reset="true"]:focus-visible {
outline: none;
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
}
.buzz-avatar-framing-slider-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: var(--buzz-avatar-framing-slider-fill);
border-bottom-left-radius: inherit;
border-bottom-right-radius: 0;
border-top-left-radius: inherit;
border-top-right-radius: 0;
background: var(--buzz-avatar-framing-slider-fill-color);
pointer-events: none;
transition: background 150ms ease;
}
.buzz-avatar-framing-slider[data-active="true"]
.buzz-avatar-framing-slider-fill {
background: var(--buzz-avatar-framing-slider-fill-color);
}
.buzz-avatar-framing-slider-handle {
position: absolute;
top: 50%;
left: clamp(5px, var(--buzz-avatar-framing-slider-fill), calc(100% - 5px));
z-index: 2;
width: 3px;
height: calc(100% + 8px);
border-radius: 9999px;
background: hsl(var(--foreground));
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scaleX(0.25);
transition:
opacity 150ms ease,
transform 180ms ease;
}
.buzz-avatar-framing-slider[data-active="true"]
.buzz-avatar-framing-slider-handle {
opacity: 1;
transform: translate(-50%, -50%) scaleX(1);
}
.buzz-avatar-framing-slider-tip {
position: relative;
z-index: 10;
margin: 8px auto 0;
width: 100%;
max-width: none;
color: hsl(var(--muted-foreground) / 0.72);
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
opacity: 0;
pointer-events: none;
transform: translateY(-3px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
.buzz-avatar-framing-slider-tip[data-visible="true"] {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.buzz-avatar-framing-slider-tip {
transform: none;
transition: opacity 150ms ease;
}
}
/* ── Video review overlay theme (shared/ui/VideoPlayer) ─────────────────
The review dialog uses the shadcn "neutral" dark palette regardless of
the app theme. Applied together with `.dark` on the portal root; this

View File

@@ -1,3 +1,6 @@
import * as React from "react";
import { parseAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -29,16 +32,31 @@ export function UserAvatar({
testId,
}: UserAvatarProps) {
const initials = getInitials(displayName);
// Animated avatars show their static poster frame until hovered, then play
// the animation.
const animated = parseAnimatedAvatarUrl(avatarUrl);
const [isHovered, setIsHovered] = React.useState(false);
const src = animated
? rewriteRelayUrl(isHovered ? animated.animationUrl : animated.posterUrl)
: avatarUrl
? rewriteRelayUrl(avatarUrl)
: null;
return (
<Avatar className={cn(sizeClasses[size], "shadow-xs", className)}>
{avatarUrl ? (
<Avatar
// Animated avatars carry their own backdrop disc and transparent
// surroundings — any container fill would flatten the pop-out.
className={cn(sizeClasses[size], !animated && "shadow-xs", className)}
onMouseEnter={animated ? () => setIsHovered(true) : undefined}
onMouseLeave={animated ? () => setIsHovered(false) : undefined}
>
{src ? (
<AvatarImage
alt={`${displayName} avatar`}
className="bg-secondary object-cover"
className={cn("object-cover", !animated && "bg-secondary")}
data-testid={testId ? `${testId}-image` : undefined}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
src={src}
/>
) : null}
<AvatarFallback

19
desktop/src/upng-js.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module "upng-js" {
const UPNG: {
/**
* Encode RGBA frames as a (possibly animated) PNG.
*
* @param imgs - one RGBA8 ArrayBuffer per frame
* @param cnum - color count for lossy quantization; 0 = lossless
* @param dels - per-frame delays in milliseconds (animated when > 1 frame)
*/
encode(
imgs: ArrayBuffer[],
width: number,
height: number,
cnum: number,
dels?: number[],
): ArrayBuffer;
};
export default UPNG;
}

View File

@@ -0,0 +1,466 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const SHOTS = "test-results/animated-avatar";
/**
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
* moving circle) so the camera phases work headless and deterministically —
* Playwright's bundled headless shell has no media capture support.
*/
function installFakeCamera(
page: import("@playwright/test").Page,
options: { cameraDelayMs?: number; holdCamera?: boolean } = {},
) {
return page.addInitScript(
(cameraOptions) => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
const context = canvas.getContext("2d");
let hue = 0;
setInterval(() => {
if (!context) {
return;
}
hue = (hue + 7) % 360;
context.fillStyle = `hsl(${hue} 80% 60%)`;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(
canvas.width / 2 + Math.sin(hue / 30) * 60,
canvas.height / 2,
90,
0,
Math.PI * 2,
);
context.fill();
}, 90);
const stream = canvas.captureStream(15);
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: mediaDevices,
});
}
const devices: MediaDeviceInfo[] = [
{
deviceId: "builtin-camera",
groupId: "mac",
kind: "videoinput",
label: "FaceTime HD Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
{
deviceId: "iphone-continuity",
groupId: "iphone",
kind: "videoinput",
label: "Kenny's iPhone Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
];
const testWindow = window as Window & {
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
};
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
let releaseCamera: (() => void) | null = null;
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
releaseCamera?.();
releaseCamera = null;
};
Object.defineProperty(mediaDevices, "enumerateDevices", {
configurable: true,
value: () => Promise.resolve(devices),
});
Object.defineProperty(mediaDevices, "addEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "removeEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async (constraints: MediaStreamConstraints) => {
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
if (cameraOptions.holdCamera) {
await new Promise<void>((resolve) => {
releaseCamera = resolve;
});
} else if (cameraOptions.cameraDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, cameraOptions.cameraDelayMs),
);
}
return Promise.resolve(stream);
},
});
},
{
cameraDelayMs: options.cameraDelayMs ?? 0,
holdCamera: options.holdCamera ?? false,
},
);
}
// The review editor (preview + framing + poster strip + backdrop panel) is
// taller than the default 720px viewport — raise it so element screenshots
// capture the whole editor.
test.use({ viewport: { height: 1280, width: 1280 } });
const UPLOADED_PNG_DESCRIPTOR = {
sha256: "ab".repeat(32),
size: 4096,
type: "image/png",
uploaded: 1700000000,
url: `https://relay.example.com/media/${"ab".repeat(32)}.png`,
};
async function openAnimatedTab(
page: import("@playwright/test").Page,
options: { cameraDelayMs?: number; holdCamera?: boolean } = {},
) {
await installFakeCamera(page, options);
await installMockBridge(page, {
uploadDescriptors: [UPLOADED_PNG_DESCRIPTOR],
});
await page.goto("/");
await openSettings(page, "profile");
await page.getByTestId("profile-avatar-edit").click();
await page.getByRole("tab", { name: "Animated" }).click();
await expect(page.getByTestId("profile-avatar-animated")).toBeVisible();
// Let the tab indicator + content height animations settle.
await page.waitForTimeout(400);
}
test.describe("animated avatar screenshots", () => {
test("01 — animated tab idle state", async ({ page }) => {
await openAnimatedTab(page);
await expect(
page.getByTestId("profile-avatar-animated-camera-iphone"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-camera-computer"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-camera-iphone"),
).toHaveAttribute("aria-pressed", "false");
await expect(
page.getByTestId("profile-avatar-animated-camera-computer"),
).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("profile-avatar-animated-start")).toHaveCount(
0,
);
await expect(
page.getByTestId("profile-avatar-animated-camera-select"),
).toHaveCount(0);
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
await page
.getByTestId("settings-profile")
.screenshot({ path: `${SHOTS}/01-animated-tab-idle.png` });
});
test("02 — live camera preview", async ({ page }) => {
await openAnimatedTab(page, { holdCamera: true });
const animatedTabHeight = () =>
page
.getByTestId("profile-avatar-animated")
.evaluate((element) =>
Math.round(element.getBoundingClientRect().height),
);
const idleHeight = await animatedTabHeight();
await page.getByTestId("profile-avatar-animated-camera-iphone").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
}
).__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0,
),
)
.toBeGreaterThan(0);
const previewSlot = page.getByTestId(
"profile-avatar-animated-preview-slot",
);
await expect(
page.getByRole("status").filter({ hasText: "Starting camera" }),
).toBeVisible();
await expect(
previewSlot.getByRole("status").filter({ hasText: "Starting camera" }),
).toBeVisible();
await expect(page.getByTestId("profile-avatar-preview")).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toHaveCount(0);
const startingHeight = await animatedTabHeight();
expect(Math.abs(startingHeight - idleHeight)).toBeLessThanOrEqual(1);
await page.evaluate(() => {
(
window as Window & {
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
}
).__BUZZ_E2E_RELEASE_CAMERA__?.();
});
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toBeVisible({ timeout: 10_000 });
await expect(
previewSlot.getByTestId("profile-avatar-animated-preview"),
).toBeVisible();
const liveHeight = await animatedTabHeight();
expect(Math.abs(liveHeight - idleHeight)).toBeLessThanOrEqual(1);
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
}
).__BUZZ_E2E_CAMERA_CONSTRAINTS__?.at(-1)?.video,
),
)
.toEqual(
expect.objectContaining({
deviceId: { exact: "iphone-continuity" },
facingMode: { ideal: "user" },
}),
);
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
await page.waitForTimeout(500);
await page
.getByTestId("settings-profile")
.screenshot({ path: `${SHOTS}/02-live-camera.png` });
await page.getByTestId("profile-avatar-animated-record").click();
await expect(
page.getByTestId("profile-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(
page.getByTestId("profile-avatar-animated-size"),
).toHaveAttribute("aria-valuenow", "116");
});
test("03 — recording ring, pop-out review, custom color", async ({
page,
}) => {
test.setTimeout(120_000);
await openAnimatedTab(page);
await page.getByTestId("profile-avatar-animated-camera-computer").click();
await expect(
page.getByTestId("profile-avatar-animated-record"),
).toBeVisible({ timeout: 10_000 });
await page.getByTestId("profile-avatar-animated-record").click();
// Mid-recording: the timer stroke sweeps around the capture circle.
await expect(
page.getByTestId("profile-avatar-animated-timer-ring"),
).toBeVisible();
await page.waitForTimeout(1500);
await page.getByTestId("settings-profile").screenshot({
animations: "allow",
path: `${SHOTS}/03-recording-ring.png`,
});
// Review: the section icon row appears once processing finishes.
await expect(
page.getByTestId("profile-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(
page.getByTestId("profile-avatar-animated-size"),
).toHaveAttribute("aria-valuenow", "126");
await expect(page.getByTestId("profile-avatar-done")).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-framing-readout"),
).toHaveCount(0);
const previewSnapshot = () =>
page
.getByTestId("profile-avatar-animated-review-preview")
.evaluate((canvas) => (canvas as HTMLCanvasElement).toDataURL());
// Color section: pick a deterministic backdrop color (the default is random).
await page.getByTestId("profile-avatar-animated-section-color").click();
await expect(
page.getByTestId("profile-avatar-animated-backdrop-none"),
).toHaveCount(0);
await page
.getByTestId("profile-avatar-animated-backdrop-grid")
.locator("button")
.nth(5)
.click();
// Poster section: selecting a frame updates the (paused) preview.
await page.getByTestId("profile-avatar-animated-section-poster").click();
await expect(
page.getByTestId("profile-avatar-animated-poster-strip"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-poster-selector"),
).toBeVisible();
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveText("Pick the still shown before hover.");
await expect(
page.getByTestId("profile-avatar-animated-poster-prev"),
).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-poster-next"),
).toHaveCount(0);
const scrubber = page.getByTestId(
"profile-avatar-animated-poster-scrubber",
);
const scrubberBox = await scrubber.boundingBox();
if (!scrubberBox) {
throw new Error("Animated avatar poster scrubber did not render bounds.");
}
await scrubber.click({
position: {
x: scrubberBox.width * 0.72,
y: scrubberBox.height / 2,
},
});
await expect(scrubber).not.toHaveAttribute("aria-valuenow", "0");
await page.getByTestId("settings-profile").screenshot({
animations: "allow",
path: `${SHOTS}/06-poster-picker.png`,
});
// The Circle tool stays implemented, but it is hidden in the review row for now.
await expect(
page.getByTestId("profile-avatar-animated-section-shape"),
).toHaveCount(0);
// You section: the DialKit-style local slider re-composes the preview.
await page.getByTestId("profile-avatar-animated-section-person").click();
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveCount(0);
await expect(
page.getByTestId("profile-avatar-animated-outline-toggle"),
).toHaveAttribute("aria-pressed", "true");
await page.getByTestId("profile-avatar-animated-outline-toggle").click();
await expect(
page.getByTestId("profile-avatar-animated-outline-toggle"),
).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("profile-avatar-animated-outline-toggle").click();
const sizeSlider = page.getByTestId("profile-avatar-animated-size");
const initialSizeValue = await sizeSlider.getAttribute("aria-valuenow");
const sizeSliderBox = await sizeSlider.boundingBox();
if (!sizeSliderBox) {
throw new Error("Animated avatar size slider did not render bounds.");
}
await sizeSlider.click({
position: {
x: sizeSliderBox.width * 0.25,
y: sizeSliderBox.height / 2,
},
});
await expect(sizeSlider).not.toHaveAttribute(
"aria-valuenow",
initialSizeValue ?? "",
);
// Restore defaults so the screenshot shows the standard framing.
await page.getByTestId("profile-avatar-animated-reset-framing").click();
await page.waitForTimeout(400);
await page.getByTestId("settings-profile").screenshot({
animations: "allow",
path: `${SHOTS}/04-review-popout-backdrop.png`,
});
// Custom backdrop color panel (shared HSV picker).
await page.getByTestId("profile-avatar-animated-section-color").click();
await page.getByTestId("profile-avatar-animated-backdrop-custom").click();
await expect(
page.getByTestId("profile-avatar-animated-custom-color-spectrum"),
).toBeVisible();
await expect(page.getByTestId("profile-avatar-done")).toHaveCount(0);
const beforeCustomColor = await previewSnapshot();
const customColorSpectrum = page.getByTestId(
"profile-avatar-animated-custom-color-spectrum",
);
const customColorSpectrumBox = await customColorSpectrum.boundingBox();
if (!customColorSpectrumBox) {
throw new Error(
"Animated avatar custom color picker did not render bounds.",
);
}
await customColorSpectrum.click({
position: {
x: customColorSpectrumBox.width * 0.88,
y: customColorSpectrumBox.height * 0.82,
},
});
await page.waitForTimeout(400);
const customColorPreview = await previewSnapshot();
expect(customColorPreview).not.toEqual(beforeCustomColor);
await page.getByTestId("settings-profile").screenshot({
animations: "allow",
path: `${SHOTS}/05-custom-backdrop-color.png`,
});
await page.getByTestId("profile-avatar-animated-custom-color-done").click();
await expect(
page.getByTestId("profile-avatar-animated-backdrop-custom"),
).toHaveAttribute("aria-pressed", "true");
expect(await previewSnapshot()).toEqual(customColorPreview);
// Done applies the recording (APNG encode + mocked two-file upload) and
// then saves the profile in one step.
const doneButton = page.getByTestId("profile-avatar-done");
await doneButton.click();
await expect(doneButton).toContainText("Saving", { timeout: 2_000 });
await expect(page.getByRole("tab", { name: "Image" })).toBeDisabled();
await expect(page.getByRole("tab", { name: "Emoji" })).toBeDisabled();
await expect(page.getByRole("tab", { name: "Animated" })).toBeDisabled();
await expect
.poll(
() =>
page.evaluate(
() =>
(
window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }
).__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "upload_media_bytes",
).length ?? 0,
),
{ timeout: 30_000 },
)
.toBeGreaterThanOrEqual(2);
await expect
.poll(
() =>
page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
),
{ timeout: 30_000 },
)
.toEqual(expect.arrayContaining(["update_profile"]));
await expect(page.getByTestId("profile-avatar-animated-error")).toHaveCount(
0,
);
await expect(page.getByTestId("profile-avatar-preview")).toBeVisible({
timeout: 10_000,
});
});
});

View File

@@ -25,10 +25,6 @@ const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};
const FIRST_RUN_KENNY = {
...TEST_IDENTITIES.tyler,
username: "Kenny QA",
};
type TestIdentity = {
privateKey: string;
@@ -513,27 +509,6 @@ async function expectWelcomeGuideIntro(
}
}
async function getMockProfile(page: Page) {
return page.evaluate(async () => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
return (await invoke("get_profile")) as {
avatar_url: string | null;
display_name: string | null;
};
});
}
async function expectIncompleteOnboarding(page: Page) {
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expectShellHidden(page);
@@ -931,7 +906,10 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
buffer: Buffer.from("png bytes"),
});
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue(url);
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
await expect(
page.getByTestId("onboarding-avatar-preview-fallback"),
).toHaveText("MQ");
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});

View File

@@ -147,13 +147,14 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await page.getByTestId("profile-avatar-edit").click();
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
await page.getByTestId("profile-avatar-done").click();
await waitForAvatarEditorToClose(page);
await expect(page.getByTestId("profile-display-name-value")).toHaveText(
displayName,
);
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
await page.getByTestId("profile-avatar-edit").click();
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
await page.getByTestId("profile-avatar-done").click();
await expandIdentity(page);
@@ -168,7 +169,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await expandIdentity(page);
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
await page.getByTestId("profile-avatar-edit").click();
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
await expect(page.getByTestId("profile-about-value")).toHaveText(about);
});
@@ -271,6 +272,51 @@ test("nests the avatar edit button in a clipped notch", async ({ page }) => {
expect(transitionProperty).toContain("scale");
});
test("swaps the avatar preview and mode tabs while editing", async ({
page,
}) => {
await page.goto("/");
await openSettings(page, "profile");
const previewFrame = page.getByTestId("profile-avatar-clip-frame");
const closedPreviewBox = await previewFrame.boundingBox();
if (!closedPreviewBox) {
throw new Error("Profile avatar preview did not render bounds.");
}
await page.getByTestId("profile-avatar-edit").click();
const tabList = page.getByRole("tablist", { name: "Avatar type" });
await expect(tabList).toBeVisible();
await expect(page.getByTestId("profile-avatar-mode-tabs-slot")).toBeVisible();
await page.waitForTimeout(350);
const openPreviewBox = await previewFrame.boundingBox();
const tabListBox = await tabList.boundingBox();
if (!openPreviewBox || !tabListBox) {
throw new Error("Profile avatar edit layout did not render bounds.");
}
const closedPreviewCenterY = closedPreviewBox.y + closedPreviewBox.height / 2;
const tabListCenterY = tabListBox.y + tabListBox.height / 2;
expect(Math.abs(tabListCenterY - closedPreviewCenterY)).toBeLessThan(16);
const tabListBottomY = tabListBox.y + tabListBox.height;
const segmentToPreviewGap = openPreviewBox.y - tabListBottomY;
expect(segmentToPreviewGap).toBeGreaterThan(48);
expect(segmentToPreviewGap).toBeLessThan(72);
expect(openPreviewBox.y).toBeGreaterThan(closedPreviewCenterY + 72);
await page.getByTestId("profile-avatar-done").click();
await waitForAvatarEditorToClose(page);
await expect(tabList).toHaveCount(0);
const restoredPreviewBox = await previewFrame.boundingBox();
if (!restoredPreviewBox) {
throw new Error("Profile avatar preview did not restore bounds.");
}
expect(Math.abs(restoredPreviewBox.y - closedPreviewBox.y)).toBeLessThan(8);
});
test("highlights the avatar drop target while dragging an image", async ({
page,
}) => {
@@ -387,9 +433,7 @@ test("uploads local profile avatar files before saving", async ({ page }) => {
await page.getByTestId("profile-avatar-done").click();
await waitForAvatarEditorToClose(page);
await page.getByTestId("profile-avatar-edit").click();
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(
pastedAvatarUrl,
);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
await page.getByTestId("profile-avatar-url").fill("");
await page.getByTestId("profile-avatar-done").click();
await expect(
@@ -397,9 +441,7 @@ test("uploads local profile avatar files before saving", async ({ page }) => {
).toHaveCount(1);
await waitForAvatarEditorToClose(page);
await page.getByTestId("profile-avatar-edit").click();
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(
pastedAvatarUrl,
);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
await expect
.poll(() =>
@@ -451,9 +493,7 @@ test("reveals emoji background colors only after choosing an emoji", async ({
await waitForAvatarEditorToClose(page);
await page.getByTestId("profile-avatar-edit").click();
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(
imageAvatarUrl,
);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue("");
await page.getByRole("tab", { name: "Emoji" }).click();
const colorGridShell = page.getByTestId("profile-avatar-color-grid-shell");

22
pnpm-lock.yaml generated
View File

@@ -36,6 +36,9 @@ importers:
'@emoji-mart/react':
specifier: ^1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@19.2.6)
'@mediapipe/tasks-vision':
specifier: ^0.10.35
version: 0.10.35
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -168,6 +171,9 @@ importers:
tiptap-markdown:
specifier: ^0.9.0
version: 0.9.0(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))
upng-js:
specifier: ^2.1.0
version: 2.1.0
yaml:
specifier: ^2.8.3
version: 2.9.0
@@ -539,6 +545,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mediapipe/tasks-vision@0.10.35':
resolution: {integrity: sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -1306,6 +1315,7 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
@@ -1446,12 +1456,14 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
@@ -1608,6 +1620,7 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -2935,6 +2948,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
upng-js@2.1.0:
resolution: {integrity: sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -3286,6 +3302,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mediapipe/tasks-vision@0.10.35': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -5831,6 +5849,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
upng-js@2.1.0:
dependencies:
pako: 1.0.11
use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.6):
dependencies:
react: 19.2.6