Files
Genarrative/src/components/image-editor/ImageCanvasUiAssetExtractionRasterModel.ts
T
JenkenB 55469a2198 UI extraction selection + local FFmpeg dev env
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.
2026-06-22 19:13:37 +08:00

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');
}