Files
Genarrative/src/services/puzzleReferenceImage.ts
T
2026-05-10 22:20:54 +08:00

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