feat(worker): sharp pipeline with orientation, variants, exif strip

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>
This commit is contained in:
浅念
2026-09-17 23:16:20 +08:00
parent f5f1855b58
commit 64a2cec788
10 changed files with 820 additions and 6 deletions
+113
View File
@@ -0,0 +1,113 @@
// 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,
};
}