feat(ai-image): AI生图开发
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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')));
|
||||
});
|
||||
Reference in New Issue
Block a user