241 lines
6.8 KiB
TypeScript
241 lines
6.8 KiB
TypeScript
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
|
|
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
|
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
|
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
|
|
|
|
type PuzzleReferenceImageSize = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export type PuzzleReferenceImageReadResult = PuzzleReferenceImageSize & {
|
|
dataUrl: string;
|
|
};
|
|
|
|
export type PuzzleReferenceImageCropParams = {
|
|
source: string;
|
|
cropX: number;
|
|
cropY: number;
|
|
cropSize: number;
|
|
};
|
|
|
|
function readFileAsDataUrl(file: File) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
|
reader.onload = () => {
|
|
if (typeof reader.result !== 'string') {
|
|
reject(new Error('参考图读取失败,请重试。'));
|
|
return;
|
|
}
|
|
resolve(reader.result);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function ensureReferenceImageWithinLimit(dataUrl: string) {
|
|
if (dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
|
throw new Error('参考图过大,请换一张尺寸更小的图片。');
|
|
}
|
|
return dataUrl;
|
|
}
|
|
|
|
function loadReferenceImage(dataUrl: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
|
image.src = dataUrl;
|
|
});
|
|
}
|
|
|
|
function resolveCompressedImageSize(
|
|
image: HTMLImageElement,
|
|
): PuzzleReferenceImageSize {
|
|
const sourceWidth = image.naturalWidth || image.width;
|
|
const sourceHeight = image.naturalHeight || image.height;
|
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
|
throw new Error('参考图读取失败,请重试。');
|
|
}
|
|
|
|
const scale = Math.min(
|
|
1,
|
|
PUZZLE_REFERENCE_IMAGE_MAX_EDGE / Math.max(sourceWidth, sourceHeight),
|
|
);
|
|
return {
|
|
width: Math.max(1, Math.round(sourceWidth * scale)),
|
|
height: Math.max(1, Math.round(sourceHeight * scale)),
|
|
};
|
|
}
|
|
|
|
function resolveReferenceImageNaturalSize(
|
|
image: HTMLImageElement,
|
|
): PuzzleReferenceImageSize {
|
|
const width = image.naturalWidth || image.width;
|
|
const height = image.naturalHeight || image.height;
|
|
if (width <= 0 || height <= 0) {
|
|
throw new Error('拼图图片读取失败,请重试。');
|
|
}
|
|
|
|
return { width, height };
|
|
}
|
|
|
|
export function isPuzzleReferenceImageSquare(size: PuzzleReferenceImageSize) {
|
|
return (
|
|
Math.abs(Math.round(size.width) - Math.round(size.height)) <=
|
|
PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE
|
|
);
|
|
}
|
|
|
|
function shouldCompressReferenceImage(file: File, dataUrl: string) {
|
|
return (
|
|
file.size > PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES ||
|
|
dataUrl.length > PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH
|
|
);
|
|
}
|
|
|
|
async function compressReferenceImageDataUrl(file: File, dataUrl: string) {
|
|
if (
|
|
typeof document === 'undefined' ||
|
|
typeof Image === 'undefined' ||
|
|
!shouldCompressReferenceImage(file, dataUrl)
|
|
) {
|
|
return dataUrl;
|
|
}
|
|
|
|
const image = await loadReferenceImage(dataUrl);
|
|
const size = resolveCompressedImageSize(image);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = size.width;
|
|
canvas.height = size.height;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
return dataUrl;
|
|
}
|
|
|
|
// 中文注释:参考图只作为生成提示,不需要保留手机原图体积;压到单边 1024 内更容易稳定进入 VectorEngine 参考图分支。
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = 'high';
|
|
context.fillStyle = '#ffffff';
|
|
context.fillRect(0, 0, size.width, size.height);
|
|
context.drawImage(image, 0, 0, size.width, size.height);
|
|
|
|
const candidates = [0.84, 0.76, 0.68].map((quality) =>
|
|
canvas.toDataURL('image/jpeg', quality),
|
|
);
|
|
return candidates.reduce((best, current) =>
|
|
current.length < best.length ? current : best,
|
|
);
|
|
}
|
|
|
|
export async function readPuzzleReferenceImageAsDataUrl(file: File) {
|
|
const dataUrl = await readFileAsDataUrl(file);
|
|
try {
|
|
const compressedDataUrl = await compressReferenceImageDataUrl(
|
|
file,
|
|
dataUrl,
|
|
);
|
|
return ensureReferenceImageWithinLimit(
|
|
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
|
|
);
|
|
} catch (error) {
|
|
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
|
return dataUrl;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function readPuzzleReferenceImageForUpload(
|
|
file: File,
|
|
): Promise<PuzzleReferenceImageReadResult> {
|
|
const dataUrl = await readFileAsDataUrl(file);
|
|
const image = await loadReferenceImage(dataUrl);
|
|
const size = resolveReferenceImageNaturalSize(image);
|
|
|
|
if (!isPuzzleReferenceImageSquare(size)) {
|
|
return {
|
|
dataUrl,
|
|
...size,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const compressedDataUrl = await compressReferenceImageDataUrl(file, dataUrl);
|
|
return {
|
|
dataUrl: ensureReferenceImageWithinLimit(
|
|
compressedDataUrl.length < dataUrl.length ? compressedDataUrl : dataUrl,
|
|
),
|
|
...size,
|
|
};
|
|
} catch (error) {
|
|
if (dataUrl.length <= PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH) {
|
|
return {
|
|
dataUrl,
|
|
...size,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function cropPuzzleReferenceImageDataUrl({
|
|
source,
|
|
cropX,
|
|
cropY,
|
|
cropSize,
|
|
}: PuzzleReferenceImageCropParams) {
|
|
const image = await loadReferenceImage(source);
|
|
const sourceSize = resolveReferenceImageNaturalSize(image);
|
|
const normalizedCropSize = Math.max(
|
|
1,
|
|
Math.min(sourceSize.width, sourceSize.height, Math.round(cropSize)),
|
|
);
|
|
const normalizedCropX = Math.max(
|
|
0,
|
|
Math.min(sourceSize.width - normalizedCropSize, Math.round(cropX)),
|
|
);
|
|
const normalizedCropY = Math.max(
|
|
0,
|
|
Math.min(sourceSize.height - normalizedCropSize, Math.round(cropY)),
|
|
);
|
|
const outputSize = Math.max(
|
|
1,
|
|
Math.min(PUZZLE_REFERENCE_IMAGE_MAX_EDGE, normalizedCropSize),
|
|
);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = outputSize;
|
|
canvas.height = outputSize;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) {
|
|
throw new Error('拼图图片裁剪失败,请重试。');
|
|
}
|
|
|
|
// 中文注释:拼图棋盘固定按 1:1 切块,非正方形上传图必须先裁成正方形再进入草稿链路。
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = 'high';
|
|
context.fillStyle = '#ffffff';
|
|
context.fillRect(0, 0, outputSize, outputSize);
|
|
context.drawImage(
|
|
image,
|
|
normalizedCropX,
|
|
normalizedCropY,
|
|
normalizedCropSize,
|
|
normalizedCropSize,
|
|
0,
|
|
0,
|
|
outputSize,
|
|
outputSize,
|
|
);
|
|
|
|
const candidates = [0.88, 0.8, 0.72].map((quality) =>
|
|
canvas.toDataURL('image/jpeg', quality),
|
|
);
|
|
return ensureReferenceImageWithinLimit(
|
|
candidates.reduce((best, current) =>
|
|
current.length < best.length ? current : best,
|
|
),
|
|
);
|
|
}
|