Files
Genarrative/src/components/image-editor/ImageCanvasRasterEditModel.ts
T
lhk229 ee2cad377f 新增画布完美像素功能
在图片工具栏接入完美像素交互、占位流程与历史保护

新增严格像素网格检测和登录态同步处理接口

补齐素材类型校验、持久化边界、并发限制及技术文档

修复 Windows rlib 门禁长文件名解析
2026-07-30 11:42:45 +00:00

234 lines
5.9 KiB
TypeScript

import {
type EditorBackgroundRemovalInput,
type EditorBackgroundRemovalResult,
type EditorPixelArtSnapInput,
type EditorPixelArtSnapResult,
removeEditorImageBackground,
snapEditorImageToPixelArt,
} from '../../services/image-editor/editorProjectClient';
export type CropExpandInsets = {
left: number;
top: number;
right: number;
bottom: number;
};
export type CropExpandGeometry = {
outputWidth: number;
outputHeight: number;
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
targetX: number;
targetY: number;
};
export type CropExpandFrame = {
x: number;
y: number;
width: number;
height: number;
};
export type RasterEditResult = {
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
taskId?: string | null;
elapsedMs?: number;
provider?: string;
};
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', { willReadFrequently: true });
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),
};
}
export function parseCropExpandInsetValue(value: string) {
const trimmedValue = value.trim();
if (!trimmedValue) {
return 0;
}
const parsedValue = Number.parseInt(trimmedValue, 10);
if (!Number.isFinite(parsedValue)) {
throw new Error('裁扩数值必须是整数');
}
return parsedValue;
}
export function buildCropExpandInsetsFromFrame({
layer,
frame,
}: {
layer: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
};
frame: CropExpandFrame;
}): CropExpandInsets {
const scaleX =
layer.width > 0 ? Math.max(1, layer.originalWidth) / layer.width : 1;
const scaleY =
layer.height > 0 ? Math.max(1, layer.originalHeight) / layer.height : 1;
const sourceRight = layer.x + layer.width;
const sourceBottom = layer.y + layer.height;
const frameRight = frame.x + frame.width;
const frameBottom = frame.y + frame.height;
return {
left: Math.round((layer.x - frame.x) * scaleX),
top: Math.round((layer.y - frame.y) * scaleY),
right: Math.round((frameRight - sourceRight) * scaleX),
bottom: Math.round((frameBottom - sourceBottom) * scaleY),
};
}
export function buildCropExpandGeometry({
width,
height,
insets,
}: {
width: number;
height: number;
insets: CropExpandInsets;
}): CropExpandGeometry {
const sourceWidth = Math.max(1, Math.round(width || 1));
const sourceHeight = Math.max(1, Math.round(height || 1));
const cropLeft = Math.max(0, -insets.left);
const cropTop = Math.max(0, -insets.top);
const cropRight = Math.max(0, -insets.right);
const cropBottom = Math.max(0, -insets.bottom);
const targetX = Math.max(0, insets.left);
const targetY = Math.max(0, insets.top);
const drawableWidth = sourceWidth - cropLeft - cropRight;
const drawableHeight = sourceHeight - cropTop - cropBottom;
if (drawableWidth <= 0 || drawableHeight <= 0) {
throw new Error('裁剪范围不能小于 1 像素');
}
return {
outputWidth: targetX + drawableWidth + Math.max(0, insets.right),
outputHeight: targetY + drawableHeight + Math.max(0, insets.bottom),
sourceX: cropLeft,
sourceY: cropTop,
sourceWidth: drawableWidth,
sourceHeight: drawableHeight,
targetX,
targetY,
};
}
export function buildCropExpandLayerPatch({
layerX,
layerY,
insets,
outputWidth,
outputHeight,
}: {
layerX: number;
layerY: number;
insets: CropExpandInsets;
outputWidth: number;
outputHeight: number;
}) {
return {
x: layerX - insets.left,
y: layerY - insets.top,
width: outputWidth,
height: outputHeight,
originalWidth: outputWidth,
originalHeight: outputHeight,
};
}
export async function renderCropExpandImage({
source,
insets,
}: {
source: string;
insets: CropExpandInsets;
}): Promise<RasterEditResult> {
const image = await loadImageElement(source);
const imageSize = resolveImageSize(image);
const geometry = buildCropExpandGeometry({
width: imageSize.width,
height: imageSize.height,
insets,
});
const { canvas, context } = createRasterCanvas(
geometry.outputWidth,
geometry.outputHeight,
);
// 中文注释:画布默认透明,扩大出来的新区域不填色,直接保留 alpha=0。
context.clearRect(0, 0, geometry.outputWidth, geometry.outputHeight);
context.drawImage(
image,
geometry.sourceX,
geometry.sourceY,
geometry.sourceWidth,
geometry.sourceHeight,
geometry.targetX,
geometry.targetY,
geometry.sourceWidth,
geometry.sourceHeight,
);
return {
imageSrc: canvas.toDataURL('image/png'),
width: geometry.outputWidth,
height: geometry.outputHeight,
};
}
export async function removeImageBackground(
input: string | EditorBackgroundRemovalInput,
): Promise<EditorBackgroundRemovalResult> {
return removeEditorImageBackground(
typeof input === 'string' ? { sourceImageSrc: input } : input,
);
}
export async function snapImageToPerfectPixels(
input: EditorPixelArtSnapInput,
): Promise<EditorPixelArtSnapResult> {
return snapEditorImageToPixelArt(input);
}