裁剪属于像素级操作,需要先把图片画到 canvas 再导出。云端图片地址通常不带 CORS 头,canvas 会被标记为跨域,toDataURL 直接抛 SecurityError;而 loadImage 只挂了 onload、没有 onerror,加载失败时 Promise 永久挂起。两者叠加后,用户 侧的表现都是「点击确认裁剪没有任何反应」,控制台之外看不到原因。 - loadImage 补充 onerror,避免加载失败时 Promise 永不落地 - 仅对非 data:/blob: 的地址设置 crossOrigin=anonymous,本地资源设了反而 可能取不到像素 - 新增 resolveCroppableImageSource():优先用本地媒体缓存 Blob 构造同源 objectURL,取不到再回退原始地址,并在 finally 中 revoke - cropImageNode 补充 try/catch 与失败提示,让错误可见而不是静默失败 无外部行为变化,仅修复失败路径与错误反馈。
183 lines
7.4 KiB
TypeScript
183 lines
7.4 KiB
TypeScript
export type ImageCropRect = {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export type ImageAngleTransform = {
|
||
horizontalAngle: number;
|
||
pitchAngle: number;
|
||
cameraDistance: number;
|
||
wideAngle: boolean;
|
||
};
|
||
|
||
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
|
||
|
||
export const MAX_UPSCALE_LONG_EDGE = 4096;
|
||
|
||
export type ImageUpscaleParams = {
|
||
targetLongEdge: number;
|
||
algorithm: ImageUpscaleAlgorithm;
|
||
};
|
||
|
||
export type ImageSplitParams = {
|
||
rows: number;
|
||
columns: number;
|
||
};
|
||
|
||
export type ImageSplitPiece = {
|
||
row: number;
|
||
column: number;
|
||
dataUrl: string;
|
||
};
|
||
|
||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||
const image = await loadImage(dataUrl);
|
||
if (crop) {
|
||
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
|
||
}
|
||
const size = Math.min(image.width, image.height);
|
||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||
const sy = Math.max(0, Math.floor((image.height - size) / 2));
|
||
return drawCrop(image, sx, sy, size, size);
|
||
}
|
||
|
||
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
|
||
const image = await loadImage(dataUrl);
|
||
const rows = Math.max(1, Math.floor(params.rows));
|
||
const columns = Math.max(1, Math.floor(params.columns));
|
||
const pieces: ImageSplitPiece[] = [];
|
||
|
||
for (let row = 0; row < rows; row += 1) {
|
||
const sy = Math.floor((row * image.height) / rows);
|
||
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
|
||
for (let column = 0; column < columns; column += 1) {
|
||
const sx = Math.floor((column * image.width) / columns);
|
||
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
|
||
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
|
||
}
|
||
}
|
||
|
||
return pieces;
|
||
}
|
||
|
||
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
|
||
const image = await loadImage(dataUrl);
|
||
const canvas = document.createElement("canvas");
|
||
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
|
||
canvas.width = image.width + padding * 2;
|
||
canvas.height = image.height + padding * 2;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return dataUrl;
|
||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const horizontal = params.horizontalAngle / 60;
|
||
const pitch = params.pitchAngle / 45;
|
||
const distanceScale = 1.12 - params.cameraDistance * 0.035;
|
||
const wideScale = params.wideAngle ? 0.88 : 1;
|
||
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
|
||
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
|
||
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
const skewX = horizontal * image.width * 0.18;
|
||
const skewY = pitch * image.height * 0.12;
|
||
const x = cx - width / 2 + horizontal * padding * 0.5;
|
||
const y = cy - height / 2 + pitch * padding * 0.45;
|
||
|
||
context.save();
|
||
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
|
||
context.drawImage(image, x + skewX, y + skewY, width, height);
|
||
context.restore();
|
||
|
||
if (params.wideAngle) {
|
||
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
|
||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
|
||
context.fillStyle = gradient;
|
||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
return canvas.toDataURL("image/png");
|
||
}
|
||
|
||
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
|
||
const image = await loadImage(dataUrl);
|
||
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
|
||
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
|
||
}
|
||
|
||
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
|
||
const longEdge = Math.max(1, width, height);
|
||
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
|
||
const scale = target / longEdge;
|
||
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
||
}
|
||
|
||
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, sw);
|
||
canvas.height = Math.max(1, sh);
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return image.src;
|
||
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||
return canvas.toDataURL("image/png");
|
||
}
|
||
|
||
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
|
||
let source: CanvasImageSource = image;
|
||
let sourceWidth = image.width;
|
||
let sourceHeight = image.height;
|
||
|
||
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
|
||
const nextWidth = sourceWidth * 2;
|
||
const nextHeight = sourceHeight * 2;
|
||
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
|
||
source = next;
|
||
sourceWidth = nextWidth;
|
||
sourceHeight = nextHeight;
|
||
}
|
||
|
||
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
|
||
}
|
||
|
||
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
|
||
}
|
||
|
||
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return canvas;
|
||
context.imageSmoothingEnabled = algorithm !== "nearest";
|
||
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
|
||
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
|
||
return canvas;
|
||
}
|
||
|
||
function loadImage(dataUrl: string) {
|
||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const image = new Image();
|
||
// 只有跨源 HTTP(S) 地址才需要声明匿名跨域;同源的 /api 资源不能
|
||
// 强制走 CORS,否则缺少 ACAO 响应头时反而会加载失败。
|
||
if (isCrossOriginHttpUrl(dataUrl)) image.crossOrigin = "anonymous";
|
||
image.onload = () => resolve(image);
|
||
// 缺少 onerror 时加载失败会让 Promise 永久挂起,调用方 await 后表现为「点击无反应」。
|
||
image.onerror = () => reject(new Error("图片加载失败,无法处理该图片"));
|
||
image.src = dataUrl;
|
||
});
|
||
}
|
||
|
||
function isCrossOriginHttpUrl(value: string) {
|
||
if (typeof window === "undefined") return false;
|
||
try {
|
||
const url = new URL(value, window.location.href);
|
||
return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== window.location.origin;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|