feat: enhance workflow controls and media settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-20 14:39:41 +08:00
parent 3775485bdf
commit 6628a6b2d0
29 changed files with 1658 additions and 249 deletions

View File

@ -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_MAX_BYTES = 1024 * 1024;
const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config"; const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const; const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number];
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record< const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
(typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number], AdminVideoResolution,
number number
> = { > = {
"480p": 1, "480p": 1,
@ -202,6 +203,38 @@ const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
"1080p": 2, "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) { function formatCompactNumber(value: number, fractionDigits = 2) {
if (!Number.isFinite(value)) return "0"; if (!Number.isFinite(value)) return "0";
const fixed = value.toFixed(fractionDigits); 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 = { type AiModel = {
id: string; id: string;
providerId: string; providerId: string;
@ -572,6 +706,7 @@ type AiModel = {
isAgentModel?: boolean; isAgentModel?: boolean;
agentCodingModelId?: string; agentCodingModelId?: string;
isMultimodal?: boolean; isMultimodal?: boolean;
capabilities?: Record<string, unknown> | null;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置 imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
}; };
@ -1791,6 +1926,7 @@ export function AdminSettingsClient() {
() => () =>
enhanceProConfig.apiKeys.map((apiKey, index) => ({ enhanceProConfig.apiKeys.map((apiKey, index) => ({
id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`, id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
index,
name: `Krea Key ${index + 1}`, name: `Krea Key ${index + 1}`,
maskedKey: maskAdminSecret(apiKey), maskedKey: maskAdminSecret(apiKey),
})), })),
@ -2680,6 +2816,7 @@ export function AdminSettingsClient() {
fallbackModelId1: "", // 备用模型 ID 1 fallbackModelId1: "", // 备用模型 ID 1
fallbackModelId2: "", // 备用模型 ID 2 fallbackModelId2: "", // 备用模型 ID 2
excludeFromFailover: false, excludeFromFailover: false,
capabilities: {} as Record<string, unknown>,
// 🆕 图片生成模型特性配置 // 🆕 图片生成模型特性配置
imageGenConfig: createDefaultImageGenConfig(), imageGenConfig: createDefaultImageGenConfig(),
}); });
@ -2740,13 +2877,18 @@ export function AdminSettingsClient() {
const isVideoProviderForModelConfig = isVideoModelDescriptor({ const isVideoProviderForModelConfig = isVideoModelDescriptor({
providerType: selectedModelProviderType, providerType: selectedModelProviderType,
modelId: modelForm.modelId, modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen, supportsImageGen: modelForm.supportsImageGen,
}); });
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({ const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
providerType: selectedModelProviderType, providerType: selectedModelProviderType,
modelId: modelForm.modelId, modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen, supportsImageGen: modelForm.supportsImageGen,
}); });
const modelVideoSupportedResolutions = getAdminVideoSupportedResolutions(
modelForm.capabilities
);
const configuredVideoDurationOptions = getConfiguredVideoDurationOptions( const configuredVideoDurationOptions = getConfiguredVideoDurationOptions(
modelForm.imageGenConfig 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 = ( const updateEnhanceProPointsBucket = (
index: number, index: number,
updater: (item: EnhanceProPointsResolutionBucket) => EnhanceProPointsResolutionBucket 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>( const handleEnhanceProPresetChange = <K extends keyof EnhanceProPreset>(
index: number, index: number,
key: K, key: K,
@ -5539,6 +5725,7 @@ export function AdminSettingsClient() {
fallbackModelId1: "", fallbackModelId1: "",
fallbackModelId2: "", fallbackModelId2: "",
excludeFromFailover: false, excludeFromFailover: false,
capabilities: {},
imageGenConfig: createDefaultImageGenConfig(), imageGenConfig: createDefaultImageGenConfig(),
}); });
setVideoDurationOptionsInput(""); setVideoDurationOptionsInput("");
@ -8746,6 +8933,108 @@ export function AdminSettingsClient() {
</div> </div>
</div> </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="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
@ -12734,6 +13023,67 @@ export function AdminSettingsClient() {
</Button> </Button>
</div> </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"> <div className="space-y-2">
<Label>支持视频秒数</Label> <Label>支持视频秒数</Label>
<Input <Input
@ -13295,6 +13645,12 @@ export function AdminSettingsClient() {
(model as any).fallbackModelId2 || "", (model as any).fallbackModelId2 || "",
excludeFromFailover: excludeFromFailover:
(model as any).excludeFromFailover || false, (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: imageGenConfig:
(model as any).imageGenConfig || createDefaultImageGenConfig(), (model as any).imageGenConfig || createDefaultImageGenConfig(),
}); });
@ -15934,18 +16290,31 @@ export function AdminSettingsClient() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="enhanceProApiKey">API Key</Label> <Label htmlFor="enhanceProApiKey">API Key</Label>
<Input <div className="flex flex-col gap-2 md:flex-row">
id="enhanceProApiKey" <Input
type="password" id="enhanceProApiKey"
value={enhanceProConfig.apiKey} type="password"
onChange={(e) => value={enhanceProConfig.apiKey}
setEnhanceProConfig((prev) => ({ onChange={(e) =>
...prev, setEnhanceProConfig((prev) => ({
apiKey: e.target.value, ...prev,
})) apiKey: e.target.value,
} }))
placeholder="请输入 Krea Bearer Token" }
/> 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>
<div className="space-y-4 rounded-xl border p-4"> <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} API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div> </div>
</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> </div>
)) ))
) : ( ) : (

View File

@ -4663,7 +4663,7 @@ export function AgentWorkflowSettingsClient({
</div> </div>
</div> </div>
</CardHeader> </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="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4"> <div className="pr-4">
<div className="text-sm font-medium">开启第一步:选择生图模式</div> <div className="text-sm font-medium">开启第一步:选择生图模式</div>
@ -4708,17 +4708,17 @@ export function AgentWorkflowSettingsClient({
/> />
</div> </div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4"> <div className="pr-4">
<div className="text-sm font-medium">开启套 PSD 模板</div> <div className="text-sm font-medium">开启套 PSD 模板</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。 关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。
</div> </div>
</div> </div>
<Switch <Switch
checked={workflowPageFeatureSettings.psdTemplateEnabled} checked={workflowPageFeatureSettings.psdTemplateEnabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setConfig((prev) => ({ setConfig((prev) => ({
...prev, ...prev,
pageFeatureSettings: { pageFeatureSettings: {
...(prev.pageFeatureSettings || ...(prev.pageFeatureSettings ||
@ -4727,9 +4727,31 @@ export function AgentWorkflowSettingsClient({
}, },
})) }))
} }
/> />
</div> </div>
</CardContent>
<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> </Card>
</motion.div> </motion.div>

View File

@ -41,6 +41,8 @@ const imageGenConfigSchema = z.object({
loadingColor: z.string().optional(), loadingColor: z.string().optional(),
}).optional(); }).optional();
const capabilitiesSchema = z.record(z.string(), z.unknown()).nullable().optional();
function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) { function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
if (!config) return config; if (!config) return config;
@ -79,6 +81,7 @@ const createModelSchema = z.object({
isAgentModel: z.boolean().optional(), isAgentModel: z.boolean().optional(),
agentCodingModelId: z.string().max(128).optional(), agentCodingModelId: z.string().max(128).optional(),
isMultimodal: z.boolean().optional(), isMultimodal: z.boolean().optional(),
capabilities: capabilitiesSchema,
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
}); });
@ -101,6 +104,7 @@ const updateModelSchema = z.object({
isAgentModel: z.boolean().optional(), isAgentModel: z.boolean().optional(),
agentCodingModelId: z.string().max(128).optional(), agentCodingModelId: z.string().max(128).optional(),
isMultimodal: z.boolean().optional(), isMultimodal: z.boolean().optional(),
capabilities: capabilitiesSchema,
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
}); });

View File

@ -1,12 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { import { getPointsSystemConfig } from "@/lib/db/queries";
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; 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 { getTripoProviderConfig } from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; 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) { export async function POST(request: Request) {
try { try {
const session = await auth(); const session = await auth();
@ -359,36 +349,21 @@ export async function POST(request: Request) {
} }
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(taskType); const unitCost = get3DTaskCreditCost(pointsConfig, {
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3DModel)) taskType,
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3DModel))) pbr: body.pbr,
: 0; quad: body.quad,
smartLowPoly: body.smartLowPoly,
if ( });
shouldChargePoints && const pointsCharge = await prepareFeaturePointsCharge({
pointsConfig.enabled && userId: session.user.id,
unitCost > 0 && unitCost,
pointsConfig.blockOnInsufficient !== false reason: "model3d_task",
) { modelId: "tripo-3d",
const userData = await getUserById({ id: session.user.id }); note: `3D模型任务消耗积分(${taskType})`,
if (!userData?.isHighVip) { });
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0)); if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
} }
const config = await getTripoProviderConfig( const config = await getTripoProviderConfig(
@ -413,17 +388,13 @@ export async function POST(request: Request) {
); );
} }
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) { if (pointsCharge.chargeable) {
try { try {
const consumeResult = await consumeUserPoints({ const consumeResult = await pointsCharge.consume({
userId: session.user.id, note: `3D模型任务消耗积分(${taskType} / ${taskId})`,
amount: unitCost,
reason: "model3d_generation",
modelId: "tripo-3d",
note: "3D模型生成消耗积分",
}); });
if (!consumeResult.ok) { if (!consumeResult?.ok) {
console.warn( console.warn(
`[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}` `[Points] Failed to consume 3D points. user=${session.user.id} task=${taskId} cost=${unitCost}`
); );

View File

@ -6,6 +6,9 @@ import {
executeBoxCutoutJob, executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service"; } from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/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 runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
@ -40,6 +43,19 @@ export async function POST(request: NextRequest) {
} }
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString(); 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({ const result = await executeBoxCutoutJob({
userId: session.user.id, userId: session.user.id,
imageUrl: resolvedImageUrl, imageUrl: resolvedImageUrl,
@ -52,6 +68,14 @@ export async function POST(request: NextRequest) {
sourceNodeId: body.sourceNodeId, 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); return NextResponse.json(result);
} catch (error) { } catch (error) {
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) { if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {

View File

@ -9,6 +9,12 @@ import {
generateCanvasImage, generateCanvasImage,
type CanvasGenerateImageRequest, type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image"; } 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; export const maxDuration = 120;
@ -16,6 +22,13 @@ interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean; 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 { function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return { return {
prompt: String(body.prompt || "").trim(), prompt: String(body.prompt || "").trim(),
@ -34,6 +47,8 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
typeof body.imageCount === "number" && Number.isFinite(body.imageCount) typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount ? body.imageCount
: 1, : 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
referenceImage: referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined, typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages) 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; const asyncMode = body.async === true;
if (asyncMode) { if (asyncMode) {
const task = await createCanvasImageTask({ const task = await createCanvasImageTask({
@ -114,6 +153,18 @@ export async function POST(request: Request) {
request: payload, request: payload,
}); });
await enqueueCanvasImageTask(task.id); 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({ return NextResponse.json({
success: true, success: true,
taskId: task.id, taskId: task.id,
@ -122,6 +173,13 @@ export async function POST(request: Request) {
} }
const result = await generateCanvasImage(payload); 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({ return NextResponse.json({
success: true, success: true,
data: result, data: result,

View File

@ -1,6 +1,9 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service"; 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 { import {
getFriendlyRateLimitMessage, getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage, 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({ const result = await executeExtendJob({
userId: session.user.id, userId: session.user.id,
imageUrl, imageUrl,
@ -50,6 +66,14 @@ export async function POST(request: NextRequest) {
yScale: body.yScale ? Number(body.yScale) : undefined, 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({ return NextResponse.json({
success: true, success: true,
original_url: result.original_url, original_url: result.original_url,
@ -68,6 +92,19 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(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({ const result = await executeExtendJob({
userId: session.user.id, userId: session.user.id,
imageBase64: buffer.toString("base64"), imageBase64: buffer.toString("base64"),
@ -76,6 +113,14 @@ export async function POST(request: NextRequest) {
messageId: String(formData.get("messageId") || "") || null, 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({ return NextResponse.json({
success: true, success: true,
original_url: result.original_url, original_url: result.original_url,

View File

@ -6,6 +6,9 @@ import {
fetchImageBufferFromUrl, fetchImageBufferFromUrl,
RmbgServiceError, RmbgServiceError,
} from "@/lib/ai/rmbg/service"; } 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 runtime = "nodejs";
export const maxDuration = 60; 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({ const result = await executeRmbgJob({
userId: session.user.id, userId: session.user.id,
imageBuffer, imageBuffer,
@ -90,6 +106,14 @@ export async function POST(request: NextRequest) {
sourceImageUrl, sourceImageUrl,
}); });
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume RMBG points:", pointsError);
}
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
...result, ...result,

View File

@ -1103,6 +1103,7 @@ const updateSchema = z.object({
presetStepEnabled: z.boolean().optional(), presetStepEnabled: z.boolean().optional(),
materialStepEnabled: z.boolean().optional(), materialStepEnabled: z.boolean().optional(),
psdTemplateEnabled: z.boolean().optional(), psdTemplateEnabled: z.boolean().optional(),
batchProcessingEnabled: z.boolean().optional(),
}) })
.optional(), .optional(),
steps: z steps: z

View File

@ -6,9 +6,12 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { import {
createMediaAssetRelation, createMediaAssetRelation,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
upsertMediaAsset, upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
// PicWish API 需要公网可访问的 HTTP/HTTPS URL // PicWish API 需要公网可访问的 HTTP/HTTPS URL
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
@ -380,6 +383,19 @@ export async function POST(request: NextRequest) {
console.log("[WatermarkRemove] 开始处理图片:", publicImageUrl); 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(); const formData = new FormData();
formData.append("sync", "0"); 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 { try {
const result = await finalizeWatermarkTaskResult({ const result = await finalizeWatermarkTaskResult({
taskId, taskId,

View File

@ -1557,6 +1557,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
presetStepEnabled?: boolean; presetStepEnabled?: boolean;
materialStepEnabled?: boolean; materialStepEnabled?: boolean;
psdTemplateEnabled?: boolean; psdTemplateEnabled?: boolean;
batchProcessingEnabled?: boolean;
}; };
steps?: Array<{ steps?: Array<{
key: AgentWorkflowStepKey; key: AgentWorkflowStepKey;
@ -1614,6 +1615,8 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
workflowPageFeatureSettings.presetStepEnabled !== false; workflowPageFeatureSettings.presetStepEnabled !== false;
const isWorkflowMaterialStepEnabled = const isWorkflowMaterialStepEnabled =
workflowPageFeatureSettings.materialStepEnabled !== false; workflowPageFeatureSettings.materialStepEnabled !== false;
const isBatchProcessingEnabled =
workflowPageFeatureSettings.batchProcessingEnabled !== false;
const sanitizedWorkflowSelections = useMemo( const sanitizedWorkflowSelections = useMemo(
() => () =>
sanitizeAgentWorkflowSelections({ sanitizeAgentWorkflowSelections({
@ -2764,10 +2767,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
}, [isAgentMode]); }, [isAgentMode]);
useEffect(() => { useEffect(() => {
if (shouldOpenBatchProcessing) { if (shouldOpenBatchProcessing && isBatchProcessingEnabled) {
setSceneFusionDialogOpen(true); setSceneFusionDialogOpen(true);
} }
}, [shouldOpenBatchProcessing]); }, [isBatchProcessingEnabled, shouldOpenBatchProcessing]);
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]); const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
const buildPersistedCanvasData = useCallback(() => { const buildPersistedCanvasData = useCallback(() => {
@ -8458,6 +8461,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<div className="min-h-screen bg-[#050607] text-white"> <div className="min-h-screen bg-[#050607] text-white">
<AgentCanvasTopNavDark <AgentCanvasTopNavDark
activeKey="agent" activeKey="agent"
batchProcessingEnabled={isBatchProcessingEnabled}
rightSlot={agentProjectNavSlot} rightSlot={agentProjectNavSlot}
siteLogo={siteSettingsData?.siteLogo} siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark} siteLogoDark={siteSettingsData?.siteLogoDark}
@ -9614,23 +9618,25 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip> {isBatchProcessingEnabled ? (
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
className={cn( <Button
isVideoMode className={cn(
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS isVideoMode
: "h-9 w-9 rounded-xl" ? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
)} : "h-9 w-9 rounded-xl"
onClick={() => setSceneFusionDialogOpen(true)} )}
size="icon" onClick={() => setSceneFusionDialogOpen(true)}
variant="ghost" size="icon"
> variant="ghost"
<Layers className="h-4 w-4" /> >
</Button> <Layers className="h-4 w-4" />
</TooltipTrigger> </Button>
<TooltipContent side="top">批量处理</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent side="top">批量处理</TooltipContent>
</Tooltip>
) : null}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -10,6 +10,7 @@ import {
Check, Check,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Crosshair,
Download, Download,
Eye, Eye,
EyeOff, EyeOff,
@ -615,6 +616,7 @@ export default function CanvasModel3DPage() {
); );
const [worldViewportMode, setWorldViewportMode] = const [worldViewportMode, setWorldViewportMode] =
useState<WorldViewportMode>("world"); useState<WorldViewportMode>("world");
const [worldFirstPerson, setWorldFirstPerson] = useState(false);
const [viewerResetToken, setViewerResetToken] = useState(0); const [viewerResetToken, setViewerResetToken] = useState(0);
const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]); const [taskHistory, setTaskHistory] = useState<TaskRecord[]>([]);
@ -2393,6 +2395,12 @@ export default function CanvasModel3DPage() {
worldViewportMode, worldViewportMode,
]); ]);
useEffect(() => {
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
setWorldFirstPerson(false);
}
}, [workspaceMode, worldViewportMode]);
return ( return (
<div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100"> <div className="flex h-dvh w-full flex-col overflow-hidden bg-zinc-950 text-zinc-100">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
@ -3463,16 +3471,34 @@ export default function CanvasModel3DPage() {
3D 世界控制 3D 世界控制
</h3> </h3>
</div> </div>
<Button <div className="flex shrink-0 items-center gap-2">
className="h-8 rounded-full border-white/10 bg-white/5 px-3 text-zinc-200 hover:bg-white/10 hover:text-white" <Button
onClick={() => setViewerResetToken((prev) => prev + 1)} className={cn(
size="sm" "h-8 rounded-full border-white/10 px-3 text-zinc-200 hover:bg-white/10 hover:text-white",
type="button" worldFirstPerson
variant="outline" ? "bg-yellow-500/20 text-yellow-100"
> : "bg-white/5"
<RotateCw className="mr-1.5 h-3.5 w-3.5" /> )}
回正 disabled={!currentPrimarySpzUrl || worldViewportMode !== "splat"}
</Button> 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>
<div className="mt-3 grid grid-cols-3 gap-2"> <div className="mt-3 grid grid-cols-3 gap-2">
@ -3520,10 +3546,12 @@ export default function CanvasModel3DPage() {
{worldViewportMode === "splat" ? ( {worldViewportMode === "splat" ? (
<> <>
<div className="font-medium text-zinc-200"> <div className="font-medium text-zinc-200">
鼠标拖拽会围绕场景原点旋转 {worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景原点旋转"}
</div> </div>
<div className="mt-1"> <div className="mt-1">
左键拖拽旋转,滚轮缩放;已关闭横向平移。需要切换模型时,直接点击右侧资产卡片。 {worldFirstPerson
? "点击画面后可锁定鼠标视角,WASD 或方向键移动,Q/R 上升,E/F 下降,Esc 退出鼠标锁定。"
: "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"}
</div> </div>
</> </>
) : worldViewportMode === "mesh" ? ( ) : worldViewportMode === "mesh" ? (
@ -3552,6 +3580,7 @@ export default function CanvasModel3DPage() {
{workspaceMode === "world" ? ( {workspaceMode === "world" ? (
<World3DViewer <World3DViewer
emptyStateTitle={viewerEmptyStateTitle} emptyStateTitle={viewerEmptyStateTitle}
firstPerson={worldViewportMode === "splat" && worldFirstPerson}
imageUrl={currentWorldImageUrl} imageUrl={currentWorldImageUrl}
meshLayerVisibility={effectiveLayerVisibility} meshLayerVisibility={effectiveLayerVisibility}
meshSettings={viewerSettings} meshSettings={viewerSettings}

View File

@ -12,6 +12,7 @@ interface AgentCanvasTopNavDarkProps {
onBack?: () => void; onBack?: () => void;
rightSlot?: ReactNode; rightSlot?: ReactNode;
activeKey?: "featured" | "agent" | "canvas" | "gallery"; activeKey?: "featured" | "agent" | "canvas" | "gallery";
batchProcessingEnabled?: boolean;
} }
const NAV_ITEMS = [ const NAV_ITEMS = [
@ -48,6 +49,7 @@ export function AgentCanvasTopNavDark({
onBack, onBack,
rightSlot = null, rightSlot = null,
activeKey = "agent", activeKey = "agent",
batchProcessingEnabled = true,
}: AgentCanvasTopNavDarkProps) { }: AgentCanvasTopNavDarkProps) {
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot"; const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || ""; const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
@ -94,7 +96,9 @@ export function AgentCanvasTopNavDark({
<div className="ml-auto flex shrink-0 items-center gap-3"> <div className="ml-auto flex shrink-0 items-center gap-3">
<nav className="hidden items-center gap-2 md:flex"> <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 Icon = item.icon;
const isActive = item.key === activeKey; const isActive = item.key === activeKey;
return ( return (

View File

@ -4455,6 +4455,7 @@ export function AgentGenerationWorkspaceDarkV2({
siteLogoDark={siteLogoDark} siteLogoDark={siteLogoDark}
onBack={onBack} onBack={onBack}
activeKey="agent" activeKey="agent"
batchProcessingEnabled={resolvedWorkflowFeatureSettings.batchProcessingEnabled}
rightSlot={topNavRightSlot || <div className="w-10" />} rightSlot={topNavRightSlot || <div className="w-10" />}
/> />

View File

@ -15,6 +15,7 @@ import {
flattenVisibleAgentWorkflowSteps, flattenVisibleAgentWorkflowSteps,
getAgentWorkflowBlockedOptionKeysForSelections, getAgentWorkflowBlockedOptionKeysForSelections,
getAgentWorkflowBrandStoreEntries, getAgentWorkflowBrandStoreEntries,
getAgentWorkflowPageFeatureSettings,
getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory, getAgentWorkflowFourthCategoryOptionsByBrandStoreThirdCategory,
getAgentWorkflowPriceBandOptions, getAgentWorkflowPriceBandOptions,
getAgentWorkflowThirdCategoryOptionsByBrandStore, getAgentWorkflowThirdCategoryOptionsByBrandStore,
@ -608,6 +609,10 @@ export function AgentWorkflowPanelDarkV2({
steps: getRenderableAgentWorkflowStepTree(source.steps || []), steps: getRenderableAgentWorkflowStepTree(source.steps || []),
}; };
}, [config]); }, [config]);
const pageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(config),
[config]
);
const flattenedResolvedSteps = useMemo( const flattenedResolvedSteps = useMemo(
() => () =>
flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter( flattenVisibleAgentWorkflowSteps(displayConfig.steps, selections).filter(
@ -1314,6 +1319,7 @@ export function AgentWorkflowPanelDarkV2({
siteLogo={siteLogo} siteLogo={siteLogo}
siteLogoDark={siteLogoDark} siteLogoDark={siteLogoDark}
activeKey="agent" activeKey="agent"
batchProcessingEnabled={pageFeatureSettings.batchProcessingEnabled}
rightSlot={topNavRightSlot} rightSlot={topNavRightSlot}
/> />

View File

@ -397,6 +397,7 @@ export function EnhanceProWorkspace({
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true); const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
const [faceOpen, setFaceOpen] = useState(true); const [faceOpen, setFaceOpen] = useState(true);
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 }); const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const [comparePosition, setComparePosition] = useState(50);
const [isPreviewDragging, setIsPreviewDragging] = useState(false); const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const previewDragStateRef = useRef<{ const previewDragStateRef = useRef<{
pointerId: number; pointerId: number;
@ -434,6 +435,9 @@ export function EnhanceProWorkspace({
: null; : null;
const activePreviewUrl = const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || ""; selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const showComparisonPreview = Boolean(
selectedGeneratedImage?.originalUrl && sourceImageUrl
);
const activePreviewLabel = const activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0 selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex) ? getActiveResultLabel(selectedResultIndex)
@ -518,6 +522,10 @@ export function EnhanceProWorkspace({
previewDragStateRef.current = null; previewDragStateRef.current = null;
}, [activePreviewUrl]); }, [activePreviewUrl]);
useEffect(() => {
setComparePosition(50);
}, [selectedResultIndex]);
useEffect(() => { useEffect(() => {
if (formState.face_enhancement) { if (formState.face_enhancement) {
setFaceOpen(true); setFaceOpen(true);
@ -733,16 +741,62 @@ export function EnhanceProWorkspace({
onPointerMove={handlePreviewPointerMove} onPointerMove={handlePreviewPointerMove}
onPointerUp={finishPreviewDrag} 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="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="overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]"> <div className="relative overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
<img <div
alt={activePreviewLabel} className="relative"
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)}
style={{ style={{
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`, 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> </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"> <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">

View File

@ -407,7 +407,13 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
const OFFICIAL_DROPDOWN_SCALE = 0.7; const OFFICIAL_DROPDOWN_SCALE = 0.7;
const OFFICIAL_NINE_GRID_WIDTH = 930; const OFFICIAL_NINE_GRID_WIDTH = 930;
const OFFICIAL_NINE_GRID_HEIGHT = 520; 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 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_WIDTH = 895;
const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PROMPT_COMPOSER_BASE_HEIGHT = 286;
const PRESET_COMPOSER_BASE_HEIGHT = 238; const PRESET_COMPOSER_BASE_HEIGHT = 238;
@ -1033,6 +1039,7 @@ function createMediaResourceNode({
function createNineGridCompositeNode({ function createNineGridCompositeNode({
anchor, anchor,
columns, columns,
functionKey = MULTI_VIEW_NINE_GRID_FUNCTION_KEY,
gridPrompt, gridPrompt,
option, option,
rows, rows,
@ -1042,6 +1049,7 @@ function createNineGridCompositeNode({
}: { }: {
anchor: CloneNode; anchor: CloneNode;
columns: number; columns: number;
functionKey?: string;
gridPrompt?: string; gridPrompt?: string;
option?: string; option?: string;
rows: number; rows: number;
@ -1073,6 +1081,7 @@ function createNineGridCompositeNode({
accent: "#8790ff", accent: "#8790ff",
draftValues: { draftValues: {
action: "IMAGE_GENERATE_NINE_GRID", action: "IMAGE_GENERATE_NINE_GRID",
functionKey,
generated: true, generated: true,
gridColumns: columns, gridColumns: columns,
gridPrompt: resolvedGridPrompt, gridPrompt: resolvedGridPrompt,
@ -1084,6 +1093,7 @@ function createNineGridCompositeNode({
outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "", outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "",
params: { params: {
columns, columns,
functionKey,
mode: "grid", mode: "grid",
option: option || "", option: option || "",
prompt: resolvedGridPrompt, prompt: resolvedGridPrompt,
@ -1187,6 +1197,110 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node); 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( function getConfiguredFunction(
config: VideoCanvasFunctionConfig, config: VideoCanvasFunctionConfig,
key: string, key: string,
@ -1293,19 +1407,23 @@ function composeFunctionPrompt(
return `${base}\n\n用户补充:${extra}`; 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( function composeMultiViewNineGridPrompt(
definition: VideoCanvasFunctionDefinition, definition: VideoCanvasFunctionDefinition,
option?: string, option?: string,
) { ) {
const configuredPrompt = definition.prompt.trim() || getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY); return composeConfiguredImagePresetPrompt(definition, option);
const selectedOption = String(option || "").trim();
const parts = [configuredPrompt];
if (selectedOption) {
parts.push(`用户选择:${selectedOption}`);
}
return parts.join("\n\n");
} }
function getFirstGeneratedImageUrl( function getFirstGeneratedImageUrl(
@ -1616,15 +1734,50 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode { function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
const action = getDraftString(node, "action"); const action = getDraftString(node, "action");
const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node);
const isNineGridResultNode = const isNineGridResultNode =
action === "IMAGE_GENERATE_NINE_GRID" || !isCharacterThreeViewResultNode &&
Boolean(node.draftValues?.nineGridComposite) || (action === "IMAGE_GENERATE_NINE_GRID" ||
(node.inputType === "image" && /多机位九宫格/.test(node.title || "")); Boolean(node.draftValues?.nineGridComposite) ||
(node.inputType === "image" && /多机位九宫格/.test(node.title || "")));
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) { if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
return normalizeTextResourceNode(node, fallbackIndex); 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) { 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 { return {
...node, ...node,
width: OFFICIAL_NINE_GRID_WIDTH, width: OFFICIAL_NINE_GRID_WIDTH,
@ -1632,9 +1785,18 @@ function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNod
draftValues: { draftValues: {
...(node.draftValues || {}), ...(node.draftValues || {}),
action: "IMAGE_GENERATE_NINE_GRID", 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, nineGridComposite: true,
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536", 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 === "角色三视图") { if (starterTitle === "角色三视图") {
const preset = "character-three-view"; const preset = CHARACTER_THREE_VIEW_FUNCTION_KEY;
const distance = 100; const distance = 100;
const prompt = getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY);
const left = createPresetMediaNode({ const left = createPresetMediaNode({
action: "IMAGE_RESOURCE", action: "IMAGE_RESOURCE",
height: 520, height: 520,
@ -1777,18 +1940,32 @@ function createStarterWorkflow(
x: Math.round(center.x - 874 - distance / 2), x: Math.round(center.x - 874 - distance / 2),
y: Math.round(center.y - 320), y: Math.round(center.y - 320),
}); });
const right = createPresetMediaNode({ const rightBase = createPresetMediaNode({
action: "IMAGE_GENERATE", action: "IMAGE_GENERATE",
generated: true, generated: true,
height: 520, height: 520,
mediaType: "image", mediaType: "image",
mediaUrl: CHARACTER_THREE_VIEW_URL, mediaUrl: CHARACTER_THREE_VIEW_URL,
preset, preset,
title: "角色三视图", prompt,
width: 660, title: "角色三视图生成",
width: OFFICIAL_NINE_GRID_WIDTH,
x: Math.round(center.x + distance / 2), x: Math.round(center.x + distance / 2),
y: Math.round(center.y - 320), 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 }; 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]; const title = labels[action];
if (action === "grid") { if (action === "grid") {
const { columns, rows } = normalizeGridOption(option || "9宫格"); const functionKey =
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, MULTI_VIEW_NINE_GRID_FUNCTION_KEY); getOfficialImagePresetFunctionKey(option) || MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
const prompt = composeMultiViewNineGridPrompt(gridDefinition, option); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
const gridNode = createNineGridCompositeNode({ const prompt =
anchor, functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
columns, ? composeMultiViewNineGridPrompt(definition, option)
gridPrompt: prompt, : composeConfiguredImagePresetPrompt(definition, option);
option, const shouldGenerate = videoCanvasFunctionConfig.enabled && definition.enabled;
rows, const resultTitle = option?.trim() || definition.label || title;
sourceUrl, 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, x: rightX,
y: centerY(OFFICIAL_NINE_GRID_HEIGHT), y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
}); });
const shouldGenerate = videoCanvasFunctionConfig.enabled && gridDefinition?.enabled; const pendingNode: CloneNode = {
const pendingGridNode: CloneNode = { ...resultNode,
...gridNode, subtitle: shouldGenerate ? "生成中" : "生成结果",
draftValues: { draftValues: {
...(gridNode.draftValues || {}), ...(resultNode.draftValues || {}),
generated: !shouldGenerate, action: "IMAGE_GENERATE",
generationProgress: shouldGenerate ? 2 : 100, functionKey,
generationStatus: shouldGenerate ? "pending" : "complete", generated: false,
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"), 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: "生成中", pendingLabel: "生成中",
prompt,
sourceNodeId: anchor.id, sourceNodeId: anchor.id,
sourceUrl, sourceUrl,
url: shouldGenerate ? [] : gridNode.draftValues?.url, toolbarOption: resultTitle,
url: [],
}, },
}; };
setNodes((current) => [...current, pendingGridNode]); setNodes((current) => [...current, pendingNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]);
setSelectedNodeId(pendingGridNode.id); setSelectedNodeId(pendingNode.id);
setCanvasCommand(null); setCanvasCommand(null);
setNodeContextMenu(null); setNodeContextMenu(null);
setConnectionMenu(null); setConnectionMenu(null);
setActivePanel(null); setActivePanel(null);
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) { if (shouldGenerate) {
const response = await imageGenerationService.generateImage({ const response = await imageGenerationService.generateImage({
aspectRatio: "16:9", aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1, imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, gridDefinition), model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt, prompt,
promptExtend: false,
referenceImage: sourceUrl, referenceImage: sourceUrl,
}); });
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
if (generatedUrl) { if (generatedUrl) {
setNodes((current) => setNodes((current) =>
current.map((node) => current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? { ? {
...node, ...node,
subtitle: "生成结果",
draftValues: { draftValues: {
...(node.draftValues || {}), ...(node.draftValues || {}),
functionKey,
generated: true, generated: true,
generationProgress: 100, generationProgress: 100,
generationStatus: "complete", generationStatus: "complete",
mediaUrl: generatedUrl, mediaUrl: generatedUrl,
params: {
...getNodeParams(node),
functionKey,
option: resultTitle,
prompt,
},
prompt, prompt,
sourceNodeId: anchor.id, sourceNodeId: anchor.id,
sourceUrl, sourceUrl,
toolbarOption: resultTitle,
url: [generatedUrl], url: [generatedUrl],
}, },
} }
@ -2749,10 +3074,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
) )
); );
} else { } else {
const errorMessage = response.error || "九宫格生成未返回结果"; const errorMessage = response.error || `${definition.label}生成未返回结果`;
setNodes((current) => setNodes((current) =>
current.map((node) => current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending" node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? { ? {
...node, ...node,
draftValues: { draftValues: {
@ -2764,10 +3089,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
: node : node
) )
); );
toast.error(`${errorMessage},已保留可切分占位节点`); toast.error(`${errorMessage},已保留占位节点`);
} }
} }
toast.success(shouldGenerate ? "已创建生成中占位节点" : "已按预设提示词生成多机位九宫格结果图"); toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`);
return; return;
} }
@ -2844,9 +3169,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
} else if (definition.route === "canvas-generate-image") { } else if (definition.route === "canvas-generate-image") {
const response = await imageGenerationService.generateImage({ const response = await imageGenerationService.generateImage({
aspectRatio: "16:9", aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1, imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition), model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt, prompt,
promptExtend: false,
referenceImage: sourceUrl, referenceImage: sourceUrl,
}); });
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -2955,30 +3282,40 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const starterMode = getPresetMode(anchor); const starterMode = getPresetMode(anchor);
const slashScene = payload.slashScene || "";
const promptInput = payload.prompt.trim();
const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene);
const functionKey = const functionKey =
payload.slashScene && /九宫格|宫格|多机位/.test(payload.slashScene) slashFunctionKey ||
? MULTI_VIEW_NINE_GRID_FUNCTION_KEY (starterMode === CHARACTER_THREE_VIEW_FUNCTION_KEY
: starterMode === "character-three-view" ? CHARACTER_THREE_VIEW_FUNCTION_KEY
? "character-three-view" : "image-repaint");
: "image-repaint";
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
const prompt = composeFunctionPrompt( const isWideGeneration = isWideImageGenerationFunction(functionKey);
definition, const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : 420;
payload.prompt.trim() || payload.slashScene || "生成角色三视图", 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 = const finalPrompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, payload.prompt.trim() || payload.slashScene || "多机位九宫格") ? composeMultiViewNineGridPrompt(definition, promptInput || slashScene || "多机位九宫格")
: prompt; : isWideGeneration
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1)); ? composeConfiguredImagePresetPrompt(definition, promptInput || slashScene || definition.label)
: composeFunctionPrompt(
definition,
promptInput || slashScene || "生成角色三视图",
);
let generatedUrl = ""; let generatedUrl = "";
if (videoCanvasFunctionConfig.enabled && definition?.enabled) { if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
const response = await imageGenerationService.generateImage({ const response = await imageGenerationService.generateImage({
aspectRatio: payload.settings.ratio, aspectRatio: isWideGeneration ? "16:9" : payload.settings.ratio,
imageCount: count, billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: isWideGeneration ? 1 : count,
model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model), model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model),
prompt: finalPrompt, prompt: finalPrompt,
promptExtend: !isWideGeneration,
referenceImage: sourceUrl, referenceImage: sourceUrl,
}); });
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -2990,38 +3327,74 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const generatedNode = generatedUrl const generatedNode = generatedUrl
? createMediaResourceNode({ ? createMediaResourceNode({
action: "IMAGE_RESOURCE", action: "IMAGE_RESOURCE",
height: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_HEIGHT : 280, height: resultHeight,
mediaType: "image", mediaType: "image",
mediaUrl: generatedUrl, mediaUrl: generatedUrl,
prompt: finalPrompt, prompt: finalPrompt,
title: payload.slashScene || definition.label || "图片生成", title: resultTitle,
width: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_WIDTH : 420, width: resultWidth,
x: rightX, 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; : 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 = { const resultNode: CloneNode = {
...created, ...created,
title: payload.slashScene || definition.label || "图片生成", title: resultTitle,
y: y:
generatedNode?.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: { draftValues: {
...(created.draftValues || {}), ...(created.draftValues || {}),
functionKey,
generated: Boolean(generatedUrl) || created.draftValues?.generated, 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: { params: {
functionKey,
mode:
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY
? "character-three-view"
: isPresetGridFunction(functionKey)
? "grid"
: "image",
prompt: finalPrompt, prompt: finalPrompt,
references: payload.references, references: payload.references,
settings: payload.settings, settings: payload.settings,
slashScene: payload.slashScene, slashScene,
sourceNodeId: anchor.id, sourceNodeId: anchor.id,
sourceUrl, sourceUrl,
}, },
prompt: finalPrompt,
sourceNodeId: anchor.id, sourceNodeId: anchor.id,
sourceUrl, sourceUrl,
targetPreset: starterMode || undefined, targetPreset: starterMode || undefined,
toolbarOption: payload.slashScene || "", toolbarOption: slashScene,
}, },
}; };
const referenceEdges = payload.references const referenceEdges = payload.references
@ -3233,8 +3606,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
try { try {
if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) { 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 = 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 ? MULTI_VIEW_NINE_GRID_FUNCTION_KEY
: node.draftValues?.generatorType === "ENHANCE" : node.draftValues?.generatorType === "ENHANCE"
? "hd-upscale" ? "hd-upscale"
@ -3244,7 +3628,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt = const prompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, String(params.option || node.title)) ? 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)); : composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title));
const gridLayout = getPresetGridLayout(functionKey);
let generatedUrl = ""; let generatedUrl = "";
if (definition.route === "enhance" && sourceUrl) { 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 || "图片增强失败"); if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败");
} else { } else {
const response = await imageGenerationService.generateImage({ 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, imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef), model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
prompt, prompt,
promptExtend: !isWideImageGenerationFunction(functionKey),
referenceImage: sourceUrl || undefined, referenceImage: sourceUrl || undefined,
}); });
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : ""; generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -3273,10 +3662,28 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
subtitle: "生成结果", subtitle: "生成结果",
draftValues: { draftValues: {
...(item.draftValues || {}), ...(item.draftValues || {}),
functionKey,
generated: true, 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", mediaType: "image",
mediaUrl: generatedUrl, mediaUrl: generatedUrl,
nodeType: "image", nodeType: "image",
outputSizeLabel: isWideImageGenerationFunction(functionKey)
? "2752 × 1536"
: item.draftValues?.outputSizeLabel,
params: {
...getNodeParams(item),
functionKey,
prompt,
},
prompt, prompt,
sourceUrl, sourceUrl,
url: [generatedUrl], url: [generatedUrl],
@ -4523,8 +4930,8 @@ function PresetWorkflowControls({
onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void; onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void;
selectedNode: CloneNode; selectedNode: CloneNode;
}) { }) {
if (mode === "character-three-view") { if (mode === CHARACTER_THREE_VIEW_FUNCTION_KEY) {
const definition = getConfiguredFunction(functionConfig, "character-three-view"); const definition = getConfiguredFunction(functionConfig, CHARACTER_THREE_VIEW_FUNCTION_KEY);
return ( return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true"> <div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} /> <CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
@ -7382,7 +7789,7 @@ function PresetMediaCanvasNode({
) : mediaUrl ? ( ) : mediaUrl ? (
<img <img
alt={node.title} 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} draggable={false}
onDragStart={preventNativeImageDrag} onDragStart={preventNativeImageDrag}
src={mediaUrl} src={mediaUrl}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
ArrowLeft,
Circle as CircleIcon, Circle as CircleIcon,
Eraser, Eraser,
FolderOpen, FolderOpen,
@ -850,8 +851,6 @@ export function RealtimeCanvasDialog({
defaultRealtimeConfig.transport.throttleInterval defaultRealtimeConfig.transport.throttleInterval
); );
const [refreshToken, setRefreshToken] = useState(0); const [refreshToken, setRefreshToken] = useState(0);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false); const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
const [modeIconConfig, setModeIconConfig] = const [modeIconConfig, setModeIconConfig] =
useState<RealtimeCanvasModeIconConfig>({ useState<RealtimeCanvasModeIconConfig>({
@ -908,17 +907,6 @@ export function RealtimeCanvasDialog({
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((data) => { .then((data) => {
if (!data || cancelled) return; 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); setIsLayerSplitEnabled(data.layerSplitEnabled === true);
const iconConfig = const iconConfig =
data.realtimeCanvasModeIconConfig && data.realtimeCanvasModeIconConfig &&
@ -3010,21 +2998,8 @@ export function RealtimeCanvasDialog({
}, [previewUrl]); }, [previewUrl]);
const handleCloseRealtime = useCallback(() => { 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(false);
}, [onOpenChange, standalone]); }, [onOpenChange]);
const handleGoHome = useCallback(() => {
if (typeof window === "undefined") return;
window.location.assign("/");
}, []);
const closeBrushSettingPopovers = useCallback(() => { const closeBrushSettingPopovers = useCallback(() => {
setBrushPopoverOpen(false); setBrushPopoverOpen(false);
@ -3157,27 +3132,15 @@ export function RealtimeCanvasDialog({
> >
<button <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" 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} onClick={handleCloseRealtime}
title="返回首页" title={standalone ? "返回无限画布" : "关闭并返回画布"}
type="button" 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)]"> <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 ? ( <ArrowLeft className="h-4 w-4 text-[#111111]" />
<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]" />
)}
</span> </span>
<span className="font-semibold text-[14px] tracking-[-0.02em]"> <span className="font-semibold text-[14px] tracking-[-0.02em]">
{siteName} {standalone ? "返回无限画布" : "返回画布"}
</span> </span>
</button> </button>

View File

@ -2767,6 +2767,7 @@ export function ImageNode({
referenceImage: data.src, referenceImage: data.src,
aspectRatio, aspectRatio,
imageCount: 1, imageCount: 1,
billingFeature: "repaint",
}); });
const image = result.data?.images?.[0]; const image = result.data?.images?.[0];

View File

@ -566,6 +566,7 @@ function CameraMovementControls({
pressedKeysRef.current = createMovementState(); pressedKeysRef.current = createMovementState();
return; return;
} }
event.preventDefault();
pressedKeysRef.current[movement] = true; pressedKeysRef.current[movement] = true;
}; };
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
@ -575,6 +576,7 @@ function CameraMovementControls({
pressedKeysRef.current = createMovementState(); pressedKeysRef.current = createMovementState();
return; return;
} }
event.preventDefault();
pressedKeysRef.current[movement] = false; pressedKeysRef.current[movement] = false;
}; };
const handleFocusIn = (event: FocusEvent) => { const handleFocusIn = (event: FocusEvent) => {

View File

@ -30,6 +30,7 @@ export const defaultAgentWorkflowPageFeatureSettings: AgentWorkflowPageFeatureSe
presetStepEnabled: true, presetStepEnabled: true,
materialStepEnabled: true, materialStepEnabled: true,
psdTemplateEnabled: true, psdTemplateEnabled: true,
batchProcessingEnabled: true,
}; };
export interface AgentWorkflowBrandCategoryOption { export interface AgentWorkflowBrandCategoryOption {
@ -258,6 +259,10 @@ export function normalizeAgentWorkflowPageFeatureSettings(
record.psdTemplateEnabled ?? record.enablePsdTemplate, record.psdTemplateEnabled ?? record.enablePsdTemplate,
defaultAgentWorkflowPageFeatureSettings.psdTemplateEnabled 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, presetStepEnabled: raw.presetStepEnabled ?? raw.modeStepEnabled,
materialStepEnabled: raw.materialStepEnabled ?? raw.skuStepEnabled, materialStepEnabled: raw.materialStepEnabled ?? raw.skuStepEnabled,
psdTemplateEnabled: raw.psdTemplateEnabled, psdTemplateEnabled: raw.psdTemplateEnabled,
batchProcessingEnabled: raw.batchProcessingEnabled,
} }
), ),
steps: sanitizedSteps, steps: sanitizedSteps,

View File

@ -424,14 +424,14 @@ function getModeConstraintOptions(
const constraint = mode ? modeConstraints?.[mode] : undefined; const constraint = mode ? modeConstraints?.[mode] : undefined;
const ratioOptions = constraint?.ratioOptions?.length ? constraint.ratioOptions : []; const ratioOptions = constraint?.ratioOptions?.length ? constraint.ratioOptions : [];
const durationOptions = constraint?.durationOptions?.length ? constraint.durationOptions : []; const durationOptions = constraint?.durationOptions?.length ? constraint.durationOptions : [];
const resolutionOptions = constraint?.resolutionOptions?.length const hasResolutionOptions = Array.isArray(constraint?.resolutionOptions);
? constraint.resolutionOptions const resolutionOptions = hasResolutionOptions ? constraint?.resolutionOptions || [] : [];
: [];
return { return {
ratioOptions, ratioOptions,
durationOptions, durationOptions,
resolutionOptions, resolutionOptions,
hasResolutionOptions,
defaults: { defaults: {
...defaults, ...defaults,
...(constraint?.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( function applyConfiguredDurationOptions(
capability: VideoToolCapabilityContract, capability: VideoToolCapabilityContract,
configuredDurationOptions: number[] configuredDurationOptions: number[]
@ -501,6 +526,8 @@ export function getVideoToolOptionsForMode(
: capability.durationOptions, : capability.durationOptions,
resolutionOptions: scoped.resolutionOptions.length resolutionOptions: scoped.resolutionOptions.length
? scoped.resolutionOptions ? scoped.resolutionOptions
: scoped.hasResolutionOptions
? []
: capability.resolutionOptions, : capability.resolutionOptions,
defaults: scoped.defaults, defaults: scoped.defaults,
}; };
@ -593,9 +620,15 @@ export function buildVideoToolCapability(params: {
return acc; return acc;
}, {}) }, {})
: base.modeConstraints; : base.modeConstraints;
const restrictedModeConstraints = restrictModeConstraintResolutions(
nextModeConstraints,
parsed.supportedResolutions && parsed.supportedResolutions.length
? parsed.supportedResolutions
: []
);
const defaultMode = pickPreferredMode(modeOptions, base.defaults.mode); const defaultMode = pickPreferredMode(modeOptions, base.defaults.mode);
const modeScopedDefaults = getModeConstraintOptions( const modeScopedDefaults = getModeConstraintOptions(
nextModeConstraints, restrictedModeConstraints,
defaultMode, defaultMode,
base.defaults base.defaults
); );
@ -605,7 +638,7 @@ export function buildVideoToolCapability(params: {
const effectiveDurationOptions = modeScopedDefaults.durationOptions.length const effectiveDurationOptions = modeScopedDefaults.durationOptions.length
? modeScopedDefaults.durationOptions ? modeScopedDefaults.durationOptions
: durationOptions; : durationOptions;
const effectiveResolutionOptions = modeScopedDefaults.resolutionOptions.length const effectiveResolutionOptions = modeScopedDefaults.hasResolutionOptions
? modeScopedDefaults.resolutionOptions ? modeScopedDefaults.resolutionOptions
: resolutionOptions; : resolutionOptions;
@ -617,7 +650,7 @@ export function buildVideoToolCapability(params: {
ratioOptions, ratioOptions,
resolutionOptions, resolutionOptions,
durationOptions, durationOptions,
modeConstraints: nextModeConstraints, modeConstraints: restrictedModeConstraints,
ui: { ui: {
...base.ui, ...base.ui,
showReturnLastFrame: showReturnLastFrame:

View File

@ -19,8 +19,10 @@ export interface CanvasGenerateImageRequest {
aspectRatio?: string; aspectRatio?: string;
imageSize?: string; imageSize?: string;
imageCount?: number; imageCount?: number;
promptExtend?: boolean;
referenceImage?: string; referenceImage?: string;
referenceImages?: string[]; referenceImages?: string[];
billingFeature?: "multi-view" | "repaint";
debugLabel?: string; debugLabel?: string;
debugMeta?: Record<string, unknown>; debugMeta?: Record<string, unknown>;
} }
@ -123,6 +125,8 @@ export async function generateCanvasImage(
typeof request.imageCount === "number" && Number.isFinite(request.imageCount) typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
? Math.max(1, Math.min(12, Math.floor(request.imageCount))) ? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
: 1; : 1;
const promptExtend =
typeof request.promptExtend === "boolean" ? request.promptExtend : undefined;
const referenceImage = const referenceImage =
typeof request.referenceImage === "string" ? request.referenceImage : undefined; typeof request.referenceImage === "string" ? request.referenceImage : undefined;
const referenceImages = Array.isArray(request.referenceImages) const referenceImages = Array.isArray(request.referenceImages)
@ -241,6 +245,7 @@ export async function generateCanvasImage(
imageSize, imageSize,
imageCount: 1, imageCount: 1,
negativePrompt, negativePrompt,
promptExtend,
}; };
let executeGenerateBatch: let executeGenerateBatch:

View File

@ -3946,6 +3946,7 @@ export async function createAiModel({
fallbackModelId2, fallbackModelId2,
excludeFromFailover, excludeFromFailover,
imageGenConfig, imageGenConfig,
capabilities,
isAgentModel, isAgentModel,
isMultimodal, isMultimodal,
agentCodingModelId, agentCodingModelId,
@ -3965,6 +3966,7 @@ export async function createAiModel({
fallbackModelId2?: string; fallbackModelId2?: string;
excludeFromFailover?: boolean; excludeFromFailover?: boolean;
imageGenConfig?: ImageGenConfig; imageGenConfig?: ImageGenConfig;
capabilities?: Record<string, unknown> | null;
isAgentModel?: boolean; isAgentModel?: boolean;
isMultimodal?: boolean; isMultimodal?: boolean;
agentCodingModelId?: string; agentCodingModelId?: string;
@ -4009,8 +4011,8 @@ export async function createAiModel({
defaultParams, defaultParams,
capabilities: capabilities:
isMultimodal === undefined isMultimodal === undefined
? null ? capabilities || null
: withMultimodalModelFlag(null, isMultimodal), : withMultimodalModelFlag(capabilities || null, isMultimodal),
}) })
.returning(); .returning();
} catch (_error) { } catch (_error) {
@ -4038,6 +4040,7 @@ export async function updateAiModel({
fallbackModelId2, fallbackModelId2,
excludeFromFailover, excludeFromFailover,
imageGenConfig, imageGenConfig,
capabilities,
isAgentModel, isAgentModel,
isMultimodal, isMultimodal,
agentCodingModelId, agentCodingModelId,
@ -4058,6 +4061,7 @@ export async function updateAiModel({
fallbackModelId2?: string; fallbackModelId2?: string;
excludeFromFailover?: boolean; excludeFromFailover?: boolean;
imageGenConfig?: ImageGenConfig; imageGenConfig?: ImageGenConfig;
capabilities?: Record<string, unknown> | null;
isAgentModel?: boolean; isAgentModel?: boolean;
isMultimodal?: boolean; isMultimodal?: boolean;
agentCodingModelId?: string; agentCodingModelId?: string;
@ -4093,7 +4097,8 @@ export async function updateAiModel({
if ( if (
isAgentModel !== undefined || isAgentModel !== undefined ||
isMultimodal !== undefined || isMultimodal !== undefined ||
agentCodingModelId !== undefined agentCodingModelId !== undefined ||
capabilities !== undefined
) { ) {
const [existingModel] = await db const [existingModel] = await db
.select({ .select({
@ -4125,11 +4130,13 @@ export async function updateAiModel({
updateData.defaultParams = nextDefaultParams; updateData.defaultParams = nextDefaultParams;
} }
if (isMultimodal !== undefined) { if (capabilities !== undefined || isMultimodal !== undefined) {
updateData.capabilities = withMultimodalModelFlag( const nextCapabilities =
existingModel.capabilities, capabilities !== undefined ? capabilities || null : existingModel.capabilities;
isMultimodal updateData.capabilities =
); isMultimodal !== undefined
? withMultimodalModelFlag(nextCapabilities, isMultimodal)
: nextCapabilities;
} }
} }

View File

@ -30,12 +30,40 @@ export type PointsSystemConfig = {
enabled: boolean; enabled: boolean;
blockOnInsufficient: boolean; blockOnInsufficient: boolean;
creditCostPer3DModel: number; creditCostPer3DModel: number;
creditCostPer3DModelPbr: number;
creditCostPer3DModelSmartLowPoly: number;
creditCostPer3DModelQuad: number;
creditCostPer3DModelRefine: number;
creditCostPer3DModelTexture: number;
creditCostPer3DModelAutoRig: number;
creditCostPer3DModelHighpolyToLowpoly: number;
creditCostPer3DWorld: number; creditCostPer3DWorld: number;
creditCostPer3DWorldMarble11: number; creditCostPer3DWorldMarble11: number;
creditCostPer3DWorldMarble11Plus: number; creditCostPer3DWorldMarble11Plus: number;
creditCostPerImageMultiView: number;
creditCostPerImageRemoveBackground: number;
creditCostPerImageBoxCutout: number;
creditCostPerImageWatermarkRemove: number;
creditCostPerImageExtend: number;
creditCostPerImageRepaint: number;
enhanceProPricing: EnhanceProPointsPricingConfig; 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 = { export type PointsPackage = {
id: string; id: string;
name: string; name: string;
@ -49,9 +77,22 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false, enabled: false,
blockOnInsufficient: true, blockOnInsufficient: true,
creditCostPer3DModel: 0, creditCostPer3DModel: 0,
creditCostPer3DModelPbr: 0,
creditCostPer3DModelSmartLowPoly: 0,
creditCostPer3DModelQuad: 0,
creditCostPer3DModelRefine: 0,
creditCostPer3DModelTexture: 0,
creditCostPer3DModelAutoRig: 0,
creditCostPer3DModelHighpolyToLowpoly: 0,
creditCostPer3DWorld: 0, creditCostPer3DWorld: 0,
creditCostPer3DWorldMarble11: 0, creditCostPer3DWorldMarble11: 0,
creditCostPer3DWorldMarble11Plus: 0, creditCostPer3DWorldMarble11Plus: 0,
creditCostPerImageMultiView: 0,
creditCostPerImageRemoveBackground: 0,
creditCostPerImageBoxCutout: 0,
creditCostPerImageWatermarkRemove: 0,
creditCostPerImageExtend: 0,
creditCostPerImageRepaint: 0,
enhanceProPricing: { enhanceProPricing: {
enabled: false, enabled: false,
chargeMode: "scale_factor", chargeMode: "scale_factor",
@ -390,6 +431,67 @@ export function get3DWorldCreditCost(
return normalizeCost(pointsConfig.creditCostPer3DWorld); 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 { export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return defaultPointsSystemConfig; return defaultPointsSystemConfig;
@ -410,6 +512,17 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DModel: normalizeCost( creditCostPer3DModel: normalizeCost(
raw.creditCostPer3DModel ?? legacy3DCost 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( creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost raw.creditCostPer3DWorld ?? legacy3DCost
), ),
@ -419,6 +532,16 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DWorldMarble11Plus: normalizeCost( creditCostPer3DWorldMarble11Plus: normalizeCost(
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost 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( enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing, raw.enhanceProPricing,
legacyEnhanceProCost legacyEnhanceProCost

View 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,
});
},
};
}

View File

@ -44,14 +44,25 @@ export type VideoCanvasFunctionConfig = {
const defaultFunctions: VideoCanvasFunctionDefinition[] = [ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
{ {
key: "character-three-view", key: "story-beat-grid-4",
label: "角色三视图", label: "剧情推演四宫格",
kind: "image-edit", kind: "image-edit",
route: "canvas-generate-image", route: "canvas-generate-image",
enabled: true, enabled: true,
creditCost: 14, creditCost: 14,
prompt: 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: "官方角色三视图预设,使用参考图生图/编辑模型。", description: "官方角色三视图预设,使用参考图生图/编辑模型。",
}, },
{ {
@ -65,6 +76,50 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。", "以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。", 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", key: "grid-split",
label: "宫格切分", label: "宫格切分",
@ -242,6 +297,58 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); 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( function normalizeFunctionDefinition(
key: string, key: string,
value: unknown, value: unknown,
@ -251,7 +358,7 @@ function normalizeFunctionDefinition(
const normalized: VideoCanvasFunctionDefinition = { const normalized: VideoCanvasFunctionDefinition = {
...fallback, ...fallback,
key, key,
label: String(incoming.label || fallback.label), label: normalizeFunctionLabel(key, incoming.label, fallback.label),
kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind, kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind,
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route, route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled, enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
@ -260,7 +367,7 @@ function normalizeFunctionDefinition(
typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost) typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost)
? Math.max(0, incoming.creditCost) ? Math.max(0, incoming.creditCost)
: fallback.creditCost, : fallback.creditCost,
prompt: String(incoming.prompt || fallback.prompt), prompt: normalizeFunctionPrompt(key, incoming.prompt, fallback.prompt),
description: String(incoming.description || fallback.description), description: String(incoming.description || fallback.description),
requiresBackend: requiresBackend:
typeof incoming.requiresBackend === "boolean" typeof incoming.requiresBackend === "boolean"

View File

@ -253,7 +253,9 @@ export const imageGenerationService = {
model?: string; model?: string;
aspectRatio?: string; aspectRatio?: string;
imageCount?: number; imageCount?: number;
promptExtend?: boolean;
referenceImage?: string; referenceImage?: string;
billingFeature?: 'multi-view' | 'repaint';
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> { }): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> {
try { try {
const response = await fetch('/api/canvas/generate/image', { const response = await fetch('/api/canvas/generate/image', {

View File

@ -109,6 +109,7 @@ export interface AgentWorkflowPageFeatureSettings {
presetStepEnabled: boolean; presetStepEnabled: boolean;
materialStepEnabled: boolean; materialStepEnabled: boolean;
psdTemplateEnabled: boolean; psdTemplateEnabled: boolean;
batchProcessingEnabled: boolean;
} }
export interface AgentWorkflowUiConfig { export interface AgentWorkflowUiConfig {