import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { generateCanvasImage } from "@/lib/canvas/generate-image"; import { db } from "@/lib/db"; import { getPointsSystemConfig, getSystemSetting } from "@/lib/db/queries"; import { mediaAsset } from "@/lib/db/schema"; import { createLogger } from "@/lib/observability/logger"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { VIDEO_CANVAS_FUNCTION_CONFIG_KEY, defaultVideoCanvasFunctionConfig, normalizeVideoCanvasFunctionConfig, } from "@/lib/video-canvas/config"; import { VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE } from "@/lib/video-canvas/subjects"; export const runtime = "nodejs"; export const maxDuration = 1200; const logger = createLogger("video-canvas-subject-view-completion"); const requestSchema = z.object({ primaryAssetId: z.string().uuid(), }); const SUBJECT_COMPLETION_VIEW_DIRECTIONS = [ "正面近景,突出面部与头部特征", "正面全身,完整展示服装与体态", "左前方四十五度全身视角", "右前方四十五度全身视角", "左侧面全身视角", "右侧面全身视角", "背面全身视角", "背面回首的全身视角", "轻微俯视的正面全身视角", ] as const; export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const parsed = requestSchema.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json( { success: false, error: parsed.error.issues[0]?.message || "请求参数无效" }, { status: 400 }, ); } const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY, }).catch(() => null); const config = setting?.value ? normalizeVideoCanvasFunctionConfig(setting.value) : defaultVideoCanvasFunctionConfig; const completionConfig = config.subjectLibrary.smartCompletion; if (!config.subjectLibrary.enabled || !completionConfig.enabled) { return NextResponse.json( { success: false, error: "智能补全已停用" }, { status: 403 }, ); } const [primaryAsset] = await db .select({ id: mediaAsset.id, originalUrl: mediaAsset.originalUrl, }) .from(mediaAsset) .where( and( eq(mediaAsset.id, parsed.data.primaryAssetId), eq(mediaAsset.userId, session.user.id), eq(mediaAsset.mediaType, "image"), eq(mediaAsset.status, "ready"), ), ) .limit(1); if (!primaryAsset) { return NextResponse.json( { success: false, error: "主参考图不存在或已失效" }, { status: 404 }, ); } const pointsConfig = await getPointsSystemConfig(); const pointsCharge = await prepareFeaturePointsCharge({ modelId: "video-canvas-subject-view-completion", note: "主体其他视角智能补全消耗积分", pointsConfig, reason: "video_canvas_subject_view_completion", unitCost: completionConfig.cost, userId: session.user.id, }); if (!pointsCharge.ok) { return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); } const referenceImage = new URL(primaryAsset.originalUrl, request.url).toString(); const selectedModel = completionConfig.model === "auto" ? config.defaultImageModel : completionConfig.model; const generatedCandidates = ( await Promise.all( Array.from({ length: completionConfig.candidateCount }, async (_, index) => { const viewDirection = SUBJECT_COMPLETION_VIEW_DIRECTIONS[ index % SUBJECT_COMPLETION_VIEW_DIRECTIONS.length ]; const candidatePrompt = `${completionConfig.prompt}\n本次只生成一张独立候选图。候选方向:${viewDirection}。不要生成拼图、九宫格、文字或边框。`; for (let attempt = 1; attempt <= 2; attempt += 1) { try { const result = await generateCanvasImage({ aspectRatio: completionConfig.aspectRatio, debugLabel: "video-canvas-subject-view-completion", debugMeta: { candidateIndex: index, candidateViewDirection: viewDirection, primaryAssetId: primaryAsset.id, subjectViewCompletion: true, }, imageCount: 1, imageSize: completionConfig.imageSize, model: selectedModel, ownerKey: `${session.user.id}:subject-view-${index}`, prompt: candidatePrompt, promptExtend: false, referenceImage, }); const image = result.images[0]; if (!image) throw new Error("模型未返回图片"); return { image, model: result.model, modelRef: result.modelRef, prompt: result.prompt, }; } catch (error) { logger.warn("subject completion candidate failed", { attempt, candidateIndex: index, error, }); } } return null; }), ) ).filter((candidate): candidate is NonNullable => Boolean(candidate)); if (generatedCandidates.length !== completionConfig.candidateCount) { return NextResponse.json( { success: false, error: `智能补全未能生成全部 ${completionConfig.candidateCount} 张候选图,请重试`, }, { status: 502 }, ); } const generatedRows = await db .insert(mediaAsset) .values( generatedCandidates.map((candidate, index) => ({ displayName: `主体补充视角 ${index + 1}`, mediaType: "image", metadata: { candidateIndex: index, generatedBy: "subject-smart-completion", primaryAssetId: primaryAsset.id, }, mimeType: "image/png", modelId: candidate.modelRef || candidate.model, normalizedUrl: candidate.image.originalUrl, originalUrl: candidate.image.originalUrl, previewUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl, prompt: candidate.prompt, rootAssetId: primaryAsset.id, sourceTable: "video_canvas_subject", sourceType: VIDEO_CANVAS_SUBJECT_UPLOAD_SOURCE_TYPE, status: "ready", thumbnailUrl: candidate.image.thumbnailUrl || candidate.image.originalUrl, userId: session.user.id, })), ) .returning({ assetId: mediaAsset.id, name: mediaAsset.displayName, url: mediaAsset.originalUrl, }); const primaryGeneratedCandidate = generatedCandidates[0]; await pointsCharge.consume({ modelId: primaryGeneratedCandidate.modelRef || primaryGeneratedCandidate.model, }).catch((error) => { logger.warn("subject completion points consumption failed", { error }); }); return NextResponse.json({ success: true, data: { candidates: generatedRows.map((item) => ({ assetId: item.assetId, mediaType: "image" as const, name: item.name || "补充视角", url: item.url, })), cost: pointsCharge.unitCost, maxSelected: completionConfig.maxSelected, model: primaryGeneratedCandidate.model, }, }); } catch (error) { logger.error("subject view completion failed", { error }); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "智能补全失败", }, { status: 500 }, ); } }