Files
Genarrative/src/services/puzzleReferenceImage.ts
2026-05-24 19:00:21 +08:00

279 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}