602 lines
19 KiB
TypeScript
602 lines
19 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 {
|
||
isWorldLabsExternalTaskApi,
|
||
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||
WorldLabsApiError,
|
||
worldLabsRequest,
|
||
} from "@/lib/worldlabs/client";
|
||
import {
|
||
getWorldLabsProviderConfig,
|
||
getWorldLabsProviderConfigs,
|
||
reportWorldLabsProviderRuntimeFailure,
|
||
} from "@/lib/worldlabs/server";
|
||
import type {
|
||
WorldLabsExternalTaskCreateData,
|
||
WorldLabsExternalTaskResponse,
|
||
WorldLabsModel,
|
||
WorldLabsOperation,
|
||
} from "@/lib/worldlabs/types";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 180;
|
||
|
||
const logger = createLogger("world-labs-task-create");
|
||
const WORLDLABS_TASK_CREATE_TIMEOUT_MS =
|
||
Number.parseInt(String(process.env.WORLDLABS_TASK_CREATE_TIMEOUT_MS || "").trim(), 10) ||
|
||
180_000;
|
||
|
||
function resolveWorldLabsCreateTimeoutMs() {
|
||
if (!Number.isFinite(WORLDLABS_TASK_CREATE_TIMEOUT_MS)) return 180_000;
|
||
if (WORLDLABS_TASK_CREATE_TIMEOUT_MS <= 0) return 180_000;
|
||
return WORLDLABS_TASK_CREATE_TIMEOUT_MS;
|
||
}
|
||
|
||
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;
|
||
sourceUrl?: string;
|
||
mediaAssets?: Array<{
|
||
mediaAssetId?: string;
|
||
sourceUrl?: string;
|
||
azimuth?: number | null;
|
||
}>;
|
||
};
|
||
|
||
function readOptionalTrimmedText(input: unknown) {
|
||
const value = String(input || "").trim();
|
||
return value || null;
|
||
}
|
||
|
||
function readOptionalHttpUrl(input: unknown) {
|
||
const value = readOptionalTrimmedText(input);
|
||
if (!value) return null;
|
||
try {
|
||
const parsed = new URL(value);
|
||
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||
? parsed.toString()
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getObjectValue(value: unknown) {
|
||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||
}
|
||
|
||
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||
const root = getObjectValue(payload);
|
||
const data = getObjectValue(root?.data);
|
||
return (data || root || {}) as T;
|
||
}
|
||
|
||
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||
const normalized = String(status || "").trim().toLowerCase();
|
||
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||
return "success" as const;
|
||
}
|
||
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||
return "failed" as const;
|
||
}
|
||
return "running" as const;
|
||
}
|
||
|
||
function normalizeExternalTaskCreateOperation(
|
||
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||
): WorldLabsOperation {
|
||
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
|
||
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
|
||
const status = readOptionalTrimmedText(data.status) || "accepted";
|
||
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
|
||
return {
|
||
operation_id: taskId || undefined,
|
||
done: normalizedStatus === "success",
|
||
error:
|
||
normalizedStatus === "failed"
|
||
? {
|
||
message:
|
||
readOptionalTrimmedText(
|
||
(payload as Record<string, unknown>)?.message ||
|
||
(payload as Record<string, unknown>)?.error
|
||
) || "World Labs 外部任务创建失败",
|
||
}
|
||
: null,
|
||
metadata: {
|
||
externalTaskApi: true,
|
||
status,
|
||
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
|
||
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
|
||
},
|
||
response: data,
|
||
};
|
||
}
|
||
|
||
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||
}
|
||
|
||
function shouldUseExternalTaskCreateApi(
|
||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||
mode: WorldTaskMode
|
||
) {
|
||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "image";
|
||
}
|
||
|
||
function isWorldLabsModeUnsupportedByExternalProvider(
|
||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||
mode: WorldTaskMode
|
||
) {
|
||
return isWorldLabsExternalTaskApi(config.baseUrl) && mode !== "image";
|
||
}
|
||
|
||
function assertWorldModeSupportedByCandidate(
|
||
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||
mode: WorldTaskMode,
|
||
) {
|
||
if (isWorldLabsModeUnsupportedByExternalProvider(config, mode)) {
|
||
const normalizedBaseUrl = String(config.baseUrl || "").trim() || "未知服务地址";
|
||
const normalizedMode = String(mode || "").trim() || "未知模式";
|
||
throw new Error(
|
||
`${normalizedBaseUrl} 的 world-generation 当前仅支持图生世界(image)。当前提交的是 ${normalizedMode},请先切换供应商或使用图生世界。`
|
||
);
|
||
}
|
||
}
|
||
|
||
function resolveOfficialMarbleModel(model?: string | null) {
|
||
const normalized = readOptionalTrimmedText(model);
|
||
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||
return "marble-1.1";
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function buildExternalWorldTaskPayload(
|
||
mode: WorldTaskMode,
|
||
worldPrompt: Record<string, unknown>,
|
||
prompt: string | null,
|
||
displayName: string,
|
||
) {
|
||
const normalizedPrompt = readOptionalTrimmedText(prompt);
|
||
if (!normalizedPrompt && mode === "text") {
|
||
throw new Error("请输入世界描述");
|
||
}
|
||
const payload: Record<string, unknown> = {
|
||
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||
displayName,
|
||
};
|
||
if (normalizedPrompt) {
|
||
payload.prompt = normalizedPrompt;
|
||
} else if (mode !== "text") {
|
||
payload.prompt = "图生世界";
|
||
}
|
||
|
||
if (mode === "image" || mode === "multi-image") {
|
||
payload.world_prompt = worldPrompt;
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
function buildOfficialWorldTaskPayload(
|
||
displayName: string,
|
||
worldPrompt: Record<string, unknown>,
|
||
officialMarbleModel: string,
|
||
body: CreateWorldTaskRequest
|
||
) {
|
||
return {
|
||
display_name: displayName,
|
||
model: officialMarbleModel,
|
||
permission: {
|
||
public: body.makePublic === true,
|
||
},
|
||
world_prompt: worldPrompt,
|
||
};
|
||
}
|
||
|
||
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);
|
||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||
if (!mediaAssetId && !sourceUrl) {
|
||
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||
}
|
||
return {
|
||
type: "image",
|
||
image_prompt: mediaAssetId
|
||
? {
|
||
source: "media_asset",
|
||
media_asset_id: mediaAssetId,
|
||
}
|
||
: {
|
||
source: "uri",
|
||
uri: sourceUrl,
|
||
},
|
||
...(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);
|
||
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||
if (!mediaAssetId && !sourceUrl) 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: mediaAssetId
|
||
? {
|
||
source: "media_asset",
|
||
media_asset_id: mediaAssetId,
|
||
}
|
||
: {
|
||
source: "uri",
|
||
uri: sourceUrl,
|
||
},
|
||
};
|
||
})
|
||
.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);
|
||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||
if (!mediaAssetId && !sourceUrl) {
|
||
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||
}
|
||
|
||
return {
|
||
type: "video",
|
||
video_prompt: mediaAssetId
|
||
? {
|
||
source: "media_asset",
|
||
media_asset_id: mediaAssetId,
|
||
}
|
||
: {
|
||
source: "uri",
|
||
uri: sourceUrl,
|
||
},
|
||
...(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 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");
|
||
}
|
||
|
||
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
|
||
const predictedBillingModel = candidates.some((candidate) =>
|
||
shouldUseExternalTaskCreateApi(candidate, mode)
|
||
)
|
||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||
: officialMarbleModel;
|
||
const pointsConfig = await getPointsSystemConfig();
|
||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||
|
||
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 }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let result: WorldLabsOperation | null = null;
|
||
let usedProvider = null as Awaited<
|
||
ReturnType<typeof getWorldLabsProviderConfig>
|
||
> | null;
|
||
let usedExternalTaskApi = false;
|
||
let lastError: unknown = null;
|
||
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||
assertWorldModeSupportedByCandidate(candidate, mode);
|
||
let requestUsedExternalTaskApi = false;
|
||
if (useExternalTaskApi) {
|
||
const payload = buildExternalWorldTaskPayload(
|
||
mode,
|
||
worldPrompt,
|
||
readOptionalTrimmedText(body.prompt),
|
||
displayName,
|
||
);
|
||
try {
|
||
const externalResult = await worldLabsRequest<
|
||
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||
>(candidate, {
|
||
method: "POST",
|
||
path: "/api/external/task/generate",
|
||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||
json: payload,
|
||
});
|
||
result = normalizeExternalTaskCreateOperation(externalResult);
|
||
requestUsedExternalTaskApi = true;
|
||
} catch (error) {
|
||
const externalError = error instanceof Error ? error.message : String(error);
|
||
logger.warn("external world create failed, fallback to marble endpoint", {
|
||
providerId: candidate.providerId || null,
|
||
error: externalError,
|
||
});
|
||
if (isCredentialOrBillingProviderError(error)) {
|
||
await reportWorldLabsProviderRuntimeFailure(
|
||
candidate.providerId,
|
||
externalError
|
||
);
|
||
}
|
||
|
||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||
method: "POST",
|
||
path: "/marble/v1/worlds:generate",
|
||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||
json: buildOfficialWorldTaskPayload(
|
||
displayName,
|
||
worldPrompt,
|
||
officialMarbleModel,
|
||
body
|
||
),
|
||
});
|
||
requestUsedExternalTaskApi = false;
|
||
}
|
||
} else {
|
||
requestUsedExternalTaskApi = false;
|
||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||
method: "POST",
|
||
path: "/marble/v1/worlds:generate",
|
||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||
json: buildOfficialWorldTaskPayload(
|
||
displayName,
|
||
worldPrompt,
|
||
officialMarbleModel,
|
||
body
|
||
),
|
||
});
|
||
}
|
||
|
||
if (!result?.operation_id) {
|
||
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
|
||
}
|
||
|
||
usedProvider = candidate;
|
||
usedExternalTaskApi = requestUsedExternalTaskApi;
|
||
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 {
|
||
const usedBillingModel = usedExternalTaskApi
|
||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||
: officialMarbleModel;
|
||
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||
: worldModelDisplayName;
|
||
await consumeUserPoints({
|
||
userId: session.user.id,
|
||
amount: unitCost,
|
||
reason: "world3d_generation",
|
||
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||
});
|
||
} 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: usedExternalTaskApi
|
||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||
: officialMarbleModel,
|
||
providerId: usedProvider.providerId || null,
|
||
status: result.done ? "success" : "running",
|
||
unitCost,
|
||
externalTaskApi: usedExternalTaskApi,
|
||
});
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
taskId,
|
||
status: result.done ? "success" : "running",
|
||
assetPayload: {
|
||
providerId: usedProvider.providerId || null,
|
||
providerName: usedProvider.providerName || null,
|
||
externalTaskApi: usedExternalTaskApi,
|
||
marbleApi: !usedExternalTaskApi,
|
||
},
|
||
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 }
|
||
);
|
||
}
|
||
}
|