55469a2198
Require an explicit red-box selection before extracting UI-design assets: add rectangular/ellipse/brush selection UI, render red-outline marks into a marked Data URL on the client, and update the extraction prompt to "仅提取被红色框框选的素材并整理成spritesheet". Add frontend overlay/raster models and tests to support the selection flow and adjust numerous image-editor tests and views to wire up the selection lifecycle. On the dev ops side, enhance scripts/dev to detect a local %LOCALAPPDATA%/Genarrative/ffmpeg/bin installation on Windows, prepend it to PATH and inject CHARACTER_ANIMATION_FFMPEG_PATH/FFPROBE_PATH (with tests), and export the helper. Server config now prefers GENARRATIVE_*-prefixed FFmpeg env vars and includes a Rust test to validate reading the prefixed variables. Update docs to describe the new UI extraction behavior and local FFmpeg handling.
142 lines
3.9 KiB
TypeScript
142 lines
3.9 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');
|
|
}
|