Files
Genarrative/src/components/image-editor/ImageCanvasUiAssetExtractionRasterModel.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

148 lines
4.0 KiB
TypeScript

import type { UiAssetExtractionMark } from './ImageCanvasUiAssetExtractionModel';
function assertCanvasSupport() {
if (typeof document === 'undefined') {
throw new Error('当前环境不支持图片编辑');
}
}
function loadImageElement(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('图片读取失败'));
image.src = source;
});
}
function createRasterCanvas(width: number, height: number) {
assertCanvasSupport();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('当前浏览器不支持图片编辑');
}
return { canvas, context };
}
function resolveImageSize(image: HTMLImageElement) {
return {
width: Math.max(1, image.naturalWidth || image.width || 1),
height: Math.max(1, image.naturalHeight || image.height || 1),
};
}
function prepareRedSelectionStroke(context: CanvasRenderingContext2D) {
context.strokeStyle = '#ff2b2b';
context.lineWidth = 6;
context.lineCap = 'round';
context.lineJoin = 'round';
}
function resolveMarkLabelPoint(mark: UiAssetExtractionMark) {
if (mark.tool === 'brush') {
return mark.points[0] ?? { x: 0, y: 0 };
}
return {
x: Math.min(mark.x, mark.x + mark.width),
y: Math.min(mark.y, mark.y + mark.height),
};
}
function drawRedSelectionIndexLabel(
context: CanvasRenderingContext2D,
mark: UiAssetExtractionMark,
index: number,
) {
const point = resolveMarkLabelPoint(mark);
const radius = 18;
const x = Math.max(
radius + 2,
Math.min(point.x, context.canvas.width - radius - 2),
);
const y = Math.max(
radius + 2,
Math.min(point.y, context.canvas.height - radius - 2),
);
context.save();
context.fillStyle = '#ff2b2b';
context.strokeStyle = '#ffffff';
context.lineWidth = 4;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = '#ffffff';
context.font = '900 24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(String(index + 1), x, y + 1);
context.restore();
}
export async function renderUiDesignAssetExtractionMarkedImage({
source,
marks,
showIndexLabels = false,
}: {
source: string;
marks: UiAssetExtractionMark[];
showIndexLabels?: boolean;
}) {
if (!marks.length) {
throw new Error('请至少框选一个素材');
}
const image = await loadImageElement(source);
const { width, height } = resolveImageSize(image);
const { canvas, context } = createRasterCanvas(width, height);
context.drawImage(image, 0, 0, width, height);
prepareRedSelectionStroke(context);
for (const mark of marks) {
context.beginPath();
if (mark.tool === 'rect') {
context.rect(
Math.min(mark.x, mark.x + mark.width),
Math.min(mark.y, mark.y + mark.height),
Math.abs(mark.width),
Math.abs(mark.height),
);
} else if (mark.tool === 'ellipse') {
const width = Math.abs(mark.width);
const height = Math.abs(mark.height);
context.ellipse(
Math.min(mark.x, mark.x + mark.width) + width / 2,
Math.min(mark.y, mark.y + mark.height) + height / 2,
width / 2,
height / 2,
0,
0,
Math.PI * 2,
);
} else if (mark.tool === 'brush') {
const [firstPoint, ...restPoints] = mark.points;
if (!firstPoint) {
continue;
}
context.moveTo(firstPoint.x, firstPoint.y);
restPoints.forEach((point) => {
context.lineTo(point.x, point.y);
});
context.stroke();
continue;
}
context.stroke();
}
if (showIndexLabels) {
marks.forEach((mark, index) =>
drawRedSelectionIndexLabel(context, mark, index),
);
}
return canvas.toDataURL('image/png');
}