279 lines
8.2 KiB
TypeScript
279 lines
8.2 KiB
TypeScript
const PUZZLE_REFERENCE_IMAGE_MAX_EDGE = 1024;
|
||
const PUZZLE_REFERENCE_IMAGE_COMPRESS_TRIGGER_BYTES = 1536 * 1024;
|
||
export const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 6 * 1024 * 1024;
|
||
export const PUZZLE_REFERENCE_IMAGE_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
||
const PUZZLE_REFERENCE_IMAGE_SQUARE_TOLERANCE = 1;
|
||
|
||
export function formatPuzzleReferenceImageUploadBytes(bytes: number) {
|
||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||
}
|
||
|
||
export function buildPuzzleReferenceImageTooLargeMessage(actualBytes: number) {
|
||
return `参考图过大,请压缩后再上传(当前 ${formatPuzzleReferenceImageUploadBytes(actualBytes)},最多 6MB)。`;
|
||
}
|
||
|
||
export function validatePuzzleReferenceImageFile(file: File) {
|
||
if (file.size <= 0) {
|
||
throw new Error('参考图文件为空,请重新选择。');
|
||
}
|
||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||
throw new Error(buildPuzzleReferenceImageTooLargeMessage(file.size));
|
||
}
|
||
if (file.type.trim() && !file.type.trim().startsWith('image/')) {
|
||
throw new Error('参考图必须是图片文件。');
|
||
}
|
||
}
|
||
|
||
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(buildPuzzleReferenceImageTooLargeMessage(dataUrl.length));
|
||
}
|
||
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) {
|
||
validatePuzzleReferenceImageFile(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> {
|
||
validatePuzzleReferenceImageFile(file);
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
|
||
export function puzzleReferenceImageDataUrlToFile(
|
||
dataUrl: string,
|
||
fileName = 'puzzle-reference.jpg',
|
||
) {
|
||
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
|
||
const mimeType =
|
||
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
|
||
const binary = atob(encoded);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
bytes[index] = binary.charCodeAt(index);
|
||
}
|
||
return new File([bytes], fileName, { type: mimeType });
|
||
}
|