diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index d58a1df75..614aaa515 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -275,6 +275,10 @@ import { recommendedVideoDurationReferenceCosts, recommendedVideoResolutionBaseCosts, } from "@/lib/ai/video-model-config"; +import { + formatImageModelCreditDiscountLabel, + getGenerationCreditCostFromImageGenConfig, +} from "@/lib/ai/image-model-pricing"; import type { AgentWorkflowUiConfig } from "@/types/agent"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); @@ -595,6 +599,7 @@ type ImageGenConfig = { supportsWatermark?: boolean; // 是否支持水印开关 supportsSeed?: boolean; // 是否支持随机种子 creditCostPerImage?: number; // 每张图片积分消耗 + creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折 creditCostPerVideo?: number; // 每条视频积分消耗 videoDurationOptions?: number[]; // 视频秒数选项 creditCostPerVideoByDuration?: Record; // 各秒数积分 @@ -15025,6 +15030,57 @@ export function AdminSettingsClient() {

+
+ + { + const rawValue = String(e.target.value || "").trim(); + const nextRate = rawValue + ? Math.min( + 10, + Math.max(0.1, Number.parseFloat(rawValue) || 0) + ) + : undefined; + setModelForm({ + ...modelForm, + imageGenConfig: { + ...modelForm.imageGenConfig, + creditDiscountRate: nextRate, + }, + }); + }} + /> +

+ 例如输入 5 表示 5折,7.5 表示 7.5折;前台模型列表会自动显示折扣标识。 +

+ {formatImageModelCreditDiscountLabel( + modelForm.imageGenConfig?.creditDiscountRate + ) ? ( +

+ 当前生效: + {formatImageModelCreditDiscountLabel( + modelForm.imageGenConfig?.creditDiscountRate + )} + ,图片单价将按{" "} + { + getGenerationCreditCostFromImageGenConfig( + modelForm.imageGenConfig, + "image" + ) + }{" "} + 积分结算。 +

+ ) : null} +
+
) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 99e344506..6407dac22 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -94,7 +94,7 @@ import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; -import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; +import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; import { bootstrapConversationStateProtocol, composeConversationStateProtocol, @@ -4218,21 +4218,7 @@ function getModelCreditCost( target: "image" | "video", options?: { duration?: number | null; resolution?: string | null } ): number { - const config = (modelConfig as any)?.imageGenConfig || {}; - const sourceValue = - target === "video" - ? (() => { - const resolvedVideoCost = getVideoCreditCostFromConfig( - config, - options?.duration, - options?.resolution - ); - if (resolvedVideoCost > 0) return resolvedVideoCost; - return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0; - })() - : config.creditCostPerImage ?? 0; - const normalized = Number(sourceValue); - return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; + return getModelGenerationCreditCost(modelConfig, target, options); } async function resolveBillingModelByReference(modelRef: string): Promise { @@ -9695,9 +9681,7 @@ export async function POST(request: Request) { } : undefined ); - const modelCreditCostPerImage = Number( - (currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0 - ); + const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image"); const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", { duration: Number(effectiveVideoParams?.duration) || undefined, resolution: String(effectiveVideoParams?.resolution || "").trim() || undefined, diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 5383f7f81..43fd0ed1b 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -20,6 +20,7 @@ import { getConfiguredVideoDurationOptions, normalizeVideoResolutionCostMap, } from "@/lib/ai/video-model-config"; +import { normalizeImageGenPricingConfig } from "@/lib/ai/image-model-pricing"; // 🆕 图片生成模型特性配置 Schema const imageGenConfigSchema = z.object({ @@ -33,6 +34,7 @@ const imageGenConfigSchema = z.object({ supportsWatermark: z.boolean().optional(), supportsSeed: z.boolean().optional(), creditCostPerImage: z.number().min(0).max(100000).optional(), + creditDiscountRate: z.number().min(0.1).max(10).optional(), creditCostPerVideo: z.number().min(0).max(100000).optional(), videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(), creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).optional(), @@ -53,14 +55,14 @@ function normalizeImageGenConfig(config: z.infer) { fallbackCost: config.creditCostPerVideo, }); - return { + return normalizeImageGenPricingConfig({ ...config, videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined, creditCostPerVideoByDuration, creditCostPerVideoByResolution: normalizeVideoResolutionCostMap( config.creditCostPerVideoByResolution ), - }; + }); } const createModelSchema = z.object({ diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts index 5da07108c..2663cd60d 100644 --- a/app/api/canvas/3d/model-proxy/route.ts +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; export const runtime = "nodejs"; export const maxDuration = 60; @@ -22,6 +23,62 @@ function isAllowedModelHost(hostname: string) { ); } +function isGltfAssetRequest(targetUrl: URL, contentType: string | null) { + const normalizedType = String(contentType || "") + .toLowerCase() + .split(";")[0] + ?.trim(); + if (normalizedType === "model/gltf+json") return true; + if (targetUrl.pathname.toLowerCase().endsWith(".gltf")) return true; + return false; +} + +function rewriteGltfAssetUri(assetUri: string, sourceUrl: URL) { + const normalized = assetUri.trim(); + if (!normalized) return normalized; + if (normalized.startsWith("data:") || normalized.startsWith("blob:")) { + return normalized; + } + + try { + const resolved = new URL(normalized, sourceUrl).toString(); + return buildThreeAssetUrl(resolved); + } catch { + return normalized; + } +} + +function rewriteGltfAssetList(value: unknown, sourceUrl: URL) { + if (!Array.isArray(value)) return value; + + return value.map((item) => { + if (!item || typeof item !== "object") return item; + const next = { ...(item as Record) }; + if (typeof next.uri === "string") { + next.uri = rewriteGltfAssetUri(next.uri, sourceUrl); + } + return next; + }); +} + +function rewriteGltfPayload(raw: string, sourceUrl: URL) { + try { + const parsed = JSON.parse(raw) as Record; + const next = { ...parsed }; + + if (Array.isArray(parsed.buffers)) { + next.buffers = rewriteGltfAssetList(parsed.buffers, sourceUrl); + } + if (Array.isArray(parsed.images)) { + next.images = rewriteGltfAssetList(parsed.images, sourceUrl); + } + + return JSON.stringify(next); + } catch { + return raw; + } +} + export async function GET(request: Request) { try { const session = await auth(); @@ -75,6 +132,7 @@ export async function GET(request: Request) { const responseHeaders = new Headers(); const contentType = upstream.headers.get("content-type") || "application/octet-stream"; + const shouldRewriteGltf = !range && isGltfAssetRequest(targetUrl, contentType); responseHeaders.set("content-type", contentType); const contentLength = upstream.headers.get("content-length"); @@ -88,6 +146,17 @@ export async function GET(request: Request) { responseHeaders.set("cache-control", "private, no-store"); + if (shouldRewriteGltf) { + const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl); + responseHeaders.set("content-type", "model/gltf+json; charset=utf-8"); + responseHeaders.delete("content-length"); + + return new NextResponse(rewrittenPayload, { + status: upstream.status, + headers: responseHeaders, + }); + } + return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders, diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts index 53be57204..d15e5fd3d 100644 --- a/app/api/canvas/3d/tasks/route.ts +++ b/app/api/canvas/3d/tasks/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getPointsSystemConfig } from "@/lib/db/queries"; -import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; +import { + resolveThreeDModelDisplayName, + resolveThreeDModelRealId, +} from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { get3DTaskCreditCost } from "@/lib/points/config"; @@ -375,6 +378,17 @@ export async function POST(request: Request) { ); } + const billableTaskLabel = + taskType === "text_to_model" || + taskType === "image_to_model" || + taskType === "multiview_to_model" + ? resolveThreeDModelDisplayName( + payload.model_version, + modelAliasConfig.tripoModels, + taskType, + ) + : taskType; + if (body.compress === true) { payload.compress = "geometry"; } @@ -391,7 +405,7 @@ export async function POST(request: Request) { unitCost, reason: "model3d_task", modelId: "tripo-3d", - note: `3D模型任务消耗积分(${taskType})`, + note: `3D模型任务消耗积分(${billableTaskLabel})`, }); if (!pointsCharge.ok) { return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); @@ -440,7 +454,7 @@ export async function POST(request: Request) { if (pointsCharge.chargeable) { try { const consumeResult = await pointsCharge.consume({ - note: `3D模型任务消耗积分(${taskType} / ${taskId})`, + note: `3D模型任务消耗积分(${billableTaskLabel} / ${taskId})`, }); if (!consumeResult?.ok) { diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index 3989f8231..e4d0cebfb 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -5,7 +5,10 @@ import { getPointsSystemConfig, getUserById, } from "@/lib/db/queries"; -import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; +import { + resolveThreeDModelDisplayName, + resolveThreeDModelRealId, +} from "@/lib/model3d/model-alias-config"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { get3DWorldCreditCost } from "@/lib/points/config"; import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; @@ -160,6 +163,11 @@ export async function POST(request: Request) { readOptionalTrimmedText(body.model), modelAliasConfig.worldModels, ); + const worldModelDisplayName = resolveThreeDModelDisplayName( + resolvedWorldModel || "marble-1.1", + modelAliasConfig.worldModels, + resolvedWorldModel || "marble-1.1", + ); const pointsConfig = await getPointsSystemConfig(); const shouldChargePoints = shouldCharge3DPoints(mode); @@ -269,7 +277,7 @@ export async function POST(request: Request) { amount: unitCost, reason: "world3d_generation", modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`, - note: `3D世界生成消耗积分(${resolvedWorldModel || "marble-1.1"})`, + note: `3D世界生成消耗积分(${worldModelDisplayName})`, }); } catch (error) { console.error("[Points] Failed to consume World Labs 3D points:", error); diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index 016d7db6c..3f8338dda 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -1,13 +1,15 @@ import { NextResponse } from "next/server"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; -import { paymentOrder, systemSettings } from "@/lib/db/schema"; +import { model3dAsset, paymentOrder, systemSettings } from "@/lib/db/schema"; import { getPointsLedgerByUserId, getPointsSystemConfig, getUserById, } from "@/lib/db/queries"; +import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { defaultMembershipConfig, defaultMembershipUserOverrides, @@ -19,6 +21,9 @@ import { const MEMBERSHIP_CONFIG_KEY = "membership_config"; const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides"; +const MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN = /^3D模型任务消耗积分((.+?) \/ ([^)]+))$/; +const MODEL3D_LEDGER_NOTE_PATTERN = /^3D模型任务消耗积分((.+?))$/; +const WORLD3D_LEDGER_NOTE_PATTERN = /^3D世界生成消耗积分((.+?))$/; function mapLedgerReasonLabel(reason: string) { if (reason === "image_generation") return "图片生成"; @@ -36,12 +41,61 @@ function mapLedgerReasonLabel(reason: string) { return reason || "积分变动"; } -function formatLedgerDetail(reason: string, note: string | null | undefined) { +function extractWorldBillingModelId(modelId: string | null | undefined) { + const normalized = typeof modelId === "string" ? modelId.trim() : ""; + if (!normalized) return ""; + if (!normalized.startsWith("worldlabs-3d:")) return normalized; + return normalized.slice("worldlabs-3d:".length).trim(); +} + +function formatLedgerDetail(params: { + reason: string; + note: string | null | undefined; + modelId?: string | null; + tripoModels: Array<{ id: string; displayName: string; realModelId?: string }>; + worldModels: Array<{ + id: string; + displayName: string; + realModelId?: string; + billingModelId?: string; + }>; + tripoTaskDisplayNamesByTaskId: Map; +}) { + const { reason, note, modelId, tripoModels, worldModels, tripoTaskDisplayNamesByTaskId } = + params; + if (reason === "recharge") { return "充值积分"; } const trimmedNote = typeof note === "string" ? note.trim() : ""; + if (reason === "model3d_task") { + const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN); + if (taskMatch) { + const [, rawLabel, taskId] = taskMatch; + const displayName = + tripoTaskDisplayNamesByTaskId.get(taskId) || + resolveThreeDModelDisplayName(rawLabel, tripoModels, rawLabel); + return `3D模型任务消耗积分(${displayName} / ${taskId})`; + } + + const modelMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_PATTERN); + if (modelMatch) { + const [, rawLabel] = modelMatch; + const displayName = resolveThreeDModelDisplayName(rawLabel, tripoModels, rawLabel); + return `3D模型任务消耗积分(${displayName})`; + } + } + + if (reason === "world3d_generation") { + const noteMatch = trimmedNote.match(WORLD3D_LEDGER_NOTE_PATTERN); + const rawLabel = extractWorldBillingModelId(modelId) || noteMatch?.[1] || ""; + const displayName = resolveThreeDModelDisplayName(rawLabel, worldModels, rawLabel); + if (displayName) { + return `3D世界生成消耗积分(${displayName})`; + } + } + return trimmedNote || mapLedgerReasonLabel(reason); } @@ -89,6 +143,43 @@ export async function GET() { .then((rows) => rows[0]), ]); + const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime(); + const model3dTaskIds = ledger + .filter((item) => item.reason === "model3d_task") + .map((item) => { + const match = String(item.note || "").trim().match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN); + return match?.[2] || ""; + }) + .filter(Boolean); + const uniqueModel3dTaskIds = Array.from(new Set(model3dTaskIds)); + const model3dTaskDisplayNamesByTaskId = new Map(); + + if (uniqueModel3dTaskIds.length > 0) { + const relatedModelTasks = await db + .select({ + taskId: model3dAsset.taskId, + modelVersion: model3dAsset.modelVersion, + }) + .from(model3dAsset) + .where( + and( + eq(model3dAsset.userId, session.user.id), + inArray(model3dAsset.taskId, uniqueModel3dTaskIds), + ), + ); + + relatedModelTasks.forEach((task) => { + const displayName = resolveThreeDModelDisplayName( + task.modelVersion, + threeDModelAliasConfig.tripoModels, + String(task.modelVersion || "").trim(), + ); + if (task.taskId && displayName) { + model3dTaskDisplayNamesByTaskId.set(task.taskId, displayName); + } + }); + } + const membershipConfig = configSetting?.value ? normalizeMembershipConfig(configSetting.value) : defaultMembershipConfig; @@ -105,7 +196,14 @@ export async function GET() { const usageRecords = ledger.map((item) => ({ id: item.id, - detail: formatLedgerDetail(item.reason, item.note), + detail: formatLedgerDetail({ + reason: item.reason, + note: item.note, + modelId: item.modelId, + tripoModels: threeDModelAliasConfig.tripoModels, + worldModels: threeDModelAliasConfig.worldModels, + tripoTaskDisplayNamesByTaskId: model3dTaskDisplayNamesByTaskId, + }), status: item.amount < 0 ? "consumed" : "earned", amount: item.amount, createdAt: item.createdAt?.toISOString() || new Date().toISOString(), diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 60f6ee37f..0db5cc4d2 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -99,6 +99,10 @@ import { resolveThreeDModelDisplayName, type PublicThreeDModelAliasConfig, } from "@/lib/model3d/model-alias-config"; +import { + extractModelAssetFormat, + resolvePreferredModelAssetUrl, +} from "@/lib/model3d/model-url"; import { defaultPointsSystemConfig, get3DTaskCreditCost, @@ -152,6 +156,8 @@ type WorldViewportMode = "world" | "imagery" | "mesh" | "splat"; type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & { providerId?: string | null; providerName?: string | null; + sourceModelUrl?: string | null; + storageArchivedAt?: string | null; }; type TaskProvider = "tripo-3d" | "worldlabs-3d"; type TaskMode = @@ -319,23 +325,17 @@ function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus { } function extractModelFormat(modelUrl?: string | null): string { - const value = String(modelUrl || "").toLowerCase(); - if (!value) return "3d"; - if (value.includes(".gltf")) return "gltf"; - if (value.includes(".glb")) return "glb"; - if (value.includes(".usdz")) return "usdz"; - if (value.includes(".fbx")) return "fbx"; - if (value.includes(".obj")) return "obj"; - if (value.includes(".stl")) return "stl"; - if (value.includes(".3mf")) return "3mf"; - return "3d"; + return extractModelAssetFormat(modelUrl); } function resolveTaskModelUrl(task?: TaskRecord | null): string | null { if (!task) return null; - if (task.modelUrl) return task.modelUrl; const meshUrl = task.assetPayload?.meshUrl; - return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null; + return resolvePreferredModelAssetUrl({ + primaryUrl: task.modelUrl, + sourceUrl: task.assetPayload?.sourceModelUrl, + fallbackUrl: typeof meshUrl === "string" ? meshUrl.trim() : null, + }); } function createMultiViewSlots(count = 4): MultiViewSlot[] { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1fd7ec252..8b77e517a 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -69,7 +69,10 @@ import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; -import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; +import { + getGenerationCreditCostFromImageGenConfig, + getImageCreditCostFromConfig, +} from "@/lib/ai/image-model-pricing"; import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { @@ -3008,6 +3011,7 @@ export function CanvasChatPanel({ supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; @@ -3034,6 +3038,7 @@ export function CanvasChatPanel({ supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; @@ -8040,10 +8045,7 @@ export function CanvasChatPanel({ const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true; const blockOnInsufficient = membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false; - const unitCost = Math.max( - 0, - Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0)) - ); + const unitCost = getImageCreditCostFromConfig(currentModel?.imageGenConfig); if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) { return true; @@ -8073,7 +8075,7 @@ export function CanvasChatPanel({ return false; }, [ activeComposerMode, - currentModel?.imageGenConfig?.creditCostPerImage, + currentModel?.imageGenConfig, imageParams?.imageCount, isVideoModel, membershipSummary?.isHighVip, @@ -8088,10 +8090,13 @@ export function CanvasChatPanel({ if (activeComposerMode === "agent") return null; if (isVideoModel) { - const cost = getVideoCreditCostFromConfig( + const cost = getGenerationCreditCostFromImageGenConfig( currentModel?.imageGenConfig, - videoParams?.duration, - videoParams?.resolution + "video", + { + duration: videoParams?.duration, + resolution: videoParams?.resolution, + } ); return cost > 0 ? { @@ -8101,10 +8106,7 @@ export function CanvasChatPanel({ : null; } - const unitCost = Math.max( - 0, - Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0)) - ); + const unitCost = getImageCreditCostFromConfig(currentModel?.imageGenConfig); const imageCount = Math.max( 1, Math.round(Number(imageParams?.imageCount || 1)) diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 89454ada5..998bee967 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -46,7 +46,10 @@ import { import { toast } from "sonner"; import useSWR from "swr"; import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json"; -import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; +import { + getGenerationCreditCostFromImageGenConfig, + getImageCreditCostFromConfig, +} from "@/lib/ai/image-model-pricing"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, @@ -262,6 +265,7 @@ type CanvasModelOption = { capabilities?: unknown; imageGenConfig?: { creditCostPerImage?: number | null; + creditDiscountRate?: number | null; creditCostPerVideo?: number | null; videoDurationOptions?: number[] | null; creditCostPerVideoByDuration?: Record | null; @@ -1833,10 +1837,13 @@ function resolveVideoCanvasBillingDisplay(params: { } if (params.modelKind === "video") { - const videoCost = getVideoCreditCostFromConfig( + const videoCost = getGenerationCreditCostFromImageGenConfig( matchedModel.imageGenConfig, - params.duration, - params.resolution, + "video", + { + duration: params.duration, + resolution: params.resolution, + }, ); if (videoCost > 0) { return { @@ -1848,10 +1855,7 @@ function resolveVideoCanvasBillingDisplay(params: { } if (params.modelKind === "image") { - const unitCost = Math.max( - 0, - Math.round(Number(matchedModel.imageGenConfig.creditCostPerImage ?? 0)), - ); + const unitCost = getImageCreditCostFromConfig(matchedModel.imageGenConfig); if (unitCost > 0) { const safeImageCount = Math.max( 1, diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 808216b48..32b794aac 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -943,6 +943,12 @@ export function RealtimeCanvasDialog({ previewUrl, selectedImageLayer?.src, ]); + const previewDisplayUrl = useMemo(() => { + if (!previewUrl) return ""; + return /^https?:\/\//i.test(previewUrl) + ? buildImageProxyUrl(previewUrl, { delivery: "proxy" }) + : previewUrl; + }, [previewUrl]); const resolveRealtimeModeItem = useCallback( ( key: "realtimeModeRealtime" | "realtimeModeEnhance", @@ -3787,7 +3793,7 @@ export function RealtimeCanvasDialog({ 实时预览 ) : (
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index a2eacaae6..916868a72 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -5344,6 +5344,26 @@ export function ImageNode({ result.data.previewImageUrl || params.previewImageUrl || params.sourceImageUrl; + const responseAssetPayload = + result.data.assetPayload && + typeof result.data.assetPayload === "object" && + !Array.isArray(result.data.assetPayload) + ? (result.data.assetPayload as Record) + : null; + const nextAssetPayload = { + ...(responseAssetPayload || {}), + providerId: + params.providerId || + (typeof responseAssetPayload?.providerId === "string" + ? responseAssetPayload.providerId + : null), + providerName: + params.providerName || + (typeof responseAssetPayload?.providerName === "string" + ? responseAssetPayload.providerName + : null), + sourceImageUrl: params.sourceImageUrl, + }; if (!modelUrl) { const error = "3D 模型已完成,但没有返回模型文件"; updateNode(params.nodeId, { @@ -5401,11 +5421,7 @@ export function ImageNode({ sourceImageUrl: params.sourceImageUrl, progress: 100, output: result.data.output || null, - assetPayload: { - providerId: params.providerId || null, - providerName: params.providerName || null, - sourceImageUrl: params.sourceImageUrl, - }, + assetPayload: nextAssetPayload, }, }, }); @@ -5420,11 +5436,7 @@ export function ImageNode({ modelUrl, previewImageUrl, error: null, - assetPayload: { - providerId: params.providerId || null, - providerName: params.providerName || null, - sourceImageUrl: params.sourceImageUrl, - }, + assetPayload: nextAssetPayload, createdAt: Date.now(), }); persistCanvasStateSnapshot(); @@ -6621,7 +6633,7 @@ export function ImageNode({ />
) : null} - {/* 提示词显示 */} - {data.prompt && isHovered && !isProcessing && ( -
-

{data.prompt}

-
- )} - {data.note && !isSelected && (
+ resolvePreferredModelAssetUrl({ + primaryUrl: data.src, + sourceUrl: sourceModelUrl, + }), + [data.src, sourceModelUrl] + ); + const modelFileName = useMemo( + () => inferModelFileName(resolvedModelUrl || data.src || null, node.id), + [data.src, node.id, resolvedModelUrl] + ); + const modelFormatLabel = useMemo( + () => inferModelFormat(resolvedModelUrl || data.src || null, data.format), + [data.format, data.src, resolvedModelUrl] + ); + const primaryOpenUrl = worldUrl || resolvedModelUrl || null; const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem; const primaryOpenLabel = primaryOpenItem.label; const shouldRenderLiveViewer = - isSelected && data.status === "completed" && Boolean(data.src); + isSelected && data.status === "completed" && Boolean(resolvedModelUrl); const placeholderSizeScale = useMemo( () => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76), [node.size.height, node.size.width] @@ -195,11 +208,11 @@ export function Model3DNode({ }; const handleOpenMesh = () => { - if (!data.src) { + if (!resolvedModelUrl) { toast.error("暂无可打开的 Mesh 地址"); return; } - window.open(data.src, "_blank", "noopener,noreferrer"); + window.open(resolvedModelUrl, "_blank", "noopener,noreferrer"); }; const handleOpenImage = () => { @@ -211,13 +224,13 @@ export function Model3DNode({ }; const handleDownload = () => { - if (!data.src) { + if (!resolvedModelUrl) { toast.error("暂无可下载的 3D 文件"); return; } const link = document.createElement("a"); - link.href = data.src; - link.download = inferModelFileName(data, node.id); + link.href = resolvedModelUrl; + link.download = modelFileName; link.click(); }; @@ -296,7 +309,7 @@ export function Model3DNode({ > @@ -314,12 +327,12 @@ export function Model3DNode({
)}
- {inferModelFormat(data)} + {modelFormatLabel}

- {data.prompt || inferModelFileName(data, node.id)} + {data.prompt || modelFileName}

{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel} @@ -367,7 +380,7 @@ export function Model3DNode({ /> {primaryOpenLabel} - {worldUrl && data.src && data.src !== worldUrl && ( + {worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && ( fetch(url).then((res) => res.json()); @@ -217,8 +221,15 @@ export function ModelSelector({

-
- +
+ {getImageModelCreditDiscountLabel(model.imageGenConfig) ? ( + + {getImageModelCreditDiscountLabel(model.imageGenConfig)} + + ) : null} +
+ +
diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index e86e5f3a3..7d1f1f179 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -7,7 +7,11 @@ import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; -import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; +import { + getGenerationCreditCostFromImageGenConfig, + getImageCreditCostFromConfig, + getImageModelCreditDiscountLabel, +} from "@/lib/ai/image-model-pricing"; // 动态模型类型定义 type DynamicModel = { @@ -37,6 +41,7 @@ type DynamicModel = { supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; @@ -211,6 +216,7 @@ function PureMultimodalInput({ supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; @@ -327,10 +333,13 @@ function PureMultimodalInput({ if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null; if (isVideoModel) { - const cost = getVideoCreditCostFromConfig( + const cost = getGenerationCreditCostFromImageGenConfig( imageGenConfig, - videoParams?.duration, - videoParams?.resolution + "video", + { + duration: videoParams?.duration, + resolution: videoParams?.resolution, + } ); return cost > 0 ? { @@ -341,10 +350,7 @@ function PureMultimodalInput({ } if (!isImageModel) return null; - const unitCost = Math.max( - 0, - Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0)) - ); + const unitCost = getImageCreditCostFromConfig(imageGenConfig); const imageCount = Math.max( 1, Math.round(Number(imageParams?.imageCount || 1)) @@ -438,10 +444,7 @@ function PureMultimodalInput({ const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true; const blockOnInsufficient = membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false; - const unitCost = Math.max( - 0, - Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0)) - ); + const unitCost = getImageCreditCostFromConfig(imageGenConfig); if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) { return true; @@ -470,7 +473,7 @@ function PureMultimodalInput({ toast.error("积分不足,请先充值后再生图"); return false; }, [ - imageGenConfig?.creditCostPerImage, + imageGenConfig, imageParams?.imageCount, isImageModel, isVideoModel, @@ -1835,7 +1838,7 @@ function PureModelSelectorCompact({ key={model.id} value={model.id} className={cn( - "h-10 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full", + "h-11 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full", "focus:bg-[#e7ebf0] focus:text-[#1f2937]", "data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]", model.isSubModel ? "pl-8" : "" @@ -1871,9 +1874,16 @@ function PureModelSelectorCompact({ {model.isSubModel ? `└ ${model.displayName}` : model.displayName} - {optimisticModelId === model.id ? ( - ✓ - ) : null} + + {getImageModelCreditDiscountLabel(model.imageGenConfig) ? ( + + {getImageModelCreditDiscountLabel(model.imageGenConfig)} + + ) : null} + {optimisticModelId === model.id ? ( + ✓ + ) : null} + ))} diff --git a/lib/ai/image-model-pricing.ts b/lib/ai/image-model-pricing.ts new file mode 100644 index 000000000..112bb94f7 --- /dev/null +++ b/lib/ai/image-model-pricing.ts @@ -0,0 +1,104 @@ +import { getVideoCreditCostFromConfig, type VideoImageGenConfigLike } from "@/lib/ai/video-model-config"; + +export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & { + creditCostPerImage?: number | null; + creditDiscountRate?: number | null; +}; + +function normalizeRoundedCost(value: unknown) { + const normalized = Number(value); + if (!Number.isFinite(normalized)) return 0; + return Math.max(0, Math.round(normalized)); +} + +export function normalizeImageModelCreditDiscountRate(value: unknown) { + const normalized = Number(value); + if (!Number.isFinite(normalized)) return null; + const rounded = Math.round(normalized * 10) / 10; + if (rounded <= 0 || rounded >= 10) return null; + return rounded; +} + +export function formatImageModelCreditDiscountLabel(value: unknown) { + const normalized = normalizeImageModelCreditDiscountRate(value); + if (normalized === null) return null; + const displayValue = Number.isInteger(normalized) + ? String(normalized) + : normalized.toFixed(1).replace(/\.0$/, ""); + return `${displayValue}折`; +} + +export function getImageModelCreditDiscountLabel( + config: DiscountableImageGenConfigLike | null | undefined +) { + return formatImageModelCreditDiscountLabel(config?.creditDiscountRate); +} + +export function applyImageModelCreditDiscount( + rawCost: unknown, + config: DiscountableImageGenConfigLike | null | undefined +) { + const baseCost = normalizeRoundedCost(rawCost); + if (baseCost <= 0) return 0; + + const normalizedDiscountRate = normalizeImageModelCreditDiscountRate( + config?.creditDiscountRate + ); + if (normalizedDiscountRate === null) return baseCost; + + return Math.max(0, Math.round((baseCost * normalizedDiscountRate) / 10)); +} + +export function getGenerationCreditCostFromImageGenConfig( + config: DiscountableImageGenConfigLike | null | undefined, + target: "image" | "video", + options?: { duration?: number | null; resolution?: string | null } +) { + const sourceValue = + target === "video" + ? (() => { + const resolvedVideoCost = getVideoCreditCostFromConfig( + config, + options?.duration, + options?.resolution + ); + if (resolvedVideoCost > 0) return resolvedVideoCost; + return config?.creditCostPerVideo ?? config?.creditCostPerImage ?? 0; + })() + : config?.creditCostPerImage ?? 0; + + return applyImageModelCreditDiscount(sourceValue, config); +} + +export function getImageCreditCostFromConfig( + config: DiscountableImageGenConfigLike | null | undefined +) { + return getGenerationCreditCostFromImageGenConfig(config, "image"); +} + +export function getModelGenerationCreditCost( + modelConfig: { imageGenConfig?: DiscountableImageGenConfigLike | null } | null | undefined, + target: "image" | "video", + options?: { duration?: number | null; resolution?: string | null } +) { + return getGenerationCreditCostFromImageGenConfig( + modelConfig?.imageGenConfig, + target, + options + ); +} + +export function normalizeImageGenPricingConfig< + T extends DiscountableImageGenConfigLike | null | undefined, +>(config: T): T { + if (!config) return config; + + const normalizedDiscountRate = normalizeImageModelCreditDiscountRate( + config.creditDiscountRate + ); + + return { + ...config, + creditDiscountRate: normalizedDiscountRate ?? undefined, + } as T; +} diff --git a/lib/ai/runtime/generated-image-persistence.ts b/lib/ai/runtime/generated-image-persistence.ts index bc1350a93..7649c5a91 100644 --- a/lib/ai/runtime/generated-image-persistence.ts +++ b/lib/ai/runtime/generated-image-persistence.ts @@ -11,7 +11,7 @@ import { upsertMediaAsset, } from "@/lib/db/queries"; import { persistImageWorkflowTurn } from "@/lib/ai/conversation-state-protocol"; -import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config"; +import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; const MODEL_UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -68,22 +68,7 @@ function getModelCreditCost( target: "image" | "video", options?: { duration?: number | null; resolution?: string | null }, ): number { - const config = (modelConfig as any)?.imageGenConfig || {}; - const sourceValue = - target === "video" - ? (() => { - const resolvedVideoCost = getVideoCreditCostFromConfig( - config, - options?.duration, - options?.resolution, - ); - if (resolvedVideoCost > 0) return resolvedVideoCost; - return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0; - })() - : config.creditCostPerImage ?? 0; - - const normalized = Number(sourceValue); - return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; + return getModelGenerationCreditCost(modelConfig, target, options); } async function resolveBillingModelByReference(modelRef: string): Promise { diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 68fc9d71c..7cb30e85b 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4009,6 +4009,7 @@ interface ImageGenConfig { supportsWatermark?: boolean; supportsSeed?: boolean; creditCostPerImage?: number; + creditDiscountRate?: number; creditCostPerVideo?: number; videoDurationOptions?: number[]; creditCostPerVideoByDuration?: Record; diff --git a/lib/model3d/model-url.ts b/lib/model3d/model-url.ts new file mode 100644 index 000000000..e73c145c7 --- /dev/null +++ b/lib/model3d/model-url.ts @@ -0,0 +1,43 @@ +export type ModelAssetFormat = "gltf" | "glb" | "usdz" | "fbx" | "obj" | "stl" | "3mf" | "3d"; + +export function normalizeModelAssetUrl(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized || null; +} + +export function extractModelAssetFormat(modelUrl?: string | null): ModelAssetFormat { + const value = String(modelUrl || "").toLowerCase(); + if (!value) return "3d"; + if (value.includes(".gltf")) return "gltf"; + if (value.includes(".glb")) return "glb"; + if (value.includes(".usdz")) return "usdz"; + if (value.includes(".fbx")) return "fbx"; + if (value.includes(".obj")) return "obj"; + if (value.includes(".stl")) return "stl"; + if (value.includes(".3mf")) return "3mf"; + return "3d"; +} + +export function resolvePreferredModelAssetUrl(params: { + primaryUrl?: string | null; + sourceUrl?: string | null; + fallbackUrl?: string | null; +}) { + const primaryUrl = normalizeModelAssetUrl(params.primaryUrl); + const sourceUrl = normalizeModelAssetUrl(params.sourceUrl); + const fallbackUrl = normalizeModelAssetUrl(params.fallbackUrl); + + if (sourceUrl) { + if (!primaryUrl) return sourceUrl; + if (primaryUrl !== sourceUrl) { + const primaryFormat = extractModelAssetFormat(primaryUrl); + const sourceFormat = extractModelAssetFormat(sourceUrl); + + if (primaryFormat === "gltf") return sourceUrl; + if (primaryFormat !== "glb" && sourceFormat === "glb") return sourceUrl; + } + } + + return primaryUrl || sourceUrl || fallbackUrl || null; +} diff --git a/lib/tripo/client.ts b/lib/tripo/client.ts index 7a1531131..b3911307b 100644 --- a/lib/tripo/client.ts +++ b/lib/tripo/client.ts @@ -170,10 +170,11 @@ export function pickTaskModelUrl(output?: Record | null): strin output.obj, output.stl, ]; + const normalizedCandidates: string[] = []; for (const item of candidates) { if (typeof item === "string") { const value = item.trim(); - if (value) return value; + if (value) normalizedCandidates.push(value); continue; } if (item && typeof item === "object") { @@ -181,10 +182,20 @@ export function pickTaskModelUrl(output?: Record | null): strin typeof (item as Record).url === "string" ? String((item as Record).url).trim() : ""; - if (nested) return nested; + if (nested) normalizedCandidates.push(nested); } } - return null; + const firstCandidate = normalizedCandidates[0] || null; + if (!firstCandidate) return null; + + if (/\.gltf(\?|#|$)/i.test(firstCandidate)) { + const embeddedCandidate = normalizedCandidates.find((value) => + /\.glb(\?|#|$)/i.test(value) + ); + if (embeddedCandidate) return embeddedCandidate; + } + + return firstCandidate; } export function pickTaskPreviewImageUrl(output?: Record | null): string | null {