export const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [ 'image/webp', 'image/gif', 'image/jpeg' ] as const; export const PROFILE_AVATAR_ACCEPT_ATTRIBUTE = '.webp,.gif,.jpg,.jpeg,image/webp,image/gif,image/jpeg'; export const PROFILE_AVATAR_OUTPUT_SIZE = 256; export const PROFILE_AVATAR_EDITOR_FRAME_SIZE = 224; export const PROFILE_AVATAR_MIN_ZOOM = 1; export const PROFILE_AVATAR_MAX_ZOOM = 4; export interface ProfileAvatarDimensions { width: number; height: number; } export interface EditableProfileAvatarSource extends ProfileAvatarDimensions { file: File; objectUrl: string; mime: string; name: string; preservesAnimation: boolean; } export interface ProfileAvatarTransform { zoom: number; offsetX: number; offsetY: number; } export interface ProfileAvatarUpdates { avatarUrl: string; avatarHash: string; avatarMime: string; avatarUpdatedAt: number; } export interface ProcessedProfileAvatar extends ProfileAvatarUpdates, ProfileAvatarDimensions { base64: string; blob: Blob; } export function resolveProfileAvatarStorageFileName(mime: string | null | undefined): string { switch (mime?.toLowerCase()) { case 'image/gif': return 'profile.gif'; case 'image/jpeg': case 'image/jpg': return 'profile.jpg'; default: return 'profile.webp'; } } export function clampProfileAvatarZoom(zoom: number): number { if (!Number.isFinite(zoom)) { return PROFILE_AVATAR_MIN_ZOOM; } return Math.min(Math.max(zoom, PROFILE_AVATAR_MIN_ZOOM), PROFILE_AVATAR_MAX_ZOOM); } export function resolveProfileAvatarBaseScale( source: ProfileAvatarDimensions, frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE ): number { return Math.max(frameSize / source.width, frameSize / source.height); } export function clampProfileAvatarTransform( source: ProfileAvatarDimensions, transform: ProfileAvatarTransform, frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE ): ProfileAvatarTransform { const zoom = clampProfileAvatarZoom(transform.zoom); const renderedWidth = source.width * resolveProfileAvatarBaseScale(source, frameSize) * zoom; const renderedHeight = source.height * resolveProfileAvatarBaseScale(source, frameSize) * zoom; const maxOffsetX = Math.max(0, (renderedWidth - frameSize) / 2); const maxOffsetY = Math.max(0, (renderedHeight - frameSize) / 2); return { zoom, offsetX: clampOffset(transform.offsetX, maxOffsetX), offsetY: clampOffset(transform.offsetY, maxOffsetY) }; } function clampOffset(value: number, maxMagnitude: number): number { if (!Number.isFinite(value)) { return 0; } const nextValue = Math.min(Math.max(value, -maxMagnitude), maxMagnitude); return Object.is(nextValue, -0) ? 0 : nextValue; }