import assert from 'node:assert/strict'; import { test } from 'node:test'; import sharp from 'sharp'; import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { renderThreeFour, runAspectRatioDerivative } from '../src/media-aspect-ratio.ts'; const assertColorNear = (actual, expected) => { for (let channel = 0; channel < 3; channel++) assert.ok(Math.abs(actual[channel] - expected[channel]) <= 6, `${actual} 与 ${expected} 色差过大`); }; const readPixels = async (output) => { const { data, info } = await sharp(output).raw().toBuffer({ resolveWithObject: true }); assert.equal(info.width, 1200); assert.equal(info.height, 1600); assert.equal(info.channels, 3); return (x, y) => [...data.subarray((y * info.width + x) * 3, (y * info.width + x) * 3 + 3)]; }; test('正方形商品图等比缩放后,上下扩充处使用红色边缘色', async () => { const source = await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ff0000' }, }).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); assertColorNear(pixel(600, 0), [255, 0, 0]); assertColorNear(pixel(600, 800), [255, 0, 0]); assertColorNear(pixel(600, 1599), [255, 0, 0]); }); test('上下边缘颜色不同,各自填充对应的一侧', async () => { const source = await sharp({ create: { width: 400, height: 400, channels: 3, background: '#0000ff' }, }).composite([ { input: { create: { width: 400, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, { input: { create: { width: 400, height: 48, channels: 3, background: '#808080' } }, left: 0, top: 352 }, ]).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); assertColorNear(pixel(600, 0), [255, 0, 0]); assertColorNear(pixel(600, 800), [0, 0, 255]); assertColorNear(pixel(600, 1599), [128, 128, 128]); }); test('同一边缘有多种颜色时,接缝相接且远处不形成竖向条纹', async () => { const source = await sharp({ create: { width: 400, height: 400, channels: 3, background: '#808080' }, }).composite([ { input: { create: { width: 200, height: 48, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, { input: { create: { width: 200, height: 48, channels: 3, background: '#0000ff' } }, left: 200, top: 0 }, { input: { create: { width: 200, height: 48, channels: 3, background: '#ffff00' } }, left: 0, top: 352 }, { input: { create: { width: 200, height: 48, channels: 3, background: '#00ff00' } }, left: 200, top: 352 }, ]).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); for (const [x, topColor, bottomColor] of [ [300, [255, 0, 0], [255, 255, 0]], [900, [0, 0, 255], [0, 255, 0]], ]) { assertColorNear(pixel(x, 199), topColor); assertColorNear(pixel(x, 199), pixel(x, 200)); assertColorNear(pixel(x, 1400), pixel(x, 1399)); assertColorNear(pixel(x, 1400), bottomColor); } assertColorNear(pixel(300, 0), pixel(900, 0)); assertColorNear(pixel(300, 1599), pixel(900, 1599)); assertColorNear(pixel(300, 0), [128, 0, 128]); assertColorNear(pixel(300, 1599), [128, 255, 0]); }); test('竖长图片左右扩充处分别使用左绿右黄的边缘色', async () => { const source = await sharp({ create: { width: 300, height: 800, channels: 3, background: '#0000ff' }, }).composite([ { input: { create: { width: 36, height: 800, channels: 3, background: '#00ff00' } }, left: 0, top: 0 }, { input: { create: { width: 36, height: 800, channels: 3, background: '#ffff00' } }, left: 264, top: 0 }, ]).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); assertColorNear(pixel(0, 800), [0, 255, 0]); assertColorNear(pixel(600, 800), [0, 0, 255]); assertColorNear(pixel(1199, 800), [255, 255, 0]); }); test('左右边缘沿高度变色时,接缝相接且远处不形成横向条纹', async () => { const source = await sharp({ create: { width: 300, height: 800, channels: 3, background: '#808080' }, }).composite([ { input: { create: { width: 36, height: 400, channels: 3, background: '#00ff00' } }, left: 0, top: 0 }, { input: { create: { width: 36, height: 400, channels: 3, background: '#0000ff' } }, left: 0, top: 400 }, { input: { create: { width: 36, height: 400, channels: 3, background: '#ffff00' } }, left: 264, top: 0 }, { input: { create: { width: 36, height: 400, channels: 3, background: '#ff00ff' } }, left: 264, top: 400 }, ]).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); for (const [y, leftColor, rightColor] of [ [400, [0, 255, 0], [255, 255, 0]], [1200, [0, 0, 255], [255, 0, 255]], ]) { assertColorNear(pixel(299, y), leftColor); assertColorNear(pixel(299, y), pixel(300, y)); assertColorNear(pixel(900, y), pixel(899, y)); assertColorNear(pixel(900, y), rightColor); } assertColorNear(pixel(0, 400), pixel(0, 1200)); assertColorNear(pixel(1199, 400), pixel(1199, 1200)); assertColorNear(pixel(0, 400), [0, 128, 128]); assertColorNear(pixel(1199, 400), [255, 128, 128]); }); test('透明边缘以白底取色', async () => { const source = await sharp({ create: { width: 400, height: 400, channels: 4, background: '#00000000' }, }).composite([{ input: { create: { width: 200, height: 200, channels: 3, background: '#ff0000' }, }, left: 100, top: 100 }]).png().toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); assertColorNear(pixel(600, 0), [255, 255, 255]); assertColorNear(pixel(600, 800), [255, 0, 0]); assertColorNear(pixel(600, 1599), [255, 255, 255]); }); test('取色和留白遵循 EXIF 旋转后的显示方向', async () => { const source = await sharp({ create: { width: 400, height: 200, channels: 3, background: '#0000ff' }, }).composite([ { input: { create: { width: 400, height: 24, channels: 3, background: '#ff0000' } }, left: 0, top: 0 }, { input: { create: { width: 400, height: 24, channels: 3, background: '#808080' } }, left: 0, top: 176 }, ]).jpeg({ quality: 100 }).withMetadata({ orientation: 6 }).toBuffer(); const pixel = await readPixels(await renderThreeFour(source)); assertColorNear(pixel(0, 800), [128, 128, 128]); assertColorNear(pixel(1199, 800), [255, 0, 0]); }); test('拒绝不受支持的来源格式', async () => { const source = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#ff0000' }, }).gif().toBuffer(); await assert.rejects(renderThreeFour(source), /格式不受支持/); }); test('任务读取原图、写入独立派生素材,并完成持久化状态', async () => { const source = await sharp({ create: { width: 400, height: 400, channels: 3, background: '#ff0000' }, }).png().toBuffer(); const writes = []; const storage = { send: async (command) => { if (command instanceof GetObjectCommand) return { Body: (async function* () { yield source; })(), }; if (command instanceof PutObjectCommand) { writes.push(command.input); return {}; } throw new Error('未知存储操作'); } }; let status = 'queued'; let outputAssetId = null; const queries = []; const database = { workspaceTransaction: async (_workspaceId, work) => work({ query: async (sql, args) => { queries.push({ sql, args }); if (sql.includes('SELECT d.status')) return { rows: [{ status, storage_provider: 'local_minio', bucket: 'test-bucket', object_key: 'original.png', }] }; if (sql.includes("SET status = 'running'")) status = 'running'; if (sql.includes("SET status = 'succeeded'")) status = 'succeeded'; if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [] }; if (sql.includes('INSERT INTO atlas.media_assets')) return { rows: [{ id: 'output-asset' }] }; if (sql.includes("result = jsonb_build_object")) outputAssetId = args[1]; return { rows: [] }; }, }) }; const job = { workspaceId: 'workspace-id', payload: { derivative_id: 'derivative-id' }, }; await runAspectRatioDerivative(database, storage, job); assert.equal(status, 'succeeded'); assert.equal(outputAssetId, 'output-asset'); assert.equal(writes.length, 1); assert.equal(writes[0].Bucket, 'test-bucket'); assert.match(writes[0].Key, /^derivatives\/workspace-id\/three-four\/[a-f0-9]{64}\.jpg$/); assert.deepEqual(await sharp(writes[0].Body).metadata().then(({ width, height }) => [width, height]), [1200, 1600]); await runAspectRatioDerivative(database, storage, job); assert.equal(writes.length, 1); assert.ok(queries.some(({ sql }) => sql.includes('UPDATE atlas.operations'))); });