227 lines
7.9 KiB
TypeScript
227 lines
7.9 KiB
TypeScript
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 },
|
||
);
|
||
}
|
||
}
|