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