234 lines
6.6 KiB
TypeScript
234 lines
6.6 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import sharp from "sharp";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import {
|
|
clampNormalized,
|
|
type DetailUiSubjectAnalysis,
|
|
} from "@/lib/detail-ui-subject-analysis";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 30;
|
|
|
|
const ANALYZE_MAX_SIDE = 128;
|
|
const ANALYZE_FETCH_TIMEOUT_MS = 12000;
|
|
|
|
function percentile(values: number[], ratio: number) {
|
|
if (values.length === 0) return 0;
|
|
const sorted = [...values].sort((left, right) => left - right);
|
|
const index = Math.max(0, Math.min(sorted.length - 1, Math.floor(sorted.length * ratio)));
|
|
return sorted[index] || 0;
|
|
}
|
|
|
|
function buildSafeZones(params: {
|
|
subjectBox: { x: number; y: number; width: number; height: number };
|
|
}) {
|
|
const gap = 0.035;
|
|
const leftWidth = Math.max(0, params.subjectBox.x - gap);
|
|
const rightX = clampNormalized(params.subjectBox.x + params.subjectBox.width + gap);
|
|
const rightWidth = Math.max(0, 1 - rightX);
|
|
const topHeight = Math.max(0, params.subjectBox.y - gap);
|
|
const bottomY = clampNormalized(params.subjectBox.y + params.subjectBox.height + gap);
|
|
const bottomHeight = Math.max(0, 1 - bottomY);
|
|
|
|
return {
|
|
left:
|
|
leftWidth > 0.16
|
|
? {
|
|
x: 0,
|
|
y: 0,
|
|
width: leftWidth,
|
|
height: 1,
|
|
}
|
|
: null,
|
|
right:
|
|
rightWidth > 0.16
|
|
? {
|
|
x: rightX,
|
|
y: 0,
|
|
width: rightWidth,
|
|
height: 1,
|
|
}
|
|
: null,
|
|
top:
|
|
topHeight > 0.16
|
|
? {
|
|
x: 0,
|
|
y: 0,
|
|
width: 1,
|
|
height: topHeight,
|
|
}
|
|
: null,
|
|
bottom:
|
|
bottomHeight > 0.14
|
|
? {
|
|
x: 0,
|
|
y: bottomY,
|
|
width: 1,
|
|
height: bottomHeight,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function analyzeSubjectRegion(params: {
|
|
grayscale: Uint8Array;
|
|
width: number;
|
|
height: number;
|
|
}): DetailUiSubjectAnalysis {
|
|
const scores: number[] = [];
|
|
const scoreGrid = new Float32Array(params.width * params.height);
|
|
|
|
const pixelAt = (x: number, y: number) =>
|
|
params.grayscale[Math.max(0, Math.min(params.grayscale.length - 1, y * params.width + x))] || 0;
|
|
|
|
for (let y = 1; y < params.height - 1; y += 1) {
|
|
for (let x = 1; x < params.width - 1; x += 1) {
|
|
const gx =
|
|
-1 * pixelAt(x - 1, y - 1) +
|
|
1 * pixelAt(x + 1, y - 1) +
|
|
-2 * pixelAt(x - 1, y) +
|
|
2 * pixelAt(x + 1, y) +
|
|
-1 * pixelAt(x - 1, y + 1) +
|
|
1 * pixelAt(x + 1, y + 1);
|
|
const gy =
|
|
-1 * pixelAt(x - 1, y - 1) +
|
|
-2 * pixelAt(x, y - 1) +
|
|
-1 * pixelAt(x + 1, y - 1) +
|
|
1 * pixelAt(x - 1, y + 1) +
|
|
2 * pixelAt(x, y + 1) +
|
|
1 * pixelAt(x + 1, y + 1);
|
|
const edge = Math.sqrt(gx * gx + gy * gy);
|
|
|
|
const centerBiasX = 1 - Math.abs(x / params.width - 0.5) * 1.5;
|
|
const centerBiasY = 1 - Math.abs(y / params.height - 0.56) * 1.3;
|
|
const centerBias = Math.max(0.15, centerBiasX * centerBiasY);
|
|
const brightness = pixelAt(x, y) / 255;
|
|
const contrast = Math.abs(brightness - 0.5);
|
|
const score = edge * 0.72 + contrast * 60 + centerBias * 90;
|
|
|
|
const index = y * params.width + x;
|
|
scoreGrid[index] = score;
|
|
scores.push(score);
|
|
}
|
|
}
|
|
|
|
const threshold = percentile(scores, 0.84);
|
|
let minX = params.width;
|
|
let minY = params.height;
|
|
let maxX = 0;
|
|
let maxY = 0;
|
|
|
|
for (let y = 0; y < params.height; y += 1) {
|
|
for (let x = 0; x < params.width; x += 1) {
|
|
const score = scoreGrid[y * params.width + x];
|
|
if (score < threshold) continue;
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
|
|
if (minX >= maxX || minY >= maxY) {
|
|
minX = Math.round(params.width * 0.24);
|
|
maxX = Math.round(params.width * 0.78);
|
|
minY = Math.round(params.height * 0.18);
|
|
maxY = Math.round(params.height * 0.92);
|
|
}
|
|
|
|
const expandX = Math.round((maxX - minX) * 0.08);
|
|
const expandY = Math.round((maxY - minY) * 0.08);
|
|
minX = Math.max(0, minX - expandX);
|
|
minY = Math.max(0, minY - expandY);
|
|
maxX = Math.min(params.width - 1, maxX + expandX);
|
|
maxY = Math.min(params.height - 1, maxY + expandY);
|
|
|
|
const subjectBox = {
|
|
x: clampNormalized(minX / params.width),
|
|
y: clampNormalized(minY / params.height),
|
|
width: clampNormalized((maxX - minX) / params.width),
|
|
height: clampNormalized((maxY - minY) / params.height),
|
|
confidence: 0.72,
|
|
};
|
|
|
|
return {
|
|
subjectBox,
|
|
safeZones: buildSafeZones({ subjectBox }),
|
|
dominantHorizontal:
|
|
subjectBox.x + subjectBox.width / 2 < 0.42
|
|
? "left"
|
|
: subjectBox.x + subjectBox.width / 2 > 0.58
|
|
? "right"
|
|
: "center",
|
|
dominantVertical:
|
|
subjectBox.y + subjectBox.height / 2 < 0.42
|
|
? "top"
|
|
: subjectBox.y + subjectBox.height / 2 > 0.58
|
|
? "bottom"
|
|
: "center",
|
|
};
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const body = (await request.json()) as { imageUrl?: string };
|
|
const imageUrl = String(body.imageUrl || "").trim();
|
|
if (!/^https?:\/\//i.test(imageUrl)) {
|
|
return NextResponse.json({ success: false, error: "缺少图片地址" }, { status: 400 });
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), ANALYZE_FETCH_TIMEOUT_MS);
|
|
let buffer: Buffer;
|
|
try {
|
|
const response = await fetch(imageUrl, {
|
|
method: "GET",
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`图片拉取失败: HTTP ${response.status}`);
|
|
}
|
|
buffer = Buffer.from(await response.arrayBuffer());
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
const { data, info } = await sharp(buffer)
|
|
.rotate()
|
|
.resize({
|
|
width: ANALYZE_MAX_SIDE,
|
|
height: ANALYZE_MAX_SIDE,
|
|
fit: "inside",
|
|
withoutEnlargement: true,
|
|
})
|
|
.grayscale()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
const analysis = analyzeSubjectRegion({
|
|
grayscale: data,
|
|
width: info.width || ANALYZE_MAX_SIDE,
|
|
height: info.height || ANALYZE_MAX_SIDE,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: analysis,
|
|
});
|
|
} catch (error) {
|
|
console.error("[Canvas Detail Subject Analyze] Error:", error);
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "主体分析失败",
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|