feat: add discounted model pricing and fix realtime 3d media handling
This commit is contained in:
parent
d0bb8b7cf7
commit
1b1ff65de8
@ -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<string, number>; // 各秒数积分
|
||||
@ -15025,6 +15030,57 @@ export function AdminSettingsClient() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>积分折扣(折)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
placeholder="留空或 10 表示不打折"
|
||||
value={
|
||||
modelForm.imageGenConfig?.creditDiscountRate ?? ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
例如输入 5 表示 5折,7.5 表示 7.5折;前台模型列表会自动显示折扣标识。
|
||||
</p>
|
||||
{formatImageModelCreditDiscountLabel(
|
||||
modelForm.imageGenConfig?.creditDiscountRate
|
||||
) ? (
|
||||
<p className="text-xs text-emerald-600">
|
||||
当前生效:
|
||||
{formatImageModelCreditDiscountLabel(
|
||||
modelForm.imageGenConfig?.creditDiscountRate
|
||||
)}
|
||||
,图片单价将按{" "}
|
||||
{
|
||||
getGenerationCreditCostFromImageGenConfig(
|
||||
modelForm.imageGenConfig,
|
||||
"image"
|
||||
)
|
||||
}{" "}
|
||||
积分结算。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>每条视频积分消耗</Label>
|
||||
<Input
|
||||
@ -15370,6 +15426,18 @@ export function AdminSettingsClient() {
|
||||
duration,
|
||||
resolution
|
||||
)}{" "}
|
||||
{formatImageModelCreditDiscountLabel(
|
||||
modelForm.imageGenConfig?.creditDiscountRate
|
||||
)
|
||||
? `→ ${getGenerationCreditCostFromImageGenConfig(
|
||||
modelForm.imageGenConfig,
|
||||
"video",
|
||||
{
|
||||
duration,
|
||||
resolution,
|
||||
}
|
||||
)} `
|
||||
: ""}
|
||||
积分
|
||||
</td>
|
||||
)
|
||||
|
||||
@ -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<any | undefined> {
|
||||
@ -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,
|
||||
|
||||
@ -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<typeof imageGenConfigSchema>) {
|
||||
fallbackCost: config.creditCostPerVideo,
|
||||
});
|
||||
|
||||
return {
|
||||
return normalizeImageGenPricingConfig({
|
||||
...config,
|
||||
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
|
||||
creditCostPerVideoByDuration,
|
||||
creditCostPerVideoByResolution: normalizeVideoResolutionCostMap(
|
||||
config.creditCostPerVideoByResolution
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const createModelSchema = z.object({
|
||||
|
||||
@ -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<string, unknown>) };
|
||||
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<string, unknown>;
|
||||
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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<string, string>;
|
||||
}) {
|
||||
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<string, string>();
|
||||
|
||||
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(),
|
||||
|
||||
@ -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[] {
|
||||
|
||||
@ -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<string, number>;
|
||||
@ -3034,6 +3038,7 @@ export function CanvasChatPanel({
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
creditCostPerImage?: number;
|
||||
creditDiscountRate?: number;
|
||||
creditCostPerVideo?: number;
|
||||
videoDurationOptions?: number[];
|
||||
creditCostPerVideoByDuration?: Record<string, number>;
|
||||
@ -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))
|
||||
|
||||
@ -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<string, number> | 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,
|
||||
|
||||
@ -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({
|
||||
<img
|
||||
alt="实时预览"
|
||||
className="max-h-full max-w-full object-contain shadow-sm"
|
||||
src={previewUrl}
|
||||
src={previewDisplayUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">
|
||||
|
||||
@ -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<string, unknown>)
|
||||
: 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({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 z-30 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.45),0_0_10px_rgba(255,255,255,0.45)]"
|
||||
className="pointer-events-none absolute inset-y-0 z-30 w-px bg-white/95 shadow-[0_0_0_1px_rgba(15,23,42,0.18),0_0_6px_rgba(255,255,255,0.28)]"
|
||||
style={{
|
||||
left: `${compareSplitPercent}%`,
|
||||
transform: "translateX(-50%)",
|
||||
@ -7176,13 +7188,6 @@ export function ImageNode({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 提示词显示 */}
|
||||
{data.prompt && isHovered && !isProcessing && (
|
||||
<div className="absolute right-3 bottom-3 left-3 rounded-[16px] border border-[#dbe2ee] bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(219,226,238,0.26)]">
|
||||
<p className="line-clamp-2 text-xs leading-5 text-[#556072]">{data.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.note && !isSelected && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full border border-[#f5e0a7] bg-white px-2 py-1 text-[11px] text-[#c48917] shadow-[0_0_0_1px_rgba(245,224,167,0.22)]"
|
||||
|
||||
@ -30,6 +30,10 @@ import {
|
||||
resolveThreeDModelDisplayName,
|
||||
type PublicThreeDModelAliasConfig,
|
||||
} from "@/lib/model3d/model-alias-config";
|
||||
import {
|
||||
extractModelAssetFormat,
|
||||
resolvePreferredModelAssetUrl,
|
||||
} from "@/lib/model3d/model-url";
|
||||
import {
|
||||
Model3DViewer,
|
||||
type Model3DViewerSettings,
|
||||
@ -57,8 +61,8 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
|
||||
wireframe: false,
|
||||
};
|
||||
|
||||
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
|
||||
const raw = String(data.src || "").trim();
|
||||
function inferModelFileName(modelUrl: string | null, nodeId: string) {
|
||||
const raw = String(modelUrl || "").trim();
|
||||
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
@ -68,17 +72,9 @@ function inferModelFileName(data: Model3DNodeData, nodeId: string) {
|
||||
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
|
||||
}
|
||||
|
||||
function inferModelFormat(data: Model3DNodeData) {
|
||||
if (data.format) return data.format.toUpperCase();
|
||||
const src = String(data.src || "").toLowerCase();
|
||||
if (src.endsWith(".gltf")) return "GLTF";
|
||||
if (src.endsWith(".glb")) return "GLB";
|
||||
if (src.endsWith(".usdz")) return "USDZ";
|
||||
if (src.endsWith(".fbx")) return "FBX";
|
||||
if (src.endsWith(".obj")) return "OBJ";
|
||||
if (src.endsWith(".stl")) return "STL";
|
||||
if (src.endsWith(".3mf")) return "3MF";
|
||||
return "3D";
|
||||
function inferModelFormat(modelUrl: string | null, explicitFormat?: string) {
|
||||
if (explicitFormat) return explicitFormat.toUpperCase();
|
||||
return extractModelAssetFormat(modelUrl).toUpperCase();
|
||||
}
|
||||
|
||||
function readWorldAssetPayload(data: Model3DNodeData) {
|
||||
@ -149,16 +145,33 @@ export function Model3DNode({
|
||||
[data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels],
|
||||
);
|
||||
const worldAssetPayload = readWorldAssetPayload(data);
|
||||
const sourceModelUrl = readOptionalUrl(worldAssetPayload?.sourceModelUrl);
|
||||
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
|
||||
const worldImageUrl =
|
||||
readOptionalUrl(worldAssetPayload?.panoUrl) ||
|
||||
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
|
||||
readOptionalUrl(data.previewImage);
|
||||
const primaryOpenUrl = worldUrl || data.src || null;
|
||||
const resolvedModelUrl = useMemo(
|
||||
() =>
|
||||
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({
|
||||
>
|
||||
<Model3DViewer
|
||||
emptyStateTitle="准备查看 3D 模型"
|
||||
modelUrl={data.src}
|
||||
modelUrl={resolvedModelUrl || data.src}
|
||||
previewImageUrl={data.previewImage}
|
||||
settings={CANVAS_MODEL_VIEWER_SETTINGS}
|
||||
/>
|
||||
@ -314,12 +327,12 @@ export function Model3DNode({
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
|
||||
{inferModelFormat(data)}
|
||||
{modelFormatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 border-t px-3 py-2">
|
||||
<p className="truncate text-xs font-medium">
|
||||
{data.prompt || inferModelFileName(data, node.id)}
|
||||
{data.prompt || modelFileName}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
|
||||
@ -367,7 +380,7 @@ export function Model3DNode({
|
||||
/>
|
||||
{primaryOpenLabel}
|
||||
</DropdownMenuItem>
|
||||
{worldUrl && data.src && data.src !== worldUrl && (
|
||||
{worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && (
|
||||
<DropdownMenuItem onClick={handleOpenMesh}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircleFillIcon, ChevronDownIcon } from "@/components/icons";
|
||||
import { getImageModelCreditDiscountLabel } from "@/lib/ai/image-model-pricing";
|
||||
|
||||
// 从 API 获取的动态模型类型
|
||||
type DynamicModel = {
|
||||
@ -32,6 +33,9 @@ type DynamicModel = {
|
||||
isDefault: boolean;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
imageGenConfig?: {
|
||||
creditDiscountRate?: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
@ -217,8 +221,15 @@ export function ModelSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
|
||||
<CheckCircleFillIcon />
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig) ? (
|
||||
<span className="rounded-md border border-[#d0d4dc] bg-[#eef1f5] px-2 py-1 text-[11px] font-semibold leading-none text-[#5c6574]">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
|
||||
<CheckCircleFillIcon />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -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<string, number>;
|
||||
@ -211,6 +216,7 @@ function PureMultimodalInput({
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
creditCostPerImage?: number;
|
||||
creditDiscountRate?: number;
|
||||
creditCostPerVideo?: number;
|
||||
videoDurationOptions?: number[];
|
||||
creditCostPerVideoByDuration?: Record<string, number>;
|
||||
@ -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}
|
||||
</span>
|
||||
</span>
|
||||
{optimisticModelId === model.id ? (
|
||||
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-2">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig) ? (
|
||||
<span className="rounded-[10px] border border-[#c7ccd6] bg-[#eef1f5] px-2 py-0.5 text-[11px] font-semibold leading-none text-[#5b6472] shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]">
|
||||
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
|
||||
</span>
|
||||
) : null}
|
||||
{optimisticModelId === model.id ? (
|
||||
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
104
lib/ai/image-model-pricing.ts
Normal file
104
lib/ai/image-model-pricing.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<any | undefined> {
|
||||
|
||||
@ -4009,6 +4009,7 @@ interface ImageGenConfig {
|
||||
supportsWatermark?: boolean;
|
||||
supportsSeed?: boolean;
|
||||
creditCostPerImage?: number;
|
||||
creditDiscountRate?: number;
|
||||
creditCostPerVideo?: number;
|
||||
videoDurationOptions?: number[];
|
||||
creditCostPerVideoByDuration?: Record<string, number>;
|
||||
|
||||
43
lib/model3d/model-url.ts
Normal file
43
lib/model3d/model-url.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -170,10 +170,11 @@ export function pickTaskModelUrl(output?: Record<string, unknown> | 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<string, unknown> | null): strin
|
||||
typeof (item as Record<string, unknown>).url === "string"
|
||||
? String((item as Record<string, unknown>).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<string, unknown> | null): string | null {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user