Your Name 27d44b3b77
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add configurable 3d model display aliases
2026-04-08 22:09:43 +08:00

310 lines
9.1 KiB
TypeScript

import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import {
disableWorldLabsProvider,
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
} from "@/lib/worldlabs/server";
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 60;
type WorldTaskMode = "text" | "image" | "multi-image" | "video";
type CreateWorldTaskRequest = {
mode?: WorldTaskMode;
providerId?: string;
displayName?: string;
prompt?: string;
model?: WorldLabsModel | string;
disableRecaption?: boolean;
isPano?: boolean;
makePublic?: boolean;
reconstructImages?: boolean;
mediaAssetId?: string;
mediaAssets?: Array<{
mediaAssetId?: string;
azimuth?: number | null;
}>;
};
function readOptionalTrimmedText(input: unknown) {
const value = String(input || "").trim();
return value || null;
}
function shouldCharge3DPoints(mode: WorldTaskMode) {
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt);
if (mode === "text") {
if (!textPrompt) {
throw new Error("请输入世界描述");
}
return {
type: "text",
text_prompt: textPrompt,
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
};
}
if (mode === "image") {
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("图生世界缺少 mediaAssetId");
}
return {
type: "image",
image_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
...(body.isPano !== undefined ? { is_pano: body.isPano } : {}),
};
}
if (mode === "multi-image") {
const mediaAssets = Array.isArray(body.mediaAssets) ? body.mediaAssets : [];
const multiImagePrompt = mediaAssets
.map((item) => {
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
if (!mediaAssetId) return null;
const azimuth =
item?.azimuth === null || item?.azimuth === undefined
? null
: Number.isFinite(Number(item.azimuth))
? Number(item.azimuth)
: null;
return {
...(azimuth === null ? {} : { azimuth }),
content: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
};
})
.filter(Boolean);
if (multiImagePrompt.length < 2) {
throw new Error("多图生成世界至少需要 2 张图片");
}
return {
type: "multi-image",
multi_image_prompt: multiImagePrompt,
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
...(body.reconstructImages !== undefined
? { reconstruct_images: body.reconstructImages }
: {}),
};
}
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("视频生成世界缺少 mediaAssetId");
}
return {
type: "video",
video_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
};
}
function isPermanentProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
[401, 402, 403].includes(error.httpStatus)
);
}
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 CreateWorldTaskRequest;
const mode = String(body.mode || "").trim() as WorldTaskMode;
if (!["text", "image", "multi-image", "video"].includes(mode)) {
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
}
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DWorld))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DWorld)))
: 0;
if (
shouldChargePoints &&
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const resolvedWorldModel = resolveThreeDModelRealId(
readOptionalTrimmedText(body.model),
modelAliasConfig.worldModels,
);
const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
let result: WorldLabsOperation | null = null;
let usedProvider = null as Awaited<
ReturnType<typeof getWorldLabsProviderConfig>
> | null;
let lastError: unknown = null;
for (const candidate of candidates) {
try {
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
json: {
display_name: displayName,
model: resolvedWorldModel || "marble-1.1",
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
},
});
usedProvider = candidate;
break;
} catch (error) {
lastError = error;
if (isPermanentProviderError(error)) {
await disableWorldLabsProvider(
candidate.providerId,
error instanceof Error ? error.message : String(error)
);
}
if (preferredProviderId) {
break;
}
}
}
if (!result || !usedProvider) {
throw lastError instanceof Error
? lastError
: new Error("创建 World Labs 世界任务失败");
}
const taskId = String(result.operation_id || "").trim();
if (!taskId) {
return NextResponse.json(
{
success: false,
error: "World Labs 返回缺少 operation_id",
raw: result,
},
{ status: 502 }
);
}
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "world3d_generation",
modelId: "worldlabs-3d",
note: "3D世界生成消耗积分",
});
} catch (error) {
console.error("[Points] Failed to consume World Labs 3D points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: result.done ? "success" : "running",
assetPayload: {
providerId: usedProvider.providerId || null,
providerName: usedProvider.providerName || null,
},
raw: result,
},
});
} catch (error) {
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 World Labs 世界任务失败",
},
{ status: 500 }
);
}
}