351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
consumeUserPoints,
|
||
getPointsSystemConfig,
|
||
getUserById,
|
||
} from "@/lib/db/queries";
|
||
import {
|
||
resolveThreeDModelDisplayName,
|
||
resolveThreeDModelRealId,
|
||
} from "@/lib/model3d/model-alias-config";
|
||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||
import {
|
||
getWorldLabsProviderConfig,
|
||
getWorldLabsProviderConfigs,
|
||
reportWorldLabsProviderRuntimeFailure,
|
||
} from "@/lib/worldlabs/server";
|
||
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 60;
|
||
|
||
const logger = createLogger("world-labs-task-create");
|
||
|
||
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 isCredentialOrBillingProviderError(error: unknown) {
|
||
return (
|
||
error instanceof WorldLabsApiError &&
|
||
[401, 402, 403].includes(error.httpStatus)
|
||
);
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
const startedAt = Date.now();
|
||
let modeForLog: string | null = null;
|
||
let modelForLog: string | null = null;
|
||
let providerForLog: string | null = null;
|
||
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;
|
||
modeForLog = mode;
|
||
if (!["text", "image", "multi-image", "video"].includes(mode)) {
|
||
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
|
||
}
|
||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||
const resolvedWorldModel = resolveThreeDModelRealId(
|
||
readOptionalTrimmedText(body.model),
|
||
modelAliasConfig.worldModels,
|
||
);
|
||
const worldModelDisplayName = resolveThreeDModelDisplayName(
|
||
resolvedWorldModel || "marble-1.1",
|
||
modelAliasConfig.worldModels,
|
||
resolvedWorldModel || "marble-1.1",
|
||
);
|
||
modelForLog = resolvedWorldModel || "marble-1.1";
|
||
|
||
const pointsConfig = await getPointsSystemConfig();
|
||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||
const unitCost = get3DWorldCreditCost(
|
||
pointsConfig,
|
||
resolvedWorldModel || "marble-1.1",
|
||
);
|
||
|
||
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 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;
|
||
providerForLog = candidate.providerId || null;
|
||
break;
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (isCredentialOrBillingProviderError(error)) {
|
||
await reportWorldLabsProviderRuntimeFailure(
|
||
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:${resolvedWorldModel || "marble-1.1"}`,
|
||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
||
});
|
||
} catch (error) {
|
||
logger.error("points consume failed", error, {
|
||
userId: session.user.id,
|
||
unitCost,
|
||
mode,
|
||
model: resolvedWorldModel || "marble-1.1",
|
||
});
|
||
}
|
||
}
|
||
|
||
logger.info("task created", {
|
||
elapsedMs: Date.now() - startedAt,
|
||
taskIdLength: taskId.length,
|
||
mode,
|
||
model: resolvedWorldModel || "marble-1.1",
|
||
providerId: usedProvider.providerId || null,
|
||
status: result.done ? "success" : "running",
|
||
unitCost,
|
||
});
|
||
|
||
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) {
|
||
logger.error("task create failed", error, {
|
||
elapsedMs: Date.now() - startedAt,
|
||
mode: modeForLog,
|
||
model: modelForLog,
|
||
providerId: providerForLog,
|
||
});
|
||
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 }
|
||
);
|
||
}
|
||
}
|