64a2cec788
Task 20: services/worker package (@photo/worker, commonjs) with jest unit/e2e configs mirroring api conventions (root .env via test/load-env.ts, moduleNameMapper to @photo/contracts/db-cn/storage TS sources, maxWorkers 1, test/reset.ts = MySQL TRUNCATE + MinIO photo-live/dev prefix wipe) and the sharp processing pipeline runPipeline(): metadata whitelist gate (jpeg/png/webp/avif/heif/tiff, else PipelineError UNSUPPORTED_TYPE), decode failures mapped to PipelineError DECODE_FAILED, no-arg .rotate() auto-orientation (EXIF 5-8 swaps stored width/height), thumb <=480w q75 and browse <=1920 q82 JPEG variants with withoutEnlargement, and no withMetadata so outputs carry no EXIF/GPS at all. sharp 0.35 prebuilt loads script-free under pnpm 10; its win32 libvips lacks a HEVC encoder, so the HEVC-HEIF unit case self-skips with a console note (G2 verifies with real iPhone samples) while the AV1-HEIF case covers the same libheif decode path / 'heif' format bucket. Co-Authored-By: Claude Code <noreply@anthropic.com>
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
// 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<string> = new Set([
|
||
'jpeg',
|
||
'png',
|
||
'webp',
|
||
'avif',
|
||
'heif',
|
||
'tiff',
|
||
]);
|
||
|
||
export async function runPipeline(input: Buffer): Promise<PipelineResult> {
|
||
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,
|
||
};
|
||
}
|