351 lines
10 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 { 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 }
);
}
}