Your Name 409770d386
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add subject-aware detail overlay layout
2026-03-22 03:00:56 +08:00

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