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((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((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 { 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 }); }