2e1f4d7929
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.9 KiB
TypeScript
75 lines
2.9 KiB
TypeScript
import "server-only";
|
||
import sharp from "sharp";
|
||
import type { DetectedType } from "@/shared/attachments";
|
||
|
||
/** Ana görsel uzun kenar sınırı (fiş okunabilir kalsın, dosya küçülsün). */
|
||
export const MAX_IMAGE_EDGE = 2000;
|
||
export const THUMB_EDGE = 320;
|
||
/** Dekompresyon bombasına karşı piksel sınırı (~50 MP). */
|
||
const MAX_INPUT_PIXELS = 50_000_000;
|
||
|
||
export type ProcessedImage = {
|
||
main: Buffer;
|
||
thumb: Buffer;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export class UnreadableImageError extends Error {}
|
||
|
||
/**
|
||
* Görseli normalize eder: EXIF yönüne göre döndürür, en fazla 2000 px'e küçültür ve WebP'ye
|
||
* çevirir. sharp çıktıya meta veri yazmaz (withMetadata kullanılmadığı için EXIF/GPS/ICC dahil
|
||
* tüm meta veri düşer). HEIC (iPhone, HEVC) sharp'ın hazır derlemesinde çözülemediği için
|
||
* libheif (WASM) ile çözülür.
|
||
*/
|
||
export async function processImage(bytes: Buffer, type: DetectedType): Promise<ProcessedImage> {
|
||
try {
|
||
const source = type === "heic" ? await decodeHeic(bytes) : sharpFrom(bytes).rotate();
|
||
const { data, info } = await source
|
||
.resize({
|
||
width: MAX_IMAGE_EDGE,
|
||
height: MAX_IMAGE_EDGE,
|
||
fit: "inside",
|
||
withoutEnlargement: true,
|
||
})
|
||
.webp({ quality: 82 })
|
||
.toBuffer({ resolveWithObject: true });
|
||
const thumb = await sharp(data)
|
||
.resize({ width: THUMB_EDGE, height: THUMB_EDGE, fit: "inside", withoutEnlargement: true })
|
||
.webp({ quality: 70 })
|
||
.toBuffer();
|
||
return { main: data, thumb, width: info.width, height: info.height };
|
||
} catch (error) {
|
||
throw new UnreadableImageError(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
function sharpFrom(bytes: Buffer) {
|
||
return sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS });
|
||
}
|
||
|
||
async function decodeHeic(bytes: Buffer) {
|
||
// Bazı .heic dosyaları aslında AV1 (AVIF) içerir; sharp bunları doğrudan çözebilir.
|
||
try {
|
||
const meta = await sharpFrom(bytes).metadata();
|
||
if (meta.compression === "av1") return sharpFrom(bytes).rotate();
|
||
} catch {
|
||
// libheif ile denenecek.
|
||
}
|
||
const { default: decode } = await import("heic-decode");
|
||
const image = await decode({ buffer: bytes });
|
||
if (image.width * image.height > MAX_INPUT_PIXELS) throw new Error("Görsel çok büyük.");
|
||
// libheif yönlendirmeyi (irot/imir) çözerken uygular; ham RGBA piksellerden devam edilir.
|
||
return sharp(Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength), {
|
||
raw: { width: image.width, height: image.height, channels: 4 },
|
||
});
|
||
}
|
||
|
||
/** PDF'ler değiştirilmeden saklanır; yalnızca yapı kontrolü (başlık + dosya sonu işareti). */
|
||
export function isPlausiblePdf(bytes: Buffer): boolean {
|
||
if (bytes.subarray(0, 5).toString("latin1") !== "%PDF-") return false;
|
||
const tail = bytes.subarray(Math.max(0, bytes.length - 2048)).toString("latin1");
|
||
return tail.includes("%%EOF");
|
||
}
|