Files
浅念 64a2cec788 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>
2026-09-17 23:16:20 +08:00

114 lines
3.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}