import { Injectable } from '@angular/core'; import { PROFILE_AVATAR_ALLOWED_MIME_TYPES, PROFILE_AVATAR_OUTPUT_SIZE, ProfileAvatarTransform, EditableProfileAvatarSource, ProcessedProfileAvatar, clampProfileAvatarTransform, PROFILE_AVATAR_EDITOR_FRAME_SIZE, resolveProfileAvatarBaseScale } from '../../domain/profile-avatar.models'; const PROFILE_AVATAR_OUTPUT_MIME = 'image/webp'; const PROFILE_AVATAR_OUTPUT_QUALITY = 0.92; export function isAnimatedGif(buffer: ArrayBuffer): boolean { const bytes = new Uint8Array(buffer); if (bytes.length < 13 || readAscii(bytes, 0, 6) !== 'GIF87a' && readAscii(bytes, 0, 6) !== 'GIF89a') { return false; } let offset = 13; if ((bytes[10] & 0x80) !== 0) { offset += 3 * (2 ** ((bytes[10] & 0x07) + 1)); } let frameCount = 0; while (offset < bytes.length) { const blockType = bytes[offset]; if (blockType === 0x3B) { return false; } if (blockType === 0x21) { offset += 2; while (offset < bytes.length) { const blockSize = bytes[offset++]; if (blockSize === 0) { break; } offset += blockSize; } continue; } if (blockType !== 0x2C || offset + 10 > bytes.length) { return false; } frameCount++; if (frameCount > 1) { return true; } const packedFields = bytes[offset + 9]; offset += 10; if ((packedFields & 0x80) !== 0) { offset += 3 * (2 ** ((packedFields & 0x07) + 1)); } offset += 1; while (offset < bytes.length) { const blockSize = bytes[offset++]; if (blockSize === 0) { break; } offset += blockSize; } } return false; } export function isAnimatedWebp(buffer: ArrayBuffer): boolean { const bytes = new Uint8Array(buffer); if (bytes.length < 16 || readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') { return false; } let offset = 12; while (offset + 8 <= bytes.length) { const chunkType = readAscii(bytes, offset, 4); const chunkSize = readUint32LittleEndian(bytes, offset + 4); if (chunkType === 'ANIM' || chunkType === 'ANMF') { return true; } if (chunkType === 'VP8X' && offset + 9 <= bytes.length) { const featureFlags = bytes[offset + 8]; if ((featureFlags & 0x02) !== 0) { return true; } } offset += 8 + chunkSize + (chunkSize % 2); } return false; } @Injectable({ providedIn: 'root' }) export class ProfileAvatarImageService { validateFile(file: File): string | null { const mimeType = file.type.toLowerCase(); const normalizedName = file.name.toLowerCase(); const isAllowedMime = PROFILE_AVATAR_ALLOWED_MIME_TYPES.includes(mimeType as typeof PROFILE_AVATAR_ALLOWED_MIME_TYPES[number]); const isAllowedExtension = normalizedName.endsWith('.webp') || normalizedName.endsWith('.gif') || normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg'); if (!isAllowedExtension || (mimeType && !isAllowedMime)) { return 'Invalid file type. Use WebP, GIF, JPG, or JPEG.'; } return null; } async prepareEditableSource(file: File): Promise { const objectUrl = URL.createObjectURL(file); const mime = this.resolveSourceMime(file); try { const [image, preservesAnimation] = await Promise.all([this.loadImage(objectUrl), this.detectAnimatedSource(file, mime)]); return { file, objectUrl, mime, name: file.name, width: image.naturalWidth, height: image.naturalHeight, preservesAnimation }; } catch (error) { URL.revokeObjectURL(objectUrl); throw error; } } releaseEditableSource(source: EditableProfileAvatarSource | null | undefined): void { if (!source?.objectUrl) { return; } URL.revokeObjectURL(source.objectUrl); } async processEditableSource( source: EditableProfileAvatarSource, transform: ProfileAvatarTransform ): Promise { if (source.preservesAnimation) { return this.processAnimatedSource(source); } const image = await this.loadImage(source.objectUrl); const canvas = document.createElement('canvas'); canvas.width = PROFILE_AVATAR_OUTPUT_SIZE; canvas.height = PROFILE_AVATAR_OUTPUT_SIZE; const context = canvas.getContext('2d'); if (!context) { throw new Error('Canvas not supported'); } const clampedTransform = clampProfileAvatarTransform(source, transform); const previewScale = resolveProfileAvatarBaseScale(source, PROFILE_AVATAR_EDITOR_FRAME_SIZE) * clampedTransform.zoom; const renderRatio = PROFILE_AVATAR_OUTPUT_SIZE / PROFILE_AVATAR_EDITOR_FRAME_SIZE; const drawWidth = image.naturalWidth * previewScale * renderRatio; const drawHeight = image.naturalHeight * previewScale * renderRatio; const drawX = (PROFILE_AVATAR_OUTPUT_SIZE - drawWidth) / 2 + clampedTransform.offsetX * renderRatio; const drawY = (PROFILE_AVATAR_OUTPUT_SIZE - drawHeight) / 2 + clampedTransform.offsetY * renderRatio; context.clearRect(0, 0, canvas.width, canvas.height); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.drawImage(image, drawX, drawY, drawWidth, drawHeight); const renderedBlob = await this.canvasToBlob(canvas, PROFILE_AVATAR_OUTPUT_MIME, PROFILE_AVATAR_OUTPUT_QUALITY); const compressedBlob = renderedBlob; const updatedAt = Date.now(); const dataUrl = await this.readBlobAsDataUrl(compressedBlob); const hash = await this.computeHash(compressedBlob); return { blob: compressedBlob, base64: dataUrl.split(',', 2)[1] ?? '', avatarUrl: dataUrl, avatarHash: hash, avatarMime: compressedBlob.type || PROFILE_AVATAR_OUTPUT_MIME, avatarUpdatedAt: updatedAt, width: PROFILE_AVATAR_OUTPUT_SIZE, height: PROFILE_AVATAR_OUTPUT_SIZE }; } private async processAnimatedSource(source: EditableProfileAvatarSource): Promise { const updatedAt = Date.now(); const dataUrl = await this.readBlobAsDataUrl(source.file); const hash = await this.computeHash(source.file); return { blob: source.file, base64: dataUrl.split(',', 2)[1] ?? '', avatarUrl: dataUrl, avatarHash: hash, avatarMime: source.mime || source.file.type || PROFILE_AVATAR_OUTPUT_MIME, avatarUpdatedAt: updatedAt, width: source.width, height: source.height }; } private async detectAnimatedSource(file: File, mime: string): Promise { if (mime !== 'image/gif' && mime !== 'image/webp') { return false; } const buffer = await file.arrayBuffer(); return mime === 'image/gif' ? isAnimatedGif(buffer) : isAnimatedWebp(buffer); } private resolveSourceMime(file: File): string { const mimeType = file.type.toLowerCase(); if (mimeType === 'image/jpg') { return 'image/jpeg'; } if (mimeType) { return mimeType; } const normalizedName = file.name.toLowerCase(); if (normalizedName.endsWith('.gif')) { return 'image/gif'; } if (normalizedName.endsWith('.jpg') || normalizedName.endsWith('.jpeg')) { return 'image/jpeg'; } if (normalizedName.endsWith('.webp')) { return 'image/webp'; } return PROFILE_AVATAR_OUTPUT_MIME; } private async computeHash(blob: Blob): Promise { const buffer = await blob.arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buffer); return Array.from(new Uint8Array(digest)) .map((value) => value.toString(16).padStart(2, '0')) .join(''); } private canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); return; } reject(new Error('Failed to render avatar image')); }, type, quality); }); } private readBlobAsDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result); return; } reject(new Error('Failed to encode avatar image')); }; reader.onerror = () => reject(reader.error ?? new Error('Failed to read avatar image')); reader.readAsDataURL(blob); }); } private loadImage(url: string): Promise { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Failed to load avatar image')); image.src = url; }); } } function readAscii(bytes: Uint8Array, offset: number, length: number): string { return String.fromCharCode(...bytes.slice(offset, offset + length)); } function readUint32LittleEndian(bytes: Uint8Array, offset: number): number { return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24); }