336 lines
9.1 KiB
TypeScript
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);
|
|
}
|