// services/worker/src/pipeline.ts —— sharp 处理流水线(Task 20)。 // 输入原图 Buffer(jpeg/png/heic 等白名单格式),输出 thumb(≤480w, q75)与 // browse(≤1920w, q82)两个 JPEG 变体 + 原图尺寸。核心约定: // · .rotate() 无参 = 按 EXIF orientation 自动转正(orientation 5-8 交换宽高); // · 不调用 withMetadata → 输出不含任何 EXIF(含 GPS)——公开变体不携带拍摄 // 元数据(隐私),输出 metadata 的 orientation/exif 字段均为 undefined; // · resize withoutEnlargement → 小图不放大(300px 原图的 thumb/browse 均为原尺寸); // · 解码失败/尺寸缺失 → PipelineError('DECODE_FAILED')(processor 落 // photos.errorCode 并驱动 BullMQ 重试/终败);格式不在白名单 → // PipelineError('UNSUPPORTED_TYPE')(与 api UPLOAD_CONTENT_TYPES 对齐, // 以 sharp format 名表示;G2 真机 HEIC 样本验证后收口)。 import sharp from 'sharp'; import type { Metadata } from 'sharp'; export type PipelineErrorCode = 'DECODE_FAILED' | 'UNSUPPORTED_TYPE'; export class PipelineError extends Error { constructor( readonly code: PipelineErrorCode, message: string, ) { super(message); this.name = 'PipelineError'; } } export interface PipelineResult { thumb: Buffer; browse: Buffer; /** 原图(自动转向后)尺寸 → photos.width/height。 */ width: number; height: number; /** 变体实际尺寸(withoutEnlargement 下小于等于上限)→ photo_variants.width/height。 */ thumbWidth: number; thumbHeight: number; browseWidth: number; browseHeight: number; } export const THUMB_MAX_WIDTH = 480; export const BROWSE_MAX_WIDTH = 1920; /** 可解码格式白名单(sharp metadata.format 取值)。 */ const SUPPORTED_FORMATS: ReadonlySet = new Set([ 'jpeg', 'png', 'webp', 'avif', 'heif', 'tiff', ]); export async function runPipeline(input: Buffer): Promise { let meta: Metadata; try { meta = await sharp(input).metadata(); } catch (err) { // 头部/解码抛错一律 DECODE_FAILED(含魔数残缺、截断、随机字节拼接)。 throw new PipelineError( 'DECODE_FAILED', `原图解码失败: ${err instanceof Error ? err.message : String(err)}`, ); } if (meta.format === undefined || !SUPPORTED_FORMATS.has(meta.format)) { throw new PipelineError( 'UNSUPPORTED_TYPE', `不支持的图片格式: ${meta.format ?? 'unknown'}`, ); } if (meta.width === undefined || meta.height === undefined) { throw new PipelineError('DECODE_FAILED', '无法解析原图尺寸'); } // EXIF orientation 5-8(90°/270° 旋转):显示尺寸交换宽高;像素级转正由 // 下方 .rotate() 在变体输出时完成(输出无 EXIF,尺寸即转向后尺寸)。 const swap = (meta.orientation ?? 1) >= 5; const width = swap ? meta.height : meta.width; const height = swap ? meta.width : meta.height; let thumb: Buffer; let browse: Buffer; try { thumb = await sharp(input) .rotate() .resize({ width: THUMB_MAX_WIDTH, withoutEnlargement: true }) .jpeg({ quality: 75 }) .toBuffer(); browse = await sharp(input) .rotate() .resize({ width: BROWSE_MAX_WIDTH, withoutEnlargement: true }) .jpeg({ quality: 82 }) .toBuffer(); } catch (err) { throw new PipelineError( 'DECODE_FAILED', `变体生成失败: ${err instanceof Error ? err.message : String(err)}`, ); } // 变体尺寸以输出头部为准(精确反映 withoutEnlargement/转向结果)。 const [thumbMeta, browseMeta] = await Promise.all([ sharp(thumb).metadata(), sharp(browse).metadata(), ]); return { thumb, browse, width, height, thumbWidth: thumbMeta.width ?? 0, thumbHeight: thumbMeta.height ?? 0, browseWidth: browseMeta.width ?? 0, browseHeight: browseMeta.height ?? 0, }; }