Files
photo-live/services/worker/src/pipeline.spec.ts
T
浅念 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

155 lines
6.0 KiB
TypeScript
Raw 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.spec.ts —— runPipeline 单测(Task 20,真实 sharp 缓冲)。
// 图像 fixture 全部在测试内经 sharp 生成(无二进制入库):
// · EXIF orientation 用 withMetadata({ orientation }) 构造(sharp 写入 EXIF 标签,
// 像素不旋转——正好模拟相机旁路存储);
// · HEIC 用例分两层:HEVC 压缩 HEIF 的编码能力视 sharp 预编译二进制而定
// (当前 win32 预构建 libvips 缺 hevc 编码器 → 跳过并留 console 备注,G2 真机
// iPhone 样本验收);AV1 压缩 HEIF 走同一 libheif 解码路径与 'heif' 格式桶
// (sharp metadata.format 同为 heif),可稳定覆盖流水线的 HEIF 解码分支。
import { randomBytes } from 'node:crypto';
import sharp from 'sharp';
import { PipelineError, runPipeline } from './pipeline';
/** 生成纯色 JPEG(可选 EXIF orientation)。 */
async function makeJpeg(
width: number,
height: number,
orientation?: number,
): Promise<Buffer> {
let image = sharp({
create: { width, height, channels: 3, background: { r: 200, g: 100, b: 50 } },
});
if (orientation !== undefined) {
image = image.withMetadata({ orientation });
}
return image.jpeg({ quality: 85 }).toBuffer();
}
describe('runPipeline', () => {
test('合法 JPEG:thumb ≤480w / browse ≤1920w,双 jpeg,EXIF 全剥离', async () => {
const input = await makeJpeg(2400, 1600, 1); // 携带 EXIF(orientation 1)
const out = await runPipeline(input);
expect(out.width).toBe(2400);
expect(out.height).toBe(1600);
expect(Buffer.isBuffer(out.thumb)).toBe(true);
expect(Buffer.isBuffer(out.browse)).toBe(true);
const thumbMeta = await sharp(out.thumb).metadata();
expect(thumbMeta.format).toBe('jpeg');
expect(thumbMeta.width).toBe(480);
expect(thumbMeta.height).toBe(320);
const browseMeta = await sharp(out.browse).metadata();
expect(browseMeta.format).toBe('jpeg');
expect(browseMeta.width).toBe(1920);
expect(browseMeta.height).toBe(1280);
// EXIF 剥离(GPS 等全部元数据随 EXIF 段一并去除)。
expect(thumbMeta.orientation).toBeUndefined();
expect(thumbMeta.exif).toBeUndefined();
expect(browseMeta.orientation).toBeUndefined();
expect(browseMeta.exif).toBeUndefined();
});
test('orientation 6 JPEG:自动转正,宽高交换', async () => {
// 旁路存储 400x300 + orientation 6(顺时针 90°)→ 显示 300x400。
const input = await makeJpeg(400, 300, 6);
const out = await runPipeline(input);
expect(out.width).toBe(300);
expect(out.height).toBe(400);
// 像素真的转了:thumb(≤480 不放大)即 300x400。
const thumbMeta = await sharp(out.thumb).metadata();
expect(thumbMeta.width).toBe(300);
expect(thumbMeta.height).toBe(400);
// 输出不再携带 orientation(已物理转正)。
expect(thumbMeta.orientation).toBeUndefined();
});
test('损坏缓冲(jpeg 魔数 + 随机字节)→ PipelineError(DECODE_FAILED)', async () => {
const corrupt = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
randomBytes(8192),
]);
const err = await runPipeline(corrupt).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(PipelineError);
expect((err as PipelineError).code).toBe('DECODE_FAILED');
});
test('PNG 输入可处理,输出仍为 jpeg', async () => {
const input = await sharp({
create: { width: 640, height: 480, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
const out = await runPipeline(input);
expect(out.width).toBe(640);
expect(out.height).toBe(480);
expect((await sharp(out.thumb).metadata()).format).toBe('jpeg');
expect((await sharp(out.browse).metadata()).format).toBe('jpeg');
});
test('小图不放大(withoutEnlargement):300px 原图 thumb/browse 均原尺寸', async () => {
const input = await makeJpeg(300, 200);
const out = await runPipeline(input);
expect(out.thumbWidth).toBe(300);
expect(out.thumbHeight).toBe(200);
expect(out.browseWidth).toBe(300);
expect(out.browseHeight).toBe(200);
});
test('白名单外格式(gif)→ PipelineError(UNSUPPORTED_TYPE)', async () => {
const gif = await sharp({
create: { width: 24, height: 24, channels: 3, background: { r: 1, g: 2, b: 3 } },
})
.gif()
.toBuffer();
const err = await runPipeline(gif).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(PipelineError);
expect((err as PipelineError).code).toBe('UNSUPPORTED_TYPE');
});
test('HEIF(HVEC) 输入可解码出 jpeg 变体(缺 hevc 编码器则跳过,G2 真机验证)', async () => {
let heic: Buffer;
try {
heic = await sharp({
create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } },
})
.heif({ compression: 'hevc', quality: 50 })
.toBuffer();
} catch (err) {
// 预编译 libvips 不含 hevc 编码器:HEVC 压缩 HEIF(iPhone 默认)的端到端
// 验证留给 G2 真机样本;解码能力由下方 AV1-HEIF 用例覆盖同一 libheif 路径。
console.warn(
`[pipeline.spec] HEVC 编码不可用,跳过 HEVC 分支(G2 真机样本验证): ${
err instanceof Error ? err.message : String(err)
}`,
);
return;
}
const out = await runPipeline(heic);
expect(out.width).toBe(300);
expect(out.height).toBe(200);
expect((await sharp(out.browse).metadata()).format).toBe('jpeg');
});
test('HEIF(AV1) 输入(同一 heif 解码路径/格式桶)产出 jpeg 变体', async () => {
const avif = await sharp({
create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } },
})
.avif({ quality: 50 })
.toBuffer();
const out = await runPipeline(avif);
expect(out.width).toBe(300);
expect(out.height).toBe(200);
expect((await sharp(out.browse).metadata()).format).toBe('jpeg');
});
});