Files
Toju/toju-app/src/app/domains/profile-avatar/infrastructure/services/profile-avatar-image.service.ts
2026-04-17 03:06:44 +02:00

336 lines
9.1 KiB
TypeScript

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<EditableProfileAvatarSource> {
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<ProcessedProfileAvatar> {
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<ProcessedProfileAvatar> {
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<boolean> {
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<string> {
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<Blob> {
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<string> {
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<HTMLImageElement> {
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);
}