feat: enhance workflow controls and media settings
This commit is contained in:
parent
3775485bdf
commit
6628a6b2d0
@ -193,8 +193,9 @@ const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
|
||||
const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
|
||||
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
|
||||
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
|
||||
type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number];
|
||||
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
|
||||
(typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number],
|
||||
AdminVideoResolution,
|
||||
number
|
||||
> = {
|
||||
"480p": 1,
|
||||
@ -202,6 +203,38 @@ const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
|
||||
"1080p": 2,
|
||||
};
|
||||
|
||||
function normalizeAdminVideoResolutions(value: unknown): AdminVideoResolution[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const allowed = new Set<string>(VIDEO_PRICING_PREVIEW_RESOLUTIONS);
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => String(item || "").trim().toLowerCase())
|
||||
.filter((item): item is AdminVideoResolution => allowed.has(item))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getAdminVideoSupportedResolutions(capabilities: unknown): AdminVideoResolution[] {
|
||||
if (!capabilities || typeof capabilities !== "object") {
|
||||
return [...VIDEO_PRICING_PREVIEW_RESOLUTIONS];
|
||||
}
|
||||
const configured = normalizeAdminVideoResolutions(
|
||||
(capabilities as { supportedResolutions?: unknown }).supportedResolutions
|
||||
);
|
||||
return configured.length ? configured : [...VIDEO_PRICING_PREVIEW_RESOLUTIONS];
|
||||
}
|
||||
|
||||
function updateAdminVideoSupportedResolutions(
|
||||
capabilities: Record<string, unknown> | null | undefined,
|
||||
nextResolutions: AdminVideoResolution[]
|
||||
) {
|
||||
return {
|
||||
...(capabilities || {}),
|
||||
supportedResolutions: nextResolutions,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number, fractionDigits = 2) {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
const fixed = value.toFixed(fractionDigits);
|
||||
@ -553,6 +586,107 @@ function applyRecommendedEnhanceProPricing(
|
||||
};
|
||||
}
|
||||
|
||||
type PointsCostField =
|
||||
| "creditCostPer3DModelPbr"
|
||||
| "creditCostPer3DModelSmartLowPoly"
|
||||
| "creditCostPer3DModelQuad"
|
||||
| "creditCostPer3DModelRefine"
|
||||
| "creditCostPer3DModelTexture"
|
||||
| "creditCostPer3DModelAutoRig"
|
||||
| "creditCostPer3DModelHighpolyToLowpoly"
|
||||
| "creditCostPerImageMultiView"
|
||||
| "creditCostPerImageRemoveBackground"
|
||||
| "creditCostPerImageBoxCutout"
|
||||
| "creditCostPerImageWatermarkRemove"
|
||||
| "creditCostPerImageExtend"
|
||||
| "creditCostPerImageRepaint";
|
||||
|
||||
const THREE_D_GENERATION_POINT_FIELDS: Array<{
|
||||
key: PointsCostField;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
key: "creditCostPer3DModelPbr",
|
||||
label: "PBR 材质",
|
||||
description: "勾选 PBR 材质时,在 3D 模型基础积分上额外扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPer3DModelSmartLowPoly",
|
||||
label: "智能低模",
|
||||
description: "勾选智能低模时额外扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPer3DModelQuad",
|
||||
label: "四边面优化",
|
||||
description: "勾选四边面优化时额外扣除。",
|
||||
},
|
||||
];
|
||||
|
||||
const THREE_D_EDIT_POINT_FIELDS: Array<{
|
||||
key: PointsCostField;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
key: "creditCostPer3DModelRefine",
|
||||
label: "模型精修",
|
||||
description: "提交 refine_model 精修任务时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPer3DModelTexture",
|
||||
label: "纹理重绘",
|
||||
description: "提交 texture_model 纹理重绘任务时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPer3DModelAutoRig",
|
||||
label: "自动绑定",
|
||||
description: "提交 animate_rig 自动绑定任务时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPer3DModelHighpolyToLowpoly",
|
||||
label: "模型减面",
|
||||
description: "提交 highpoly_to_lowpoly 减面任务时扣除。",
|
||||
},
|
||||
];
|
||||
|
||||
const IMAGE_TOOL_POINT_FIELDS: Array<{
|
||||
key: PointsCostField;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
key: "creditCostPerImageMultiView",
|
||||
label: "多视角",
|
||||
description: "角色三视图 / 多机位九宫格等多视角图像生成时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageRemoveBackground",
|
||||
label: "抠图",
|
||||
description: "调用图片抠图功能时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageBoxCutout",
|
||||
label: "框选抠图",
|
||||
description: "调用框选抠图功能时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageWatermarkRemove",
|
||||
label: "去水印",
|
||||
description: "调用去水印功能并成功创建任务后扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageExtend",
|
||||
label: "扩图",
|
||||
description: "调用图片拓展 / 扩图功能时扣除。",
|
||||
},
|
||||
{
|
||||
key: "creditCostPerImageRepaint",
|
||||
label: "重绘",
|
||||
description: "调用图片提示词重绘功能时扣除。",
|
||||
},
|
||||
];
|
||||
|
||||
type AiModel = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
@ -572,6 +706,7 @@ type AiModel = {
|
||||
isAgentModel?: boolean;
|
||||
agentCodingModelId?: string;
|
||||
isMultimodal?: boolean;
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
|
||||
};
|
||||
|
||||
@ -1791,6 +1926,7 @@ export function AdminSettingsClient() {
|
||||
() =>
|
||||
enhanceProConfig.apiKeys.map((apiKey, index) => ({
|
||||
id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
|
||||
index,
|
||||
name: `Krea Key ${index + 1}`,
|
||||
maskedKey: maskAdminSecret(apiKey),
|
||||
})),
|
||||
@ -2680,6 +2816,7 @@ export function AdminSettingsClient() {
|
||||
fallbackModelId1: "", // 备用模型 ID 1
|
||||
fallbackModelId2: "", // 备用模型 ID 2
|
||||
excludeFromFailover: false,
|
||||
capabilities: {} as Record<string, unknown>,
|
||||
// 🆕 图片生成模型特性配置
|
||||
imageGenConfig: createDefaultImageGenConfig(),
|
||||
});
|
||||
@ -2740,13 +2877,18 @@ export function AdminSettingsClient() {
|
||||
const isVideoProviderForModelConfig = isVideoModelDescriptor({
|
||||
providerType: selectedModelProviderType,
|
||||
modelId: modelForm.modelId,
|
||||
capabilities: modelForm.capabilities,
|
||||
supportsImageGen: modelForm.supportsImageGen,
|
||||
});
|
||||
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
|
||||
providerType: selectedModelProviderType,
|
||||
modelId: modelForm.modelId,
|
||||
capabilities: modelForm.capabilities,
|
||||
supportsImageGen: modelForm.supportsImageGen,
|
||||
});
|
||||
const modelVideoSupportedResolutions = getAdminVideoSupportedResolutions(
|
||||
modelForm.capabilities
|
||||
);
|
||||
const configuredVideoDurationOptions = getConfiguredVideoDurationOptions(
|
||||
modelForm.imageGenConfig
|
||||
);
|
||||
@ -3120,6 +3262,19 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
};
|
||||
|
||||
const updatePointsCostField = (key: PointsCostField, value: unknown) => {
|
||||
setSiteSettings((prev) => {
|
||||
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
|
||||
return {
|
||||
...prev,
|
||||
pointsSystemConfig: {
|
||||
...config,
|
||||
[key]: roundCreditCost(value),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateEnhanceProPointsBucket = (
|
||||
index: number,
|
||||
updater: (item: EnhanceProPointsResolutionBucket) => EnhanceProPointsResolutionBucket
|
||||
@ -3570,6 +3725,37 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEnhanceProPoolKey = () => {
|
||||
const normalizedKey = normalizeEnhanceProKreaConfig({
|
||||
apiKey: enhanceProConfig.apiKey,
|
||||
}).apiKey;
|
||||
|
||||
if (!normalizedKey) {
|
||||
toast.error("请先填写要加入 Key 池的 Krea Bearer Token");
|
||||
return;
|
||||
}
|
||||
|
||||
setEnhanceProConfig((prev) => {
|
||||
const nextKeys = normalizeEnhanceProKreaConfig({
|
||||
apiKeys: [...prev.apiKeys, normalizedKey],
|
||||
}).apiKeys;
|
||||
return {
|
||||
...prev,
|
||||
apiKey: "",
|
||||
apiKeys: nextKeys,
|
||||
};
|
||||
});
|
||||
setEnhanceProApiKeysDraft("");
|
||||
toast.success("Krea Key 已加入池,保存配置后生效");
|
||||
};
|
||||
|
||||
const handleDeleteEnhanceProPoolKey = (index: number) => {
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
apiKeys: prev.apiKeys.filter((_, itemIndex) => itemIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnhanceProPresetChange = <K extends keyof EnhanceProPreset>(
|
||||
index: number,
|
||||
key: K,
|
||||
@ -5539,6 +5725,7 @@ export function AdminSettingsClient() {
|
||||
fallbackModelId1: "",
|
||||
fallbackModelId2: "",
|
||||
excludeFromFailover: false,
|
||||
capabilities: {},
|
||||
imageGenConfig: createDefaultImageGenConfig(),
|
||||
});
|
||||
setVideoDurationOptionsInput("");
|
||||
@ -8746,6 +8933,108 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">
|
||||
3D 生成附加功能积分
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
这些费用会叠加到“3D模型单次积分消耗”上;填写 0 表示不额外扣分。
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{THREE_D_GENERATION_POINT_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-md border bg-muted/20 p-3"
|
||||
>
|
||||
<Label htmlFor={`points-${field.key}`}>
|
||||
{field.label}
|
||||
</Label>
|
||||
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
<Input
|
||||
id={`points-${field.key}`}
|
||||
min={0}
|
||||
type="number"
|
||||
value={pointsSystemConfig[field.key]}
|
||||
onChange={(e) =>
|
||||
updatePointsCostField(field.key, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">
|
||||
3D 编辑与优化积分
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
独立 3D 编辑任务按这里配置的单价扣除;填写 0 表示免费。
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
{THREE_D_EDIT_POINT_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-md border bg-muted/20 p-3"
|
||||
>
|
||||
<Label htmlFor={`points-${field.key}`}>
|
||||
{field.label}
|
||||
</Label>
|
||||
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
<Input
|
||||
id={`points-${field.key}`}
|
||||
min={0}
|
||||
type="number"
|
||||
value={pointsSystemConfig[field.key]}
|
||||
onChange={(e) =>
|
||||
updatePointsCostField(field.key, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">
|
||||
图像小功能积分
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
多视角、抠图、框选抠图、去水印、扩图、重绘按这里单独扣分;填写 0 表示免费。
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{IMAGE_TOOL_POINT_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-md border bg-muted/20 p-3"
|
||||
>
|
||||
<Label htmlFor={`points-${field.key}`}>
|
||||
{field.label}
|
||||
</Label>
|
||||
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
<Input
|
||||
id={`points-${field.key}`}
|
||||
min={0}
|
||||
type="number"
|
||||
value={pointsSystemConfig[field.key]}
|
||||
onChange={(e) =>
|
||||
updatePointsCostField(field.key, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
@ -12734,6 +13023,67 @@ export function AdminSettingsClient() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
前台分辨率显示
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
关闭某个档位后,前台视频参数条不会再显示该分辨率,服务端也会拒绝该档位。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{VIDEO_PRICING_PREVIEW_RESOLUTIONS.map(
|
||||
(resolutionKey) => {
|
||||
const checked = modelVideoSupportedResolutions.includes(
|
||||
resolutionKey
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={`video-resolution-${resolutionKey}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
const currentSet = new Set(
|
||||
modelVideoSupportedResolutions
|
||||
);
|
||||
if (nextChecked) {
|
||||
currentSet.add(resolutionKey);
|
||||
} else {
|
||||
currentSet.delete(resolutionKey);
|
||||
}
|
||||
const nextResolutions =
|
||||
VIDEO_PRICING_PREVIEW_RESOLUTIONS.filter(
|
||||
(item) => currentSet.has(item)
|
||||
);
|
||||
if (nextResolutions.length === 0) {
|
||||
toast.error(
|
||||
"视频模型至少保留一个可用分辨率"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setModelForm({
|
||||
...modelForm,
|
||||
capabilities:
|
||||
updateAdminVideoSupportedResolutions(
|
||||
modelForm.capabilities,
|
||||
nextResolutions
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">
|
||||
{resolutionKey}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>支持视频秒数</Label>
|
||||
<Input
|
||||
@ -13295,6 +13645,12 @@ export function AdminSettingsClient() {
|
||||
(model as any).fallbackModelId2 || "",
|
||||
excludeFromFailover:
|
||||
(model as any).excludeFromFailover || false,
|
||||
capabilities:
|
||||
(model as any).capabilities &&
|
||||
typeof (model as any).capabilities === "object" &&
|
||||
!Array.isArray((model as any).capabilities)
|
||||
? (model as any).capabilities
|
||||
: {},
|
||||
imageGenConfig:
|
||||
(model as any).imageGenConfig || createDefaultImageGenConfig(),
|
||||
});
|
||||
@ -15934,18 +16290,31 @@ export function AdminSettingsClient() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="enhanceProApiKey">API Key</Label>
|
||||
<Input
|
||||
id="enhanceProApiKey"
|
||||
type="password"
|
||||
value={enhanceProConfig.apiKey}
|
||||
onChange={(e) =>
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
apiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入 Krea Bearer Token"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
id="enhanceProApiKey"
|
||||
type="password"
|
||||
value={enhanceProConfig.apiKey}
|
||||
onChange={(e) =>
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
apiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入 Krea Bearer Token"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddEnhanceProPoolKey}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加为新 Key
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
和 Tripo3D 一样,单个 Key 可以直接加入池;批量导入仍支持一次粘贴多行。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border p-4">
|
||||
@ -16066,6 +16435,17 @@ export function AdminSettingsClient() {
|
||||
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteEnhanceProPoolKey(item.index)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
@ -4663,7 +4663,7 @@ export function AgentWorkflowSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<div className="text-sm font-medium">开启第一步:选择生图模式</div>
|
||||
@ -4708,17 +4708,17 @@ export function AgentWorkflowSettingsClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<div className="text-sm font-medium">开启套 PSD 模板</div>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<div className="text-sm font-medium">开启套 PSD 模板</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={workflowPageFeatureSettings.psdTemplateEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((prev) => ({
|
||||
checked={workflowPageFeatureSettings.psdTemplateEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
pageFeatureSettings: {
|
||||
...(prev.pageFeatureSettings ||
|
||||
@ -4727,9 +4727,31 @@ export function AgentWorkflowSettingsClient({
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<div className="text-sm font-medium">开启批量处理</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
关闭后前台顶部导航和无限画布工具栏都隐藏“批量处理”入口。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={workflowPageFeatureSettings.batchProcessingEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
pageFeatureSettings: {
|
||||
...(prev.pageFeatureSettings ||
|
||||
defaultAgentWorkflowPageFeatureSettings),
|
||||
batchProcessingEnabled: checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@ -41,6 +41,8 @@ const imageGenConfigSchema = z.object({
|
||||
loadingColor: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
|
||||
|
||||
function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
|
||||
if (!config) return config;
|
||||
|
||||
@ -79,6 +81,7 @@ const createModelSchema = z.object({
|
||||
isAgentModel: z.boolean().optional(),
|
||||
agentCodingModelId: z.string().max(128).optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
capabilities: capabilitiesSchema,
|
||||
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
|
||||
});
|
||||
|
||||
@ -101,6 +104,7 @@ const updateModelSchema = z.object({
|
||||
isAgentModel: z.boolean().optional(),
|
||||
agentCodingModelId: z.string().max(128).optional(),
|
||||
isMultimodal: z.boolean().optional(),
|
||||
capabilities: capabilitiesSchema,
|
||||
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
|
||||
});
|
||||
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { 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";
|
||||
import { getTripoProviderConfig } from "@/lib/tripo/server";
|
||||
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
|
||||
|
||||
@ -109,14 +107,6 @@ function assignMapped(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCharge3DPoints(taskType: CreateTaskRequest["taskType"]) {
|
||||
return (
|
||||
taskType === "text_to_model" ||
|
||||
taskType === "image_to_model" ||
|
||||
taskType === "multiview_to_model"
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -359,36 +349,21 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const shouldChargePoints = shouldCharge3DPoints(taskType);
|
||||
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel))
|
||||
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel)))
|
||||
: 0;
|
||||
|
||||
if (
|
||||
shouldChargePoints &&
|
||||
pointsConfig.enabled &&
|
||||
unitCost > 0 &&
|
||||
pointsConfig.blockOnInsufficient !== false
|
||||
) {
|
||||
const userData = await getUserById({ id: session.user.id });
|
||||
if (!userData?.isHighVip) {
|
||||
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
|
||||
|
||||
if (currentBalance < unitCost) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "积分不足",
|
||||
code: "insufficient_points",
|
||||
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||
balance: currentBalance,
|
||||
expectedCost: unitCost,
|
||||
unitCost,
|
||||
},
|
||||
{ status: 402 }
|
||||
);
|
||||
}
|
||||
}
|
||||
const unitCost = get3DTaskCreditCost(pointsConfig, {
|
||||
taskType,
|
||||
pbr: body.pbr,
|
||||
quad: body.quad,
|
||||
smartLowPoly: body.smartLowPoly,
|
||||
});
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost,
|
||||
reason: "model3d_task",
|
||||
modelId: "tripo-3d",
|
||||
note: `3D模型任务消耗积分(${taskType})`,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const config = await getTripoProviderConfig(
|
||||
@ -413,17 +388,13 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
const consumeResult = await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "model3d_generation",
|
||||
modelId: "tripo-3d",
|
||||
note: "3D模型生成消耗积分",
|
||||
const consumeResult = await pointsCharge.consume({
|
||||
note: `3D模型任务消耗积分(${taskType} / ${taskId})`,
|
||||
});
|
||||
|
||||
if (!consumeResult.ok) {
|
||||
if (!consumeResult?.ok) {
|
||||
console.warn(
|
||||
`[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}`
|
||||
);
|
||||
|
||||
@ -6,6 +6,9 @@ import {
|
||||
executeBoxCutoutJob,
|
||||
} from "@/lib/ai/box-cutout/service";
|
||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
@ -40,6 +43,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
|
||||
reason: "image_box_cutout",
|
||||
modelId: "box-cutout",
|
||||
note: "框选抠图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeBoxCutoutJob({
|
||||
userId: session.user.id,
|
||||
imageUrl: resolvedImageUrl,
|
||||
@ -52,6 +68,14 @@ export async function POST(request: NextRequest) {
|
||||
sourceNodeId: body.sourceNodeId,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume box cutout points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||
|
||||
@ -9,6 +9,12 @@ import {
|
||||
generateCanvasImage,
|
||||
type CanvasGenerateImageRequest,
|
||||
} from "@/lib/canvas/generate-image";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
getImageToolCreditCost,
|
||||
type ImageToolCreditFeature,
|
||||
} from "@/lib/points/config";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
@ -16,6 +22,13 @@ interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
function normalizeBillingFeature(
|
||||
value: unknown
|
||||
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
|
||||
if (value === "multi-view" || value === "repaint") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
|
||||
return {
|
||||
prompt: String(body.prompt || "").trim(),
|
||||
@ -34,6 +47,8 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
|
||||
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
|
||||
? body.imageCount
|
||||
: 1,
|
||||
promptExtend:
|
||||
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
|
||||
referenceImage:
|
||||
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
|
||||
referenceImages: Array.isArray(body.referenceImages)
|
||||
@ -107,6 +122,30 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const billingFeature = normalizeBillingFeature(body.billingFeature);
|
||||
const pointsCharge = billingFeature
|
||||
? await (async () => {
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
return prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
|
||||
reason:
|
||||
billingFeature === "multi-view"
|
||||
? "image_multi_view"
|
||||
: "image_repaint",
|
||||
modelId: "canvas-generate-image",
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? "图片多视角生成消耗积分"
|
||||
: "图片重绘消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (pointsCharge && !pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const asyncMode = body.async === true;
|
||||
if (asyncMode) {
|
||||
const task = await createCanvasImageTask({
|
||||
@ -114,6 +153,18 @@ export async function POST(request: Request) {
|
||||
request: payload,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note:
|
||||
billingFeature === "multi-view"
|
||||
? `图片多视角生成消耗积分(${task.id})`
|
||||
: `图片重绘消耗积分(${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image task points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
@ -122,6 +173,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const result = await generateCanvasImage(payload);
|
||||
if (pointsCharge?.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume canvas image points:", pointsError);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import {
|
||||
getFriendlyRateLimitMessage,
|
||||
getUserFacingRequestErrorMessage,
|
||||
@ -39,6 +42,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageUrl,
|
||||
@ -50,6 +66,14 @@ export async function POST(request: NextRequest) {
|
||||
yScale: body.yScale ? Number(body.yScale) : undefined,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
original_url: result.original_url,
|
||||
@ -68,6 +92,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
|
||||
reason: "image_extend",
|
||||
modelId: "image-extend",
|
||||
note: "图片扩图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeExtendJob({
|
||||
userId: session.user.id,
|
||||
imageBase64: buffer.toString("base64"),
|
||||
@ -76,6 +113,14 @@ export async function POST(request: NextRequest) {
|
||||
messageId: String(formData.get("messageId") || "") || null,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume extend points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
original_url: result.original_url,
|
||||
|
||||
@ -6,6 +6,9 @@ import {
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
@ -79,6 +82,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
|
||||
reason: "image_remove_background",
|
||||
modelId: "rmbg",
|
||||
note: "图片抠图消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
@ -90,6 +106,14 @@ export async function POST(request: NextRequest) {
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume();
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume RMBG points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
|
||||
@ -1103,6 +1103,7 @@ const updateSchema = z.object({
|
||||
presetStepEnabled: z.boolean().optional(),
|
||||
materialStepEnabled: z.boolean().optional(),
|
||||
psdTemplateEnabled: z.boolean().optional(),
|
||||
batchProcessingEnabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
steps: z
|
||||
|
||||
@ -6,9 +6,12 @@ import { eq } from "drizzle-orm";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import {
|
||||
createMediaAssetRelation,
|
||||
getPointsSystemConfig,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
upsertMediaAsset,
|
||||
} from "@/lib/db/queries";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
|
||||
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
|
||||
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
|
||||
@ -380,6 +383,19 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
console.log("[WatermarkRemove] 开始处理图片:", publicImageUrl);
|
||||
|
||||
const pointsConfig = await getPointsSystemConfig();
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost: getImageToolCreditCost(pointsConfig, "watermark-remove"),
|
||||
reason: "image_watermark_remove",
|
||||
modelId: "watermark-remove",
|
||||
note: "去水印消耗积分",
|
||||
pointsConfig,
|
||||
});
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
|
||||
}
|
||||
|
||||
// 创建去水印任务
|
||||
const formData = new FormData();
|
||||
formData.append("sync", "0");
|
||||
@ -416,6 +432,16 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `去水印任务消耗积分(${taskId})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[Points] Failed to consume watermark remove points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await finalizeWatermarkTaskResult({
|
||||
taskId,
|
||||
|
||||
@ -1557,6 +1557,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
presetStepEnabled?: boolean;
|
||||
materialStepEnabled?: boolean;
|
||||
psdTemplateEnabled?: boolean;
|
||||
batchProcessingEnabled?: boolean;
|
||||
};
|
||||
steps?: Array<{
|
||||
key: AgentWorkflowStepKey;
|
||||
@ -1614,6 +1615,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
workflowPageFeatureSettings.presetStepEnabled !== false;
|
||||
const isWorkflowMaterialStepEnabled =
|
||||
workflowPageFeatureSettings.materialStepEnabled !== false;
|
||||
const isBatchProcessingEnabled =
|
||||
workflowPageFeatureSettings.batchProcessingEnabled !== false;
|
||||
const sanitizedWorkflowSelections = useMemo(
|
||||
() =>
|
||||
sanitizeAgentWorkflowSelections({
|
||||
@ -2764,10 +2767,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
}, [isAgentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenBatchProcessing) {
|
||||
if (shouldOpenBatchProcessing && isBatchProcessingEnabled) {
|
||||
setSceneFusionDialogOpen(true);
|
||||
}
|
||||
}, [shouldOpenBatchProcessing]);
|
||||
}, [isBatchProcessingEnabled, shouldOpenBatchProcessing]);
|
||||
|
||||
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
|
||||
const buildPersistedCanvasData = useCallback(() => {
|
||||
@ -8458,6 +8461,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
<div className="min-h-screen bg-[#050607] text-white">
|
||||
<AgentCanvasTopNavDark
|
||||
activeKey="agent"
|
||||
batchProcessingEnabled={isBatchProcessingEnabled}
|
||||
rightSlot={agentProjectNavSlot}
|
||||
siteLogo={siteSettingsData?.siteLogo}
|
||||
siteLogoDark={siteSettingsData?.siteLogoDark}
|
||||
@ -9614,23 +9618,25 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() => setSceneFusionDialogOpen(true)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">批量处理</TooltipContent>
|
||||
</Tooltip>
|
||||
{isBatchProcessingEnabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||
: "h-9 w-9 rounded-xl"
|
||||
)}
|
||||
onClick={() => setSceneFusionDialogOpen(true)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">批量处理</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crosshair,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@ -615,6 +616,7 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
const [worldViewportMode, setWorldViewportMode] =
|
||||
useState<WorldViewportMode>("world");
|
||||
const [worldFirstPerson, setWorldFirstPerson] = useState(false);
|
||||
const [viewerResetToken, setViewerResetToken] = useState(0);
|
||||
|
||||
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
|
||||
@ -2393,6 +2395,12 @@ export default function CanvasModel3DPage() {
|
||||
worldViewportMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||
setWorldFirstPerson(false);
|
||||
}
|
||||
}, [workspaceMode, worldViewportMode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
|
||||
{/* Top Navigation Bar */}
|
||||
@ -3463,16 +3471,34 @@ export default function CanvasModel3DPage() {
|
||||
3D 世界控制
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 rounded-full border-white/10 bg-white/5 px-3 text-zinc-200 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => setViewerResetToken((prev) => prev + 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
回正
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-full border-white/10 px-3 text-zinc-200 hover:bg-white/10 hover:text-white",
|
||||
worldFirstPerson
|
||||
? "bg-yellow-500/20 text-yellow-100"
|
||||
: "bg-white/5"
|
||||
)}
|
||||
disabled={!currentPrimarySpzUrl || worldViewportMode !== "splat"}
|
||||
onClick={() => setWorldFirstPerson((prev) => !prev)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Crosshair className="mr-1.5 h-3.5 w-3.5" />
|
||||
漫游
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-full border-white/10 bg-white/5 px-3 text-zinc-200 hover:bg-white/10 hover:text-white"
|
||||
onClick={() => setViewerResetToken((prev) => prev + 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
回正
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
@ -3520,10 +3546,12 @@ export default function CanvasModel3DPage() {
|
||||
{worldViewportMode === "splat" ? (
|
||||
<>
|
||||
<div className="font-medium text-zinc-200">
|
||||
鼠标拖拽会围绕场景原点旋转
|
||||
{worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景原点旋转"}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
左键拖拽旋转,滚轮缩放;已关闭横向平移。需要切换模型时,直接点击右侧资产卡片。
|
||||
{worldFirstPerson
|
||||
? "点击画面后可锁定鼠标视角,WASD 或方向键移动,Q/R 上升,E/F 下降,Esc 退出鼠标锁定。"
|
||||
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
|
||||
</div>
|
||||
</>
|
||||
) : worldViewportMode === "mesh" ? (
|
||||
@ -3552,6 +3580,7 @@ export default function CanvasModel3DPage() {
|
||||
{workspaceMode === "world" ? (
|
||||
<World3DViewer
|
||||
emptyStateTitle={viewerEmptyStateTitle}
|
||||
firstPerson={worldViewportMode === "splat" && worldFirstPerson}
|
||||
imageUrl={currentWorldImageUrl}
|
||||
meshLayerVisibility={effectiveLayerVisibility}
|
||||
meshSettings={viewerSettings}
|
||||
|
||||
@ -12,6 +12,7 @@ interface AgentCanvasTopNavDarkProps {
|
||||
onBack?: () => void;
|
||||
rightSlot?: ReactNode;
|
||||
activeKey?: "featured" | "agent" | "canvas" | "gallery";
|
||||
batchProcessingEnabled?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@ -48,6 +49,7 @@ export function AgentCanvasTopNavDark({
|
||||
onBack,
|
||||
rightSlot = null,
|
||||
activeKey = "agent",
|
||||
batchProcessingEnabled = true,
|
||||
}: AgentCanvasTopNavDarkProps) {
|
||||
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
|
||||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||||
@ -94,7 +96,9 @@ export function AgentCanvasTopNavDark({
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-3">
|
||||
<nav className="hidden items-center gap-2 md:flex">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{NAV_ITEMS.filter(
|
||||
(item) => item.key !== "featured" || batchProcessingEnabled
|
||||
).map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.key === activeKey;
|
||||
return (
|
||||
|
||||
@ -4455,6 +4455,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
siteLogoDark={siteLogoDark}
|
||||
onBack={onBack}
|
||||
activeKey="agent"
|
||||
batchProcessingEnabled={resolvedWorkflowFeatureSettings.batchProcessingEnabled}
|
||||
rightSlot={topNavRightSlot || <div className="w-10" />}
|
||||
/>
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
flattenVisibleAgentWorkflowSteps,
|
||||
getAgentWorkflowBlockedOptionKeysForSelections,
|
||||
getAgentWorkflowBrandStoreEntries,
|
||||
getAgentWorkflowPageFeatureSettings,
|
||||
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory,
|
||||
getAgentWorkflowPriceBandOptions,
|
||||
getAgentWorkflowThirdCategoryOptionsByBrandStore,
|
||||
@ -608,6 +609,10 @@ export function AgentWorkflowPanelDarkV2({
|
||||
steps: getRenderableAgentWorkflowStepTree(source.steps || []),
|
||||
};
|
||||
}, [config]);
|
||||
const pageFeatureSettings = useMemo(
|
||||
() => getAgentWorkflowPageFeatureSettings(config),
|
||||
[config]
|
||||
);
|
||||
const flattenedResolvedSteps = useMemo(
|
||||
() =>
|
||||
flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter(
|
||||
@ -1314,6 +1319,7 @@ export function AgentWorkflowPanelDarkV2({
|
||||
siteLogo={siteLogo}
|
||||
siteLogoDark={siteLogoDark}
|
||||
activeKey="agent"
|
||||
batchProcessingEnabled={pageFeatureSettings.batchProcessingEnabled}
|
||||
rightSlot={topNavRightSlot}
|
||||
/>
|
||||
|
||||
|
||||
@ -397,6 +397,7 @@ export function EnhanceProWorkspace({
|
||||
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
|
||||
const [faceOpen, setFaceOpen] = useState(true);
|
||||
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
|
||||
const [comparePosition, setComparePosition] = useState(50);
|
||||
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
|
||||
const previewDragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
@ -434,6 +435,9 @@ export function EnhanceProWorkspace({
|
||||
: null;
|
||||
const activePreviewUrl =
|
||||
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
|
||||
const showComparisonPreview = Boolean(
|
||||
selectedGeneratedImage?.originalUrl && sourceImageUrl
|
||||
);
|
||||
const activePreviewLabel =
|
||||
selectedGeneratedImage && selectedResultIndex >= 0
|
||||
? getActiveResultLabel(selectedResultIndex)
|
||||
@ -518,6 +522,10 @@ export function EnhanceProWorkspace({
|
||||
previewDragStateRef.current = null;
|
||||
}, [activePreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setComparePosition(50);
|
||||
}, [selectedResultIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.face_enhancement) {
|
||||
setFaceOpen(true);
|
||||
@ -733,16 +741,62 @@ export function EnhanceProWorkspace({
|
||||
onPointerMove={handlePreviewPointerMove}
|
||||
onPointerUp={finishPreviewDrag}
|
||||
>
|
||||
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||||
<div className="overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
|
||||
<img
|
||||
alt={activePreviewLabel}
|
||||
className="pointer-events-none max-h-[calc(100vh-17rem)] max-w-[min(1000px,calc(100vw-35rem))] object-contain transition-transform duration-75"
|
||||
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
||||
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||||
<div className="relative overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
alt={activePreviewLabel}
|
||||
className="pointer-events-none block max-h-[calc(100vh-14rem)] max-w-[min(1200px,calc(100vw-31rem))] object-contain transition-transform duration-75"
|
||||
src={getDisplayImageUrl(activePreviewUrl, 1800)}
|
||||
/>
|
||||
{showComparisonPreview && sourceImageUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt="原图对比"
|
||||
className="h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(sourceImageUrl, 1800)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_18px_rgba(0,0,0,0.35)]"
|
||||
style={{ left: `${comparePosition}%` }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{showComparisonPreview ? (
|
||||
<>
|
||||
<div className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
||||
原图
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
|
||||
增强后
|
||||
</div>
|
||||
<input
|
||||
aria-label="原图和增强结果对比滑块"
|
||||
className="pointer-events-auto absolute right-6 bottom-5 left-6 accent-[#111111]"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
setComparePosition(Number(event.target.value) || 0)
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="range"
|
||||
value={comparePosition}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3.5 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm">
|
||||
|
||||
@ -407,7 +407,13 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
|
||||
const OFFICIAL_DROPDOWN_SCALE = 0.7;
|
||||
const OFFICIAL_NINE_GRID_WIDTH = 930;
|
||||
const OFFICIAL_NINE_GRID_HEIGHT = 520;
|
||||
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
|
||||
const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid";
|
||||
const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25";
|
||||
const CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY = "cinematic-lighting-correction";
|
||||
const CHARACTER_THREE_VIEW_FUNCTION_KEY = "character-three-view";
|
||||
const FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY = "frame-prediction-plus-3s";
|
||||
const FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY = "frame-prediction-minus-5s";
|
||||
const PROMPT_COMPOSER_BASE_WIDTH = 895;
|
||||
const PROMPT_COMPOSER_BASE_HEIGHT = 286;
|
||||
const PRESET_COMPOSER_BASE_HEIGHT = 238;
|
||||
@ -1033,6 +1039,7 @@ function createMediaResourceNode({
|
||||
function createNineGridCompositeNode({
|
||||
anchor,
|
||||
columns,
|
||||
functionKey = MULTI_VIEW_NINE_GRID_FUNCTION_KEY,
|
||||
gridPrompt,
|
||||
option,
|
||||
rows,
|
||||
@ -1042,6 +1049,7 @@ function createNineGridCompositeNode({
|
||||
}: {
|
||||
anchor: CloneNode;
|
||||
columns: number;
|
||||
functionKey?: string;
|
||||
gridPrompt?: string;
|
||||
option?: string;
|
||||
rows: number;
|
||||
@ -1073,6 +1081,7 @@ function createNineGridCompositeNode({
|
||||
accent: "#8790ff",
|
||||
draftValues: {
|
||||
action: "IMAGE_GENERATE_NINE_GRID",
|
||||
functionKey,
|
||||
generated: true,
|
||||
gridColumns: columns,
|
||||
gridPrompt: resolvedGridPrompt,
|
||||
@ -1084,6 +1093,7 @@ function createNineGridCompositeNode({
|
||||
outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "",
|
||||
params: {
|
||||
columns,
|
||||
functionKey,
|
||||
mode: "grid",
|
||||
option: option || "",
|
||||
prompt: resolvedGridPrompt,
|
||||
@ -1187,6 +1197,110 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
|
||||
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node);
|
||||
}
|
||||
|
||||
function getOfficialImagePresetFunctionKey(value?: unknown) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return null;
|
||||
if (/角色三视图|三视图生成|正侧背|character[-_\s]?(three|turnaround)/i.test(text)) {
|
||||
return CHARACTER_THREE_VIEW_FUNCTION_KEY;
|
||||
}
|
||||
if (/剧情推演|四宫格|4\s*宫格|2\s*[x×]\s*2/.test(text)) {
|
||||
return STORY_BEAT_GRID_FUNCTION_KEY;
|
||||
}
|
||||
if (/25\s*宫格|连贯分镜|5\s*[x×]\s*5/.test(text)) {
|
||||
return CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY;
|
||||
}
|
||||
if (/电影级光影|光影校正|cinematic lighting/i.test(text)) {
|
||||
return CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY;
|
||||
}
|
||||
if (/3\s*秒后|三秒后|\+\s*3|plus[-_\s]?3/i.test(text)) {
|
||||
return FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY;
|
||||
}
|
||||
if (/5\s*秒前|五秒前|-\s*5|minus[-_\s]?5/i.test(text)) {
|
||||
return FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY;
|
||||
}
|
||||
if (/多机位|九宫格|9\s*宫格|3\s*[x×]\s*3/.test(text)) {
|
||||
return MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveOfficialImagePresetFunctionKey({
|
||||
fallbackKey,
|
||||
option,
|
||||
title,
|
||||
}: {
|
||||
fallbackKey?: string;
|
||||
option?: unknown;
|
||||
title?: unknown;
|
||||
}) {
|
||||
const optionKey = getOfficialImagePresetFunctionKey(option);
|
||||
if (optionKey) return optionKey;
|
||||
const titleKey = getOfficialImagePresetFunctionKey(title);
|
||||
if (titleKey) return titleKey;
|
||||
const normalizedFallbackKey = String(fallbackKey || "").trim();
|
||||
return normalizedFallbackKey || null;
|
||||
}
|
||||
|
||||
function isCharacterThreeViewOption(value?: unknown) {
|
||||
return getOfficialImagePresetFunctionKey(value) === CHARACTER_THREE_VIEW_FUNCTION_KEY;
|
||||
}
|
||||
|
||||
function getImageGenerationBillingFeature(functionKey: string) {
|
||||
if (
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ||
|
||||
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY
|
||||
) {
|
||||
return "multi-view" as const;
|
||||
}
|
||||
if (functionKey === "image-repaint") {
|
||||
return "repaint" as const;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isWideImageGenerationFunction(functionKey: string) {
|
||||
return (
|
||||
functionKey === STORY_BEAT_GRID_FUNCTION_KEY ||
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ||
|
||||
functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY ||
|
||||
functionKey === CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY ||
|
||||
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ||
|
||||
functionKey === FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY ||
|
||||
functionKey === FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY
|
||||
);
|
||||
}
|
||||
|
||||
function getPresetGridLayout(functionKey: string) {
|
||||
if (functionKey === STORY_BEAT_GRID_FUNCTION_KEY) return { columns: 2, rows: 2 };
|
||||
if (functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY) return { columns: 3, rows: 3 };
|
||||
if (functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY) return { columns: 5, rows: 5 };
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPresetGridFunction(functionKey: string) {
|
||||
return Boolean(getPresetGridLayout(functionKey));
|
||||
}
|
||||
|
||||
function getNodeParams(node: CloneNode | null | undefined) {
|
||||
const params = node?.draftValues?.params;
|
||||
return params && typeof params === "object" && !Array.isArray(params)
|
||||
? (params as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function getNodeFunctionKey(node: CloneNode | null | undefined) {
|
||||
const params = getNodeParams(node);
|
||||
return String(params.functionKey || node?.draftValues?.functionKey || "").trim();
|
||||
}
|
||||
|
||||
function isCharacterThreeViewNode(node: CloneNode | null | undefined) {
|
||||
if (!node || node.inputType !== "image") return false;
|
||||
return (
|
||||
getNodeFunctionKey(node) === CHARACTER_THREE_VIEW_FUNCTION_KEY ||
|
||||
isCharacterThreeViewOption(node?.title)
|
||||
);
|
||||
}
|
||||
|
||||
function getConfiguredFunction(
|
||||
config: VideoCanvasFunctionConfig,
|
||||
key: string,
|
||||
@ -1293,19 +1407,23 @@ function composeFunctionPrompt(
|
||||
return `${base}\n\n用户补充:${extra}`;
|
||||
}
|
||||
|
||||
function composeConfiguredImagePresetPrompt(
|
||||
definition: VideoCanvasFunctionDefinition,
|
||||
userPrompt?: string,
|
||||
) {
|
||||
const base = definition.prompt.trim() || getDefaultFunctionPrompt(definition.key);
|
||||
const extra = String(userPrompt || "").trim();
|
||||
const extraFunctionKey = getOfficialImagePresetFunctionKey(extra);
|
||||
if (!extra || extra === base || extra === definition.label || extraFunctionKey === definition.key) return base;
|
||||
if (!base) return extra;
|
||||
return `${base}\n\n用户补充:${extra}`;
|
||||
}
|
||||
|
||||
function composeMultiViewNineGridPrompt(
|
||||
definition: VideoCanvasFunctionDefinition,
|
||||
option?: string,
|
||||
) {
|
||||
const configuredPrompt = definition.prompt.trim() || getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY);
|
||||
const selectedOption = String(option || "").trim();
|
||||
const parts = [configuredPrompt];
|
||||
|
||||
if (selectedOption) {
|
||||
parts.push(`用户选择:${selectedOption}`);
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
return composeConfiguredImagePresetPrompt(definition, option);
|
||||
}
|
||||
|
||||
function getFirstGeneratedImageUrl(
|
||||
@ -1616,15 +1734,50 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac
|
||||
|
||||
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
|
||||
const action = getDraftString(node, "action");
|
||||
const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node);
|
||||
const isNineGridResultNode =
|
||||
action === "IMAGE_GENERATE_NINE_GRID" ||
|
||||
Boolean(node.draftValues?.nineGridComposite) ||
|
||||
(node.inputType === "image" && /多机位九宫格/.test(node.title || ""));
|
||||
!isCharacterThreeViewResultNode &&
|
||||
(action === "IMAGE_GENERATE_NINE_GRID" ||
|
||||
Boolean(node.draftValues?.nineGridComposite) ||
|
||||
(node.inputType === "image" && /多机位九宫格/.test(node.title || "")));
|
||||
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
|
||||
return normalizeTextResourceNode(node, fallbackIndex);
|
||||
}
|
||||
|
||||
if (isCharacterThreeViewResultNode) {
|
||||
return {
|
||||
...node,
|
||||
width: OFFICIAL_NINE_GRID_WIDTH,
|
||||
height: OFFICIAL_NINE_GRID_HEIGHT,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
action: "IMAGE_GENERATE",
|
||||
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
|
||||
gridColumns: undefined,
|
||||
gridRows: undefined,
|
||||
nineGridCells: undefined,
|
||||
nineGridComposite: false,
|
||||
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536",
|
||||
params: {
|
||||
...getNodeParams(node),
|
||||
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
|
||||
mode: "character-three-view",
|
||||
prompt: getDraftString(node, "prompt") || getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isNineGridResultNode) {
|
||||
const params = getNodeParams(node);
|
||||
const gridFunctionKey =
|
||||
resolveOfficialImagePresetFunctionKey({
|
||||
fallbackKey: getNodeFunctionKey(node),
|
||||
option: params.option || node.draftValues?.toolbarOption,
|
||||
title: node.title,
|
||||
}) ||
|
||||
MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
|
||||
const gridLayout = getPresetGridLayout(gridFunctionKey);
|
||||
return {
|
||||
...node,
|
||||
width: OFFICIAL_NINE_GRID_WIDTH,
|
||||
@ -1632,9 +1785,18 @@ function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNod
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
action: "IMAGE_GENERATE_NINE_GRID",
|
||||
gridPrompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY),
|
||||
functionKey: gridFunctionKey,
|
||||
gridColumns: getDraftNumber(node, "gridColumns", gridLayout?.columns || 3),
|
||||
gridPrompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey),
|
||||
gridRows: getDraftNumber(node, "gridRows", gridLayout?.rows || 3),
|
||||
nineGridComposite: true,
|
||||
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536",
|
||||
params: {
|
||||
...params,
|
||||
functionKey: gridFunctionKey,
|
||||
mode: "grid",
|
||||
prompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1764,8 +1926,9 @@ function createStarterWorkflow(
|
||||
}
|
||||
|
||||
if (starterTitle === "角色三视图") {
|
||||
const preset = "character-three-view";
|
||||
const preset = CHARACTER_THREE_VIEW_FUNCTION_KEY;
|
||||
const distance = 100;
|
||||
const prompt = getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY);
|
||||
const left = createPresetMediaNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: 520,
|
||||
@ -1777,18 +1940,32 @@ function createStarterWorkflow(
|
||||
x: Math.round(center.x - 874 - distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
const right = createPresetMediaNode({
|
||||
const rightBase = createPresetMediaNode({
|
||||
action: "IMAGE_GENERATE",
|
||||
generated: true,
|
||||
height: 520,
|
||||
mediaType: "image",
|
||||
mediaUrl: CHARACTER_THREE_VIEW_URL,
|
||||
preset,
|
||||
title: "角色三视图",
|
||||
width: 660,
|
||||
prompt,
|
||||
title: "角色三视图生成",
|
||||
width: OFFICIAL_NINE_GRID_WIDTH,
|
||||
x: Math.round(center.x + distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
const right: CloneNode = {
|
||||
...rightBase,
|
||||
draftValues: {
|
||||
...(rightBase.draftValues || {}),
|
||||
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
|
||||
params: {
|
||||
...getNodeParams(rightBase),
|
||||
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
|
||||
mode: "character-three-view",
|
||||
prompt,
|
||||
},
|
||||
},
|
||||
};
|
||||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||||
}
|
||||
|
||||
@ -2681,67 +2858,215 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
};
|
||||
const title = labels[action];
|
||||
if (action === "grid") {
|
||||
const { columns, rows } = normalizeGridOption(option || "9宫格");
|
||||
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, MULTI_VIEW_NINE_GRID_FUNCTION_KEY);
|
||||
const prompt = composeMultiViewNineGridPrompt(gridDefinition, option);
|
||||
const gridNode = createNineGridCompositeNode({
|
||||
anchor,
|
||||
columns,
|
||||
gridPrompt: prompt,
|
||||
option,
|
||||
rows,
|
||||
sourceUrl,
|
||||
const functionKey =
|
||||
getOfficialImagePresetFunctionKey(option) || MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
|
||||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||||
const prompt =
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
? composeMultiViewNineGridPrompt(definition, option)
|
||||
: composeConfiguredImagePresetPrompt(definition, option);
|
||||
const shouldGenerate = videoCanvasFunctionConfig.enabled && definition.enabled;
|
||||
const resultTitle = option?.trim() || definition.label || title;
|
||||
const gridLayout = getPresetGridLayout(functionKey);
|
||||
|
||||
if (gridLayout) {
|
||||
const gridNode = createNineGridCompositeNode({
|
||||
anchor,
|
||||
columns: gridLayout.columns,
|
||||
functionKey,
|
||||
gridPrompt: prompt,
|
||||
option: resultTitle,
|
||||
rows: gridLayout.rows,
|
||||
sourceUrl,
|
||||
x: rightX,
|
||||
y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
|
||||
});
|
||||
const pendingGridNode: CloneNode = {
|
||||
...gridNode,
|
||||
draftValues: {
|
||||
...(gridNode.draftValues || {}),
|
||||
functionKey,
|
||||
generated: !shouldGenerate,
|
||||
generationProgress: shouldGenerate ? 2 : 100,
|
||||
generationStatus: shouldGenerate ? "pending" : "complete",
|
||||
gridPrompt: prompt,
|
||||
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"),
|
||||
params: {
|
||||
...getNodeParams(gridNode),
|
||||
functionKey,
|
||||
mode: "grid",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
pendingLabel: "生成中",
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
toolbarOption: resultTitle,
|
||||
url: shouldGenerate ? [] : gridNode.draftValues?.url,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((current) => [...current, pendingGridNode]);
|
||||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]);
|
||||
setSelectedNodeId(pendingGridNode.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
|
||||
if (shouldGenerate) {
|
||||
const response = await imageGenerationService.generateImage({
|
||||
aspectRatio: "16:9",
|
||||
billingFeature: getImageGenerationBillingFeature(functionKey),
|
||||
imageCount: 1,
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
});
|
||||
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (generatedUrl) {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
functionKey,
|
||||
generated: true,
|
||||
generationProgress: 100,
|
||||
generationStatus: "complete",
|
||||
gridPrompt: prompt,
|
||||
mediaUrl: generatedUrl,
|
||||
params: {
|
||||
...getNodeParams(node),
|
||||
functionKey,
|
||||
mode: "grid",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
},
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
toolbarOption: resultTitle,
|
||||
url: [generatedUrl],
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const errorMessage = response.error || `${definition.label}生成未返回结果`;
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
generationStatus: "failed",
|
||||
generationError: errorMessage,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
toast.error(`${errorMessage},已保留占位节点`);
|
||||
}
|
||||
}
|
||||
toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resultNode = createMediaResourceNode({
|
||||
action: "IMAGE_GENERATE",
|
||||
height: OFFICIAL_NINE_GRID_HEIGHT,
|
||||
mediaType: "image",
|
||||
mediaUrl: "",
|
||||
prompt,
|
||||
title: resultTitle,
|
||||
width: OFFICIAL_NINE_GRID_WIDTH,
|
||||
x: rightX,
|
||||
y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
|
||||
});
|
||||
const shouldGenerate = videoCanvasFunctionConfig.enabled && gridDefinition?.enabled;
|
||||
const pendingGridNode: CloneNode = {
|
||||
...gridNode,
|
||||
const pendingNode: CloneNode = {
|
||||
...resultNode,
|
||||
subtitle: shouldGenerate ? "生成中" : "生成结果",
|
||||
draftValues: {
|
||||
...(gridNode.draftValues || {}),
|
||||
generated: !shouldGenerate,
|
||||
generationProgress: shouldGenerate ? 2 : 100,
|
||||
generationStatus: shouldGenerate ? "pending" : "complete",
|
||||
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"),
|
||||
...(resultNode.draftValues || {}),
|
||||
action: "IMAGE_GENERATE",
|
||||
functionKey,
|
||||
generated: false,
|
||||
generationProgress: shouldGenerate ? 2 : 0,
|
||||
generationStatus: shouldGenerate ? "pending" : "idle",
|
||||
mediaType: "image",
|
||||
mediaUrl: "",
|
||||
nodeType: "image",
|
||||
outputSizeLabel: "2752 × 1536",
|
||||
params: {
|
||||
functionKey,
|
||||
mode: functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ? "character-three-view" : "image",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
pendingLabel: "生成中",
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
url: shouldGenerate ? [] : gridNode.draftValues?.url,
|
||||
toolbarOption: resultTitle,
|
||||
url: [],
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((current) => [...current, pendingGridNode]);
|
||||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]);
|
||||
setSelectedNodeId(pendingGridNode.id);
|
||||
setNodes((current) => [...current, pendingNode]);
|
||||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]);
|
||||
setSelectedNodeId(pendingNode.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
|
||||
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) {
|
||||
if (shouldGenerate) {
|
||||
const response = await imageGenerationService.generateImage({
|
||||
aspectRatio: "16:9",
|
||||
billingFeature: getImageGenerationBillingFeature(functionKey),
|
||||
imageCount: 1,
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, gridDefinition),
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
});
|
||||
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (generatedUrl) {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
? {
|
||||
...node,
|
||||
subtitle: "生成结果",
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
functionKey,
|
||||
generated: true,
|
||||
generationProgress: 100,
|
||||
generationStatus: "complete",
|
||||
mediaUrl: generatedUrl,
|
||||
params: {
|
||||
...getNodeParams(node),
|
||||
functionKey,
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
},
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
toolbarOption: resultTitle,
|
||||
url: [generatedUrl],
|
||||
},
|
||||
}
|
||||
@ -2749,10 +3074,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const errorMessage = response.error || "九宫格生成未返回结果";
|
||||
const errorMessage = response.error || `${definition.label}生成未返回结果`;
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
@ -2764,10 +3089,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
: node
|
||||
)
|
||||
);
|
||||
toast.error(`${errorMessage},已保留可切分占位节点`);
|
||||
toast.error(`${errorMessage},已保留占位节点`);
|
||||
}
|
||||
}
|
||||
toast.success(shouldGenerate ? "已创建生成中占位节点" : "已按预设提示词生成多机位九宫格结果图");
|
||||
toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2844,9 +3169,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
} else if (definition.route === "canvas-generate-image") {
|
||||
const response = await imageGenerationService.generateImage({
|
||||
aspectRatio: "16:9",
|
||||
billingFeature: getImageGenerationBillingFeature(functionKey),
|
||||
imageCount: 1,
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
@ -2955,30 +3282,40 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const starterMode = getPresetMode(anchor);
|
||||
const slashScene = payload.slashScene || "";
|
||||
const promptInput = payload.prompt.trim();
|
||||
const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene);
|
||||
const functionKey =
|
||||
payload.slashScene && /九宫格|宫格|多机位/.test(payload.slashScene)
|
||||
? MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
: starterMode === "character-three-view"
|
||||
? "character-three-view"
|
||||
: "image-repaint";
|
||||
slashFunctionKey ||
|
||||
(starterMode === CHARACTER_THREE_VIEW_FUNCTION_KEY
|
||||
? CHARACTER_THREE_VIEW_FUNCTION_KEY
|
||||
: "image-repaint");
|
||||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||||
const prompt = composeFunctionPrompt(
|
||||
definition,
|
||||
payload.prompt.trim() || payload.slashScene || "生成角色三视图",
|
||||
);
|
||||
const isWideGeneration = isWideImageGenerationFunction(functionKey);
|
||||
const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : 420;
|
||||
const resultHeight = isWideGeneration ? OFFICIAL_NINE_GRID_HEIGHT : 280;
|
||||
const resultTitle = slashScene || definition.label || "图片生成";
|
||||
const gridLayout = getPresetGridLayout(functionKey);
|
||||
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
|
||||
const finalPrompt =
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
? composeMultiViewNineGridPrompt(definition, payload.prompt.trim() || payload.slashScene || "多机位九宫格")
|
||||
: prompt;
|
||||
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
|
||||
? composeMultiViewNineGridPrompt(definition, promptInput || slashScene || "多机位九宫格")
|
||||
: isWideGeneration
|
||||
? composeConfiguredImagePresetPrompt(definition, promptInput || slashScene || definition.label)
|
||||
: composeFunctionPrompt(
|
||||
definition,
|
||||
promptInput || slashScene || "生成角色三视图",
|
||||
);
|
||||
let generatedUrl = "";
|
||||
|
||||
if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
|
||||
const response = await imageGenerationService.generateImage({
|
||||
aspectRatio: payload.settings.ratio,
|
||||
imageCount: count,
|
||||
aspectRatio: isWideGeneration ? "16:9" : payload.settings.ratio,
|
||||
billingFeature: getImageGenerationBillingFeature(functionKey),
|
||||
imageCount: isWideGeneration ? 1 : count,
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model),
|
||||
prompt: finalPrompt,
|
||||
promptExtend: !isWideGeneration,
|
||||
referenceImage: sourceUrl,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
@ -2990,38 +3327,74 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const generatedNode = generatedUrl
|
||||
? createMediaResourceNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_HEIGHT : 280,
|
||||
height: resultHeight,
|
||||
mediaType: "image",
|
||||
mediaUrl: generatedUrl,
|
||||
prompt: finalPrompt,
|
||||
title: payload.slashScene || definition.label || "图片生成",
|
||||
width: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_WIDTH : 420,
|
||||
title: resultTitle,
|
||||
width: resultWidth,
|
||||
x: rightX,
|
||||
y: Math.round(anchor.y + anchor.height / 2 - (functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_HEIGHT : 280) / 2),
|
||||
y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2),
|
||||
})
|
||||
: null;
|
||||
const created = generatedNode || createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||||
const placeholderNode = isWideGeneration
|
||||
? createMediaResourceNode({
|
||||
action:
|
||||
isPresetGridFunction(functionKey)
|
||||
? "IMAGE_GENERATE_NINE_GRID"
|
||||
: "IMAGE_GENERATE",
|
||||
height: resultHeight,
|
||||
mediaType: "image",
|
||||
mediaUrl: "",
|
||||
prompt: finalPrompt,
|
||||
title: resultTitle,
|
||||
width: resultWidth,
|
||||
x: rightX,
|
||||
y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2),
|
||||
})
|
||||
: createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||||
const created = generatedNode || placeholderNode;
|
||||
const resultNode: CloneNode = {
|
||||
...created,
|
||||
title: payload.slashScene || definition.label || "图片生成",
|
||||
title: resultTitle,
|
||||
y:
|
||||
generatedNode?.y ??
|
||||
Math.round(anchor.y + anchor.height / 2 - created.height / 2),
|
||||
(isWideGeneration
|
||||
? placeholderNode.y
|
||||
: Math.round(anchor.y + anchor.height / 2 - created.height / 2)),
|
||||
draftValues: {
|
||||
...(created.draftValues || {}),
|
||||
functionKey,
|
||||
generated: Boolean(generatedUrl) || created.draftValues?.generated,
|
||||
generationStatus: generatedUrl ? "complete" : created.draftValues?.generationStatus,
|
||||
gridColumns: gridLayout?.columns || created.draftValues?.gridColumns,
|
||||
gridPrompt: gridLayout ? finalPrompt : created.draftValues?.gridPrompt,
|
||||
gridRows: gridLayout?.rows || created.draftValues?.gridRows,
|
||||
nineGridCells: gridLayout
|
||||
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index))
|
||||
: created.draftValues?.nineGridCells,
|
||||
nineGridComposite: gridLayout ? true : created.draftValues?.nineGridComposite,
|
||||
outputSizeLabel: isWideGeneration ? "2752 × 1536" : created.draftValues?.outputSizeLabel,
|
||||
params: {
|
||||
functionKey,
|
||||
mode:
|
||||
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY
|
||||
? "character-three-view"
|
||||
: isPresetGridFunction(functionKey)
|
||||
? "grid"
|
||||
: "image",
|
||||
prompt: finalPrompt,
|
||||
references: payload.references,
|
||||
settings: payload.settings,
|
||||
slashScene: payload.slashScene,
|
||||
slashScene,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
prompt: finalPrompt,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
targetPreset: starterMode || undefined,
|
||||
toolbarOption: payload.slashScene || "",
|
||||
toolbarOption: slashScene,
|
||||
},
|
||||
};
|
||||
const referenceEdges = payload.references
|
||||
@ -3233,8 +3606,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
try {
|
||||
if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) {
|
||||
const savedFunctionKey = String(params.functionKey || node.draftValues?.functionKey || "").trim();
|
||||
const explicitFunctionKey =
|
||||
resolveOfficialImagePresetFunctionKey({
|
||||
fallbackKey: savedFunctionKey,
|
||||
option: params.option || node.draftValues?.toolbarOption,
|
||||
title: node.title,
|
||||
}) || savedFunctionKey;
|
||||
const functionKey =
|
||||
action === "IMAGE_GENERATE_NINE_GRID"
|
||||
explicitFunctionKey &&
|
||||
(videoCanvasFunctionConfig.functions[explicitFunctionKey] ||
|
||||
defaultVideoCanvasFunctionConfig.functions[explicitFunctionKey])
|
||||
? explicitFunctionKey
|
||||
: action === "IMAGE_GENERATE_NINE_GRID"
|
||||
? MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
: node.draftValues?.generatorType === "ENHANCE"
|
||||
? "hd-upscale"
|
||||
@ -3244,7 +3628,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
const prompt =
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
? composeMultiViewNineGridPrompt(definition, String(params.option || node.title))
|
||||
: isWideImageGenerationFunction(functionKey)
|
||||
? composeConfiguredImagePresetPrompt(definition, rawPrompt || String(params.option || node.title))
|
||||
: composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title));
|
||||
const gridLayout = getPresetGridLayout(functionKey);
|
||||
let generatedUrl = "";
|
||||
|
||||
if (definition.route === "enhance" && sourceUrl) {
|
||||
@ -3253,10 +3640,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败");
|
||||
} else {
|
||||
const response = await imageGenerationService.generateImage({
|
||||
aspectRatio: action === "IMAGE_GENERATE_NINE_GRID" ? "16:9" : undefined,
|
||||
aspectRatio: isWideImageGenerationFunction(functionKey) ? "16:9" : undefined,
|
||||
billingFeature: getImageGenerationBillingFeature(functionKey),
|
||||
imageCount: 1,
|
||||
model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
|
||||
prompt,
|
||||
promptExtend: !isWideImageGenerationFunction(functionKey),
|
||||
referenceImage: sourceUrl || undefined,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
@ -3273,10 +3662,28 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
subtitle: "生成结果",
|
||||
draftValues: {
|
||||
...(item.draftValues || {}),
|
||||
functionKey,
|
||||
generated: true,
|
||||
generationProgress: 100,
|
||||
generationStatus: "complete",
|
||||
gridColumns: gridLayout?.columns || item.draftValues?.gridColumns,
|
||||
gridPrompt: gridLayout ? prompt : item.draftValues?.gridPrompt,
|
||||
gridRows: gridLayout?.rows || item.draftValues?.gridRows,
|
||||
nineGridCells: gridLayout
|
||||
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index))
|
||||
: item.draftValues?.nineGridCells,
|
||||
nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite,
|
||||
mediaType: "image",
|
||||
mediaUrl: generatedUrl,
|
||||
nodeType: "image",
|
||||
outputSizeLabel: isWideImageGenerationFunction(functionKey)
|
||||
? "2752 × 1536"
|
||||
: item.draftValues?.outputSizeLabel,
|
||||
params: {
|
||||
...getNodeParams(item),
|
||||
functionKey,
|
||||
prompt,
|
||||
},
|
||||
prompt,
|
||||
sourceUrl,
|
||||
url: [generatedUrl],
|
||||
@ -4523,8 +4930,8 @@ function PresetWorkflowControls({
|
||||
onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void;
|
||||
selectedNode: CloneNode;
|
||||
}) {
|
||||
if (mode === "character-three-view") {
|
||||
const definition = getConfiguredFunction(functionConfig, "character-three-view");
|
||||
if (mode === CHARACTER_THREE_VIEW_FUNCTION_KEY) {
|
||||
const definition = getConfiguredFunction(functionConfig, CHARACTER_THREE_VIEW_FUNCTION_KEY);
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||||
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
|
||||
@ -7382,7 +7789,7 @@ function PresetMediaCanvasNode({
|
||||
) : mediaUrl ? (
|
||||
<img
|
||||
alt={node.title}
|
||||
className={cn("pointer-events-none h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
||||
className={cn("pointer-events-none h-full w-full", isCharacterThreeViewNode(node) ? "object-contain" : "object-cover")}
|
||||
draggable={false}
|
||||
onDragStart={preventNativeImageDrag}
|
||||
src={mediaUrl}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Circle as CircleIcon,
|
||||
Eraser,
|
||||
FolderOpen,
|
||||
@ -850,8 +851,6 @@ export function RealtimeCanvasDialog({
|
||||
defaultRealtimeConfig.transport.throttleInterval
|
||||
);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [siteName, setSiteName] = useState("AI Chatbot");
|
||||
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
||||
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
|
||||
const [modeIconConfig, setModeIconConfig] =
|
||||
useState<RealtimeCanvasModeIconConfig>({
|
||||
@ -908,17 +907,6 @@ export function RealtimeCanvasDialog({
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!data || cancelled) return;
|
||||
if (typeof data.siteName === "string" && data.siteName.trim()) {
|
||||
setSiteName(data.siteName.trim());
|
||||
}
|
||||
if (typeof data.siteLogo === "string" && data.siteLogo.trim()) {
|
||||
setSiteLogo(data.siteLogo.trim());
|
||||
} else if (
|
||||
typeof data.siteLogoDark === "string" &&
|
||||
data.siteLogoDark.trim()
|
||||
) {
|
||||
setSiteLogo(data.siteLogoDark.trim());
|
||||
}
|
||||
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
|
||||
const iconConfig =
|
||||
data.realtimeCanvasModeIconConfig &&
|
||||
@ -3010,21 +2998,8 @@ export function RealtimeCanvasDialog({
|
||||
}, [previewUrl]);
|
||||
|
||||
const handleCloseRealtime = useCallback(() => {
|
||||
if (standalone && typeof window !== "undefined") {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
window.location.assign("/canvas");
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange, standalone]);
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.location.assign("/");
|
||||
}, []);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const closeBrushSettingPopovers = useCallback(() => {
|
||||
setBrushPopoverOpen(false);
|
||||
@ -3157,27 +3132,15 @@ export function RealtimeCanvasDialog({
|
||||
>
|
||||
<button
|
||||
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white"
|
||||
onClick={handleGoHome}
|
||||
title="返回首页"
|
||||
onClick={handleCloseRealtime}
|
||||
title={standalone ? "返回无限画布" : "关闭并返回画布"}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)]">
|
||||
{siteLogo ? (
|
||||
<img
|
||||
alt={siteName}
|
||||
className="h-6 w-6 rounded-full object-contain"
|
||||
src={
|
||||
/^https?:\/\//i.test(siteLogo)
|
||||
? buildImageProxyUrl(siteLogo)
|
||||
: siteLogo
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 text-[#111111]" />
|
||||
)}
|
||||
<ArrowLeft className="h-4 w-4 text-[#111111]" />
|
||||
</span>
|
||||
<span className="font-semibold text-[14px] tracking-[-0.02em]">
|
||||
{siteName}
|
||||
{standalone ? "返回无限画布" : "返回画布"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@ -2767,6 +2767,7 @@ export function ImageNode({
|
||||
referenceImage: data.src,
|
||||
aspectRatio,
|
||||
imageCount: 1,
|
||||
billingFeature: "repaint",
|
||||
});
|
||||
|
||||
const image = result.data?.images?.[0];
|
||||
|
||||
@ -566,6 +566,7 @@ function CameraMovementControls({
|
||||
pressedKeysRef.current = createMovementState();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
pressedKeysRef.current[movement] = true;
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
@ -575,6 +576,7 @@ function CameraMovementControls({
|
||||
pressedKeysRef.current = createMovementState();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
pressedKeysRef.current[movement] = false;
|
||||
};
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
|
||||
@ -30,6 +30,7 @@ export const defaultAgentWorkflowPageFeatureSettings: AgentWorkflowPageFeatureSe
|
||||
presetStepEnabled: true,
|
||||
materialStepEnabled: true,
|
||||
psdTemplateEnabled: true,
|
||||
batchProcessingEnabled: true,
|
||||
};
|
||||
|
||||
export interface AgentWorkflowBrandCategoryOption {
|
||||
@ -258,6 +259,10 @@ export function normalizeAgentWorkflowPageFeatureSettings(
|
||||
record.psdTemplateEnabled ?? record.enablePsdTemplate,
|
||||
defaultAgentWorkflowPageFeatureSettings.psdTemplateEnabled
|
||||
),
|
||||
batchProcessingEnabled: normalizeBoolean(
|
||||
record.batchProcessingEnabled ?? record.enableBatchProcessing,
|
||||
defaultAgentWorkflowPageFeatureSettings.batchProcessingEnabled
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1072,6 +1077,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
|
||||
presetStepEnabled: raw.presetStepEnabled ?? raw.modeStepEnabled,
|
||||
materialStepEnabled: raw.materialStepEnabled ?? raw.skuStepEnabled,
|
||||
psdTemplateEnabled: raw.psdTemplateEnabled,
|
||||
batchProcessingEnabled: raw.batchProcessingEnabled,
|
||||
}
|
||||
),
|
||||
steps: sanitizedSteps,
|
||||
|
||||
@ -424,14 +424,14 @@ function getModeConstraintOptions(
|
||||
const constraint = mode ? modeConstraints?.[mode] : undefined;
|
||||
const ratioOptions = constraint?.ratioOptions?.length ? constraint.ratioOptions : [];
|
||||
const durationOptions = constraint?.durationOptions?.length ? constraint.durationOptions : [];
|
||||
const resolutionOptions = constraint?.resolutionOptions?.length
|
||||
? constraint.resolutionOptions
|
||||
: [];
|
||||
const hasResolutionOptions = Array.isArray(constraint?.resolutionOptions);
|
||||
const resolutionOptions = hasResolutionOptions ? constraint?.resolutionOptions || [] : [];
|
||||
|
||||
return {
|
||||
ratioOptions,
|
||||
durationOptions,
|
||||
resolutionOptions,
|
||||
hasResolutionOptions,
|
||||
defaults: {
|
||||
...defaults,
|
||||
...(constraint?.defaults || {}),
|
||||
@ -439,6 +439,31 @@ function getModeConstraintOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function restrictModeConstraintResolutions(
|
||||
modeConstraints: Partial<Record<VideoToolMode, VideoToolModeConstraint>> | undefined,
|
||||
allowedResolutions: VideoToolResolution[]
|
||||
) {
|
||||
if (!modeConstraints || allowedResolutions.length === 0) {
|
||||
return modeConstraints;
|
||||
}
|
||||
|
||||
const allowedSet = new Set<VideoToolResolution>(allowedResolutions);
|
||||
return MODE_ORDER.reduce<Partial<Record<VideoToolMode, VideoToolModeConstraint>>>(
|
||||
(acc, mode) => {
|
||||
const current = modeConstraints[mode];
|
||||
if (!current) return acc;
|
||||
acc[mode] = {
|
||||
...current,
|
||||
resolutionOptions: Array.isArray(current.resolutionOptions)
|
||||
? current.resolutionOptions.filter((item) => allowedSet.has(item))
|
||||
: undefined,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function applyConfiguredDurationOptions(
|
||||
capability: VideoToolCapabilityContract,
|
||||
configuredDurationOptions: number[]
|
||||
@ -501,6 +526,8 @@ export function getVideoToolOptionsForMode(
|
||||
: capability.durationOptions,
|
||||
resolutionOptions: scoped.resolutionOptions.length
|
||||
? scoped.resolutionOptions
|
||||
: scoped.hasResolutionOptions
|
||||
? []
|
||||
: capability.resolutionOptions,
|
||||
defaults: scoped.defaults,
|
||||
};
|
||||
@ -593,9 +620,15 @@ export function buildVideoToolCapability(params: {
|
||||
return acc;
|
||||
}, {})
|
||||
: base.modeConstraints;
|
||||
const restrictedModeConstraints = restrictModeConstraintResolutions(
|
||||
nextModeConstraints,
|
||||
parsed.supportedResolutions && parsed.supportedResolutions.length
|
||||
? parsed.supportedResolutions
|
||||
: []
|
||||
);
|
||||
const defaultMode = pickPreferredMode(modeOptions, base.defaults.mode);
|
||||
const modeScopedDefaults = getModeConstraintOptions(
|
||||
nextModeConstraints,
|
||||
restrictedModeConstraints,
|
||||
defaultMode,
|
||||
base.defaults
|
||||
);
|
||||
@ -605,7 +638,7 @@ export function buildVideoToolCapability(params: {
|
||||
const effectiveDurationOptions = modeScopedDefaults.durationOptions.length
|
||||
? modeScopedDefaults.durationOptions
|
||||
: durationOptions;
|
||||
const effectiveResolutionOptions = modeScopedDefaults.resolutionOptions.length
|
||||
const effectiveResolutionOptions = modeScopedDefaults.hasResolutionOptions
|
||||
? modeScopedDefaults.resolutionOptions
|
||||
: resolutionOptions;
|
||||
|
||||
@ -617,7 +650,7 @@ export function buildVideoToolCapability(params: {
|
||||
ratioOptions,
|
||||
resolutionOptions,
|
||||
durationOptions,
|
||||
modeConstraints: nextModeConstraints,
|
||||
modeConstraints: restrictedModeConstraints,
|
||||
ui: {
|
||||
...base.ui,
|
||||
showReturnLastFrame:
|
||||
|
||||
@ -19,8 +19,10 @@ export interface CanvasGenerateImageRequest {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
imageCount?: number;
|
||||
promptExtend?: boolean;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
billingFeature?: "multi-view" | "repaint";
|
||||
debugLabel?: string;
|
||||
debugMeta?: Record<string, unknown>;
|
||||
}
|
||||
@ -123,6 +125,8 @@ export async function generateCanvasImage(
|
||||
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
|
||||
? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
|
||||
: 1;
|
||||
const promptExtend =
|
||||
typeof request.promptExtend === "boolean" ? request.promptExtend : undefined;
|
||||
const referenceImage =
|
||||
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
||||
const referenceImages = Array.isArray(request.referenceImages)
|
||||
@ -241,6 +245,7 @@ export async function generateCanvasImage(
|
||||
imageSize,
|
||||
imageCount: 1,
|
||||
negativePrompt,
|
||||
promptExtend,
|
||||
};
|
||||
|
||||
let executeGenerateBatch:
|
||||
|
||||
@ -3946,6 +3946,7 @@ export async function createAiModel({
|
||||
fallbackModelId2,
|
||||
excludeFromFailover,
|
||||
imageGenConfig,
|
||||
capabilities,
|
||||
isAgentModel,
|
||||
isMultimodal,
|
||||
agentCodingModelId,
|
||||
@ -3965,6 +3966,7 @@ export async function createAiModel({
|
||||
fallbackModelId2?: string;
|
||||
excludeFromFailover?: boolean;
|
||||
imageGenConfig?: ImageGenConfig;
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
isAgentModel?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
agentCodingModelId?: string;
|
||||
@ -4009,8 +4011,8 @@ export async function createAiModel({
|
||||
defaultParams,
|
||||
capabilities:
|
||||
isMultimodal === undefined
|
||||
? null
|
||||
: withMultimodalModelFlag(null, isMultimodal),
|
||||
? capabilities || null
|
||||
: withMultimodalModelFlag(capabilities || null, isMultimodal),
|
||||
})
|
||||
.returning();
|
||||
} catch (_error) {
|
||||
@ -4038,6 +4040,7 @@ export async function updateAiModel({
|
||||
fallbackModelId2,
|
||||
excludeFromFailover,
|
||||
imageGenConfig,
|
||||
capabilities,
|
||||
isAgentModel,
|
||||
isMultimodal,
|
||||
agentCodingModelId,
|
||||
@ -4058,6 +4061,7 @@ export async function updateAiModel({
|
||||
fallbackModelId2?: string;
|
||||
excludeFromFailover?: boolean;
|
||||
imageGenConfig?: ImageGenConfig;
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
isAgentModel?: boolean;
|
||||
isMultimodal?: boolean;
|
||||
agentCodingModelId?: string;
|
||||
@ -4093,7 +4097,8 @@ export async function updateAiModel({
|
||||
if (
|
||||
isAgentModel !== undefined ||
|
||||
isMultimodal !== undefined ||
|
||||
agentCodingModelId !== undefined
|
||||
agentCodingModelId !== undefined ||
|
||||
capabilities !== undefined
|
||||
) {
|
||||
const [existingModel] = await db
|
||||
.select({
|
||||
@ -4125,11 +4130,13 @@ export async function updateAiModel({
|
||||
updateData.defaultParams = nextDefaultParams;
|
||||
}
|
||||
|
||||
if (isMultimodal !== undefined) {
|
||||
updateData.capabilities = withMultimodalModelFlag(
|
||||
existingModel.capabilities,
|
||||
isMultimodal
|
||||
);
|
||||
if (capabilities !== undefined || isMultimodal !== undefined) {
|
||||
const nextCapabilities =
|
||||
capabilities !== undefined ? capabilities || null : existingModel.capabilities;
|
||||
updateData.capabilities =
|
||||
isMultimodal !== undefined
|
||||
? withMultimodalModelFlag(nextCapabilities, isMultimodal)
|
||||
: nextCapabilities;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -30,12 +30,40 @@ export type PointsSystemConfig = {
|
||||
enabled: boolean;
|
||||
blockOnInsufficient: boolean;
|
||||
creditCostPer3DModel: number;
|
||||
creditCostPer3DModelPbr: number;
|
||||
creditCostPer3DModelSmartLowPoly: number;
|
||||
creditCostPer3DModelQuad: number;
|
||||
creditCostPer3DModelRefine: number;
|
||||
creditCostPer3DModelTexture: number;
|
||||
creditCostPer3DModelAutoRig: number;
|
||||
creditCostPer3DModelHighpolyToLowpoly: number;
|
||||
creditCostPer3DWorld: number;
|
||||
creditCostPer3DWorldMarble11: number;
|
||||
creditCostPer3DWorldMarble11Plus: number;
|
||||
creditCostPerImageMultiView: number;
|
||||
creditCostPerImageRemoveBackground: number;
|
||||
creditCostPerImageBoxCutout: number;
|
||||
creditCostPerImageWatermarkRemove: number;
|
||||
creditCostPerImageExtend: number;
|
||||
creditCostPerImageRepaint: number;
|
||||
enhanceProPricing: EnhanceProPointsPricingConfig;
|
||||
};
|
||||
|
||||
export type ThreeDTaskCreditParams = {
|
||||
taskType?: string | null;
|
||||
pbr?: boolean | null;
|
||||
quad?: boolean | null;
|
||||
smartLowPoly?: boolean | null;
|
||||
};
|
||||
|
||||
export type ImageToolCreditFeature =
|
||||
| "multi-view"
|
||||
| "remove-background"
|
||||
| "box-cutout"
|
||||
| "watermark-remove"
|
||||
| "extend"
|
||||
| "repaint";
|
||||
|
||||
export type PointsPackage = {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -49,9 +77,22 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
|
||||
enabled: false,
|
||||
blockOnInsufficient: true,
|
||||
creditCostPer3DModel: 0,
|
||||
creditCostPer3DModelPbr: 0,
|
||||
creditCostPer3DModelSmartLowPoly: 0,
|
||||
creditCostPer3DModelQuad: 0,
|
||||
creditCostPer3DModelRefine: 0,
|
||||
creditCostPer3DModelTexture: 0,
|
||||
creditCostPer3DModelAutoRig: 0,
|
||||
creditCostPer3DModelHighpolyToLowpoly: 0,
|
||||
creditCostPer3DWorld: 0,
|
||||
creditCostPer3DWorldMarble11: 0,
|
||||
creditCostPer3DWorldMarble11Plus: 0,
|
||||
creditCostPerImageMultiView: 0,
|
||||
creditCostPerImageRemoveBackground: 0,
|
||||
creditCostPerImageBoxCutout: 0,
|
||||
creditCostPerImageWatermarkRemove: 0,
|
||||
creditCostPerImageExtend: 0,
|
||||
creditCostPerImageRepaint: 0,
|
||||
enhanceProPricing: {
|
||||
enabled: false,
|
||||
chargeMode: "scale_factor",
|
||||
@ -390,6 +431,67 @@ export function get3DWorldCreditCost(
|
||||
return normalizeCost(pointsConfig.creditCostPer3DWorld);
|
||||
}
|
||||
|
||||
export function get3DTaskCreditCost(
|
||||
pointsConfig: PointsSystemConfig,
|
||||
params: ThreeDTaskCreditParams
|
||||
) {
|
||||
const taskType = String(params.taskType || "").trim();
|
||||
if (
|
||||
taskType === "text_to_model" ||
|
||||
taskType === "image_to_model" ||
|
||||
taskType === "multiview_to_model"
|
||||
) {
|
||||
let cost = normalizeCost(pointsConfig.creditCostPer3DModel);
|
||||
if (params.pbr === true) {
|
||||
cost += normalizeCost(pointsConfig.creditCostPer3DModelPbr);
|
||||
}
|
||||
if (params.smartLowPoly === true) {
|
||||
cost += normalizeCost(pointsConfig.creditCostPer3DModelSmartLowPoly);
|
||||
}
|
||||
if (params.quad === true) {
|
||||
cost += normalizeCost(pointsConfig.creditCostPer3DModelQuad);
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
if (taskType === "refine_model") {
|
||||
return normalizeCost(pointsConfig.creditCostPer3DModelRefine);
|
||||
}
|
||||
if (taskType === "texture_model") {
|
||||
return normalizeCost(pointsConfig.creditCostPer3DModelTexture);
|
||||
}
|
||||
if (taskType === "animate_rig") {
|
||||
return normalizeCost(pointsConfig.creditCostPer3DModelAutoRig);
|
||||
}
|
||||
if (taskType === "highpoly_to_lowpoly") {
|
||||
return normalizeCost(pointsConfig.creditCostPer3DModelHighpolyToLowpoly);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getImageToolCreditCost(
|
||||
pointsConfig: PointsSystemConfig,
|
||||
feature: ImageToolCreditFeature | null | undefined
|
||||
) {
|
||||
switch (feature) {
|
||||
case "multi-view":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageMultiView);
|
||||
case "remove-background":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageRemoveBackground);
|
||||
case "box-cutout":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageBoxCutout);
|
||||
case "watermark-remove":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageWatermarkRemove);
|
||||
case "extend":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageExtend);
|
||||
case "repaint":
|
||||
return normalizeCost(pointsConfig.creditCostPerImageRepaint);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
|
||||
if (!value || typeof value !== "object") {
|
||||
return defaultPointsSystemConfig;
|
||||
@ -410,6 +512,17 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
|
||||
creditCostPer3DModel: normalizeCost(
|
||||
raw.creditCostPer3DModel ?? legacy3DCost
|
||||
),
|
||||
creditCostPer3DModelPbr: normalizeCost(raw.creditCostPer3DModelPbr),
|
||||
creditCostPer3DModelSmartLowPoly: normalizeCost(
|
||||
raw.creditCostPer3DModelSmartLowPoly
|
||||
),
|
||||
creditCostPer3DModelQuad: normalizeCost(raw.creditCostPer3DModelQuad),
|
||||
creditCostPer3DModelRefine: normalizeCost(raw.creditCostPer3DModelRefine),
|
||||
creditCostPer3DModelTexture: normalizeCost(raw.creditCostPer3DModelTexture),
|
||||
creditCostPer3DModelAutoRig: normalizeCost(raw.creditCostPer3DModelAutoRig),
|
||||
creditCostPer3DModelHighpolyToLowpoly: normalizeCost(
|
||||
raw.creditCostPer3DModelHighpolyToLowpoly
|
||||
),
|
||||
creditCostPer3DWorld: normalizeCost(
|
||||
raw.creditCostPer3DWorld ?? legacy3DCost
|
||||
),
|
||||
@ -419,6 +532,16 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
|
||||
creditCostPer3DWorldMarble11Plus: normalizeCost(
|
||||
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost
|
||||
),
|
||||
creditCostPerImageMultiView: normalizeCost(raw.creditCostPerImageMultiView),
|
||||
creditCostPerImageRemoveBackground: normalizeCost(
|
||||
raw.creditCostPerImageRemoveBackground
|
||||
),
|
||||
creditCostPerImageBoxCutout: normalizeCost(raw.creditCostPerImageBoxCutout),
|
||||
creditCostPerImageWatermarkRemove: normalizeCost(
|
||||
raw.creditCostPerImageWatermarkRemove
|
||||
),
|
||||
creditCostPerImageExtend: normalizeCost(raw.creditCostPerImageExtend),
|
||||
creditCostPerImageRepaint: normalizeCost(raw.creditCostPerImageRepaint),
|
||||
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
|
||||
raw.enhanceProPricing,
|
||||
legacyEnhanceProCost
|
||||
|
||||
97
lib/points/feature-charge.ts
Normal file
97
lib/points/feature-charge.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
consumeUserPoints,
|
||||
getPointsSystemConfig,
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import type { PointsSystemConfig } from "@/lib/points/config";
|
||||
|
||||
type FeaturePointsChargeInput = {
|
||||
userId: string;
|
||||
unitCost: number;
|
||||
reason: string;
|
||||
modelId?: string | null;
|
||||
note: string;
|
||||
pointsConfig?: PointsSystemConfig;
|
||||
};
|
||||
|
||||
type FeaturePointsChargeOk = {
|
||||
ok: true;
|
||||
chargeable: boolean;
|
||||
unitCost: number;
|
||||
consume: (overrides?: {
|
||||
modelId?: string | null;
|
||||
note?: string;
|
||||
reason?: string;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
balance: number | null;
|
||||
skipped: boolean;
|
||||
} | null>;
|
||||
};
|
||||
|
||||
type FeaturePointsChargeBlocked = {
|
||||
ok: false;
|
||||
status: number;
|
||||
payload: {
|
||||
success: false;
|
||||
error: string;
|
||||
code: string;
|
||||
message: string;
|
||||
balance: number;
|
||||
expectedCost: number;
|
||||
unitCost: number;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeRuntimeCost(value: unknown) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) && parsedValue > 0
|
||||
? Math.max(0, Math.round(parsedValue))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export async function prepareFeaturePointsCharge(
|
||||
input: FeaturePointsChargeInput
|
||||
): Promise<FeaturePointsChargeOk | FeaturePointsChargeBlocked> {
|
||||
const unitCost = normalizeRuntimeCost(input.unitCost);
|
||||
const pointsConfig = input.pointsConfig || (await getPointsSystemConfig());
|
||||
const chargeable = pointsConfig.enabled === true && unitCost > 0;
|
||||
|
||||
if (chargeable && pointsConfig.blockOnInsufficient !== false) {
|
||||
const userData = await getUserById({ id: input.userId });
|
||||
if (!userData?.isHighVip) {
|
||||
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
|
||||
if (currentBalance < unitCost) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 402,
|
||||
payload: {
|
||||
success: false,
|
||||
error: "积分不足",
|
||||
code: "insufficient_points",
|
||||
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
|
||||
balance: currentBalance,
|
||||
expectedCost: unitCost,
|
||||
unitCost,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chargeable,
|
||||
unitCost,
|
||||
consume: async (overrides = {}) => {
|
||||
if (!chargeable) return null;
|
||||
return consumeUserPoints({
|
||||
userId: input.userId,
|
||||
amount: unitCost,
|
||||
reason: overrides.reason || input.reason,
|
||||
modelId: overrides.modelId ?? input.modelId ?? null,
|
||||
note: overrides.note || input.note,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -44,14 +44,25 @@ export type VideoCanvasFunctionConfig = {
|
||||
|
||||
const defaultFunctions: VideoCanvasFunctionDefinition[] = [
|
||||
{
|
||||
key: "character-three-view",
|
||||
label: "角色三视图",
|
||||
key: "story-beat-grid-4",
|
||||
label: "剧情推演四宫格",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
|
||||
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。画面中只能有 4 个画面区域,只允许一条竖向分割线和一条横向分割线;严禁出现 3 行 3 列、9 个画面区域或九宫格布局。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
|
||||
description: "官方剧情推演四宫格:基于参考图生成 2x2 连续剧情分镜合成图。",
|
||||
},
|
||||
{
|
||||
key: "character-three-view",
|
||||
label: "角色三视图生成",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
|
||||
description: "官方角色三视图预设,使用参考图生图/编辑模型。",
|
||||
},
|
||||
{
|
||||
@ -65,6 +76,50 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
|
||||
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
|
||||
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
|
||||
},
|
||||
{
|
||||
key: "continuous-storyboard-grid-25",
|
||||
label: "25宫格连贯分镜",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,5x5 等分排版,使用清晰分割线,不能出现文字、水印、编号或多余边框说明。",
|
||||
description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。",
|
||||
},
|
||||
{
|
||||
key: "cinematic-lighting-correction",
|
||||
label: "电影级光影校正",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以选中图片为唯一参考,在保持角色身份、发型、服装、姿态和构图一致的前提下,进行电影级光影校正。增强自然逆光、轮廓光、皮肤通透感、背景层次和柔和景深,保留原始角色与场景,不改变人物数量,不生成宫格或分镜。输出单张 16:9 图片,不能出现文字、水印、编号或额外边框。",
|
||||
description: "官方电影级光影校正:对参考图做单张电影感光影重塑。",
|
||||
},
|
||||
{
|
||||
key: "frame-prediction-plus-3s",
|
||||
label: "画面推演 - 3秒后",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以选中图片作为当前画面,推演同一镜头时间线 3 秒后的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作、表情、衣袖/头发摆动、视线或镜头位置发生合理连续变化。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
|
||||
description: "官方画面推演:根据当前帧生成 3 秒后的单帧结果。",
|
||||
},
|
||||
{
|
||||
key: "frame-prediction-minus-5s",
|
||||
label: "画面推演 - 5秒前",
|
||||
kind: "image-edit",
|
||||
route: "canvas-generate-image",
|
||||
enabled: true,
|
||||
creditCost: 14,
|
||||
prompt:
|
||||
"以选中图片作为当前画面,反向推演同一镜头时间线 5 秒前的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作起势、姿态、表情、衣袖/头发位置或镜头位置回到合理的前一状态。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
|
||||
description: "官方画面推演:根据当前帧生成 5 秒前的单帧结果。",
|
||||
},
|
||||
{
|
||||
key: "grid-split",
|
||||
label: "宫格切分",
|
||||
@ -242,6 +297,58 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const legacyFunctionPrompts: Record<string, string[]> = {
|
||||
"story-beat-grid-4": [
|
||||
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
|
||||
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
|
||||
],
|
||||
"character-three-view": [
|
||||
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
|
||||
],
|
||||
};
|
||||
|
||||
const legacyFunctionLabels: Record<string, string[]> = {
|
||||
"character-three-view": ["角色三视图"],
|
||||
};
|
||||
|
||||
function normalizeComparableText(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeFunctionLabel(
|
||||
key: string,
|
||||
value: unknown,
|
||||
fallbackLabel: string,
|
||||
) {
|
||||
const label = typeof value === "string" ? value.trim() : "";
|
||||
if (!label) return fallbackLabel;
|
||||
const normalizedLabel = normalizeComparableText(label);
|
||||
const isLegacyLabel = (legacyFunctionLabels[key] || []).some(
|
||||
(legacyLabel) => normalizeComparableText(legacyLabel) === normalizedLabel,
|
||||
);
|
||||
return isLegacyLabel ? fallbackLabel : label;
|
||||
}
|
||||
|
||||
function normalizeFunctionPrompt(
|
||||
key: string,
|
||||
value: unknown,
|
||||
fallbackPrompt: string,
|
||||
) {
|
||||
const prompt = typeof value === "string" ? value.trim() : "";
|
||||
if (!prompt) return fallbackPrompt;
|
||||
const normalizedPrompt = normalizeComparableText(prompt);
|
||||
const isStoryPromptClearlyNineGrid =
|
||||
key === "story-beat-grid-4" &&
|
||||
/(?:生成一张\s*3\s*[x×]\s*3|生成一张[^。]*九宫格|九格分别|必须是\s*9\s*个)/.test(
|
||||
normalizedPrompt,
|
||||
);
|
||||
if (isStoryPromptClearlyNineGrid) return fallbackPrompt;
|
||||
const isLegacyPrompt = (legacyFunctionPrompts[key] || []).some(
|
||||
(legacyPrompt) => normalizeComparableText(legacyPrompt) === normalizedPrompt,
|
||||
);
|
||||
return isLegacyPrompt ? fallbackPrompt : prompt;
|
||||
}
|
||||
|
||||
function normalizeFunctionDefinition(
|
||||
key: string,
|
||||
value: unknown,
|
||||
@ -251,7 +358,7 @@ function normalizeFunctionDefinition(
|
||||
const normalized: VideoCanvasFunctionDefinition = {
|
||||
...fallback,
|
||||
key,
|
||||
label: String(incoming.label || fallback.label),
|
||||
label: normalizeFunctionLabel(key, incoming.label, fallback.label),
|
||||
kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind,
|
||||
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
|
||||
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
|
||||
@ -260,7 +367,7 @@ function normalizeFunctionDefinition(
|
||||
typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost)
|
||||
? Math.max(0, incoming.creditCost)
|
||||
: fallback.creditCost,
|
||||
prompt: String(incoming.prompt || fallback.prompt),
|
||||
prompt: normalizeFunctionPrompt(key, incoming.prompt, fallback.prompt),
|
||||
description: String(incoming.description || fallback.description),
|
||||
requiresBackend:
|
||||
typeof incoming.requiresBackend === "boolean"
|
||||
|
||||
@ -253,7 +253,9 @@ export const imageGenerationService = {
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageCount?: number;
|
||||
promptExtend?: boolean;
|
||||
referenceImage?: string;
|
||||
billingFeature?: 'multi-view' | 'repaint';
|
||||
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/generate/image', {
|
||||
|
||||
@ -109,6 +109,7 @@ export interface AgentWorkflowPageFeatureSettings {
|
||||
presetStepEnabled: boolean;
|
||||
materialStepEnabled: boolean;
|
||||
psdTemplateEnabled: boolean;
|
||||
batchProcessingEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AgentWorkflowUiConfig {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user