227 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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