ee2cad377f
在图片工具栏接入完美像素交互、占位流程与历史保护 新增严格像素网格检测和登录态同步处理接口 补齐素材类型校验、持久化边界、并发限制及技术文档 修复 Windows rlib 门禁长文件名解析
234 lines
5.9 KiB
TypeScript
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);
|
|
}
|