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_CONFIG_KEY = "home_chat_animation_config";
const VIDEO_PRICING_PREVIEW_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
type AdminVideoResolution = (typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number];
const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
(typeof VIDEO_PRICING_PREVIEW_RESOLUTIONS)[number],
AdminVideoResolution,
number
> = {
"480p": 1,
@ -202,6 +203,38 @@ const DEFAULT_VIDEO_RESOLUTION_MULTIPLIERS: Record<
"1080p": 2,
};
function normalizeAdminVideoResolutions(value: unknown): AdminVideoResolution[] {
if (!Array.isArray(value)) return [];
const allowed = new Set<string>(VIDEO_PRICING_PREVIEW_RESOLUTIONS);
return Array.from(
new Set(
value
.map((item) => String(item || "").trim().toLowerCase())
.filter((item): item is AdminVideoResolution => allowed.has(item))
)
);
}
function getAdminVideoSupportedResolutions(capabilities: unknown): AdminVideoResolution[] {
if (!capabilities || typeof capabilities !== "object") {
return [...VIDEO_PRICING_PREVIEW_RESOLUTIONS];
}
const configured = normalizeAdminVideoResolutions(
(capabilities as { supportedResolutions?: unknown }).supportedResolutions
);
return configured.length ? configured : [...VIDEO_PRICING_PREVIEW_RESOLUTIONS];
}
function updateAdminVideoSupportedResolutions(
capabilities: Record<string, unknown> | null | undefined,
nextResolutions: AdminVideoResolution[]
) {
return {
...(capabilities || {}),
supportedResolutions: nextResolutions,
};
}
function formatCompactNumber(value: number, fractionDigits = 2) {
if (!Number.isFinite(value)) return "0";
const fixed = value.toFixed(fractionDigits);
@ -553,6 +586,107 @@ function applyRecommendedEnhanceProPricing(
};
}
type PointsCostField =
| "creditCostPer3DModelPbr"
| "creditCostPer3DModelSmartLowPoly"
| "creditCostPer3DModelQuad"
| "creditCostPer3DModelRefine"
| "creditCostPer3DModelTexture"
| "creditCostPer3DModelAutoRig"
| "creditCostPer3DModelHighpolyToLowpoly"
| "creditCostPerImageMultiView"
| "creditCostPerImageRemoveBackground"
| "creditCostPerImageBoxCutout"
| "creditCostPerImageWatermarkRemove"
| "creditCostPerImageExtend"
| "creditCostPerImageRepaint";
const THREE_D_GENERATION_POINT_FIELDS: Array<{
key: PointsCostField;
label: string;
description: string;
}> = [
{
key: "creditCostPer3DModelPbr",
label: "PBR 材质",
description: "勾选 PBR 材质时,在 3D 模型基础积分上额外扣除。",
},
{
key: "creditCostPer3DModelSmartLowPoly",
label: "智能低模",
description: "勾选智能低模时额外扣除。",
},
{
key: "creditCostPer3DModelQuad",
label: "四边面优化",
description: "勾选四边面优化时额外扣除。",
},
];
const THREE_D_EDIT_POINT_FIELDS: Array<{
key: PointsCostField;
label: string;
description: string;
}> = [
{
key: "creditCostPer3DModelRefine",
label: "模型精修",
description: "提交 refine_model 精修任务时扣除。",
},
{
key: "creditCostPer3DModelTexture",
label: "纹理重绘",
description: "提交 texture_model 纹理重绘任务时扣除。",
},
{
key: "creditCostPer3DModelAutoRig",
label: "自动绑定",
description: "提交 animate_rig 自动绑定任务时扣除。",
},
{
key: "creditCostPer3DModelHighpolyToLowpoly",
label: "模型减面",
description: "提交 highpoly_to_lowpoly 减面任务时扣除。",
},
];
const IMAGE_TOOL_POINT_FIELDS: Array<{
key: PointsCostField;
label: string;
description: string;
}> = [
{
key: "creditCostPerImageMultiView",
label: "多视角",
description: "角色三视图 / 多机位九宫格等多视角图像生成时扣除。",
},
{
key: "creditCostPerImageRemoveBackground",
label: "抠图",
description: "调用图片抠图功能时扣除。",
},
{
key: "creditCostPerImageBoxCutout",
label: "框选抠图",
description: "调用框选抠图功能时扣除。",
},
{
key: "creditCostPerImageWatermarkRemove",
label: "去水印",
description: "调用去水印功能并成功创建任务后扣除。",
},
{
key: "creditCostPerImageExtend",
label: "扩图",
description: "调用图片拓展 / 扩图功能时扣除。",
},
{
key: "creditCostPerImageRepaint",
label: "重绘",
description: "调用图片提示词重绘功能时扣除。",
},
];
type AiModel = {
id: string;
providerId: string;
@ -572,6 +706,7 @@ type AiModel = {
isAgentModel?: boolean;
agentCodingModelId?: string;
isMultimodal?: boolean;
capabilities?: Record<string, unknown> | null;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
};
@ -1791,6 +1926,7 @@ export function AdminSettingsClient() {
() =>
enhanceProConfig.apiKeys.map((apiKey, index) => ({
id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
index,
name: `Krea Key ${index + 1}`,
maskedKey: maskAdminSecret(apiKey),
})),
@ -2680,6 +2816,7 @@ export function AdminSettingsClient() {
fallbackModelId1: "", // 备用模型 ID 1
fallbackModelId2: "", // 备用模型 ID 2
excludeFromFailover: false,
capabilities: {} as Record<string, unknown>,
// 🆕 图片生成模型特性配置
imageGenConfig: createDefaultImageGenConfig(),
});
@ -2740,13 +2877,18 @@ export function AdminSettingsClient() {
const isVideoProviderForModelConfig = isVideoModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen,
});
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen,
});
const modelVideoSupportedResolutions = getAdminVideoSupportedResolutions(
modelForm.capabilities
);
const configuredVideoDurationOptions = getConfiguredVideoDurationOptions(
modelForm.imageGenConfig
);
@ -3120,6 +3262,19 @@ export function AdminSettingsClient() {
});
};
const updatePointsCostField = (key: PointsCostField, value: unknown) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
return {
...prev,
pointsSystemConfig: {
...config,
[key]: roundCreditCost(value),
},
};
});
};
const updateEnhanceProPointsBucket = (
index: number,
updater: (item: EnhanceProPointsResolutionBucket) => EnhanceProPointsResolutionBucket
@ -3570,6 +3725,37 @@ export function AdminSettingsClient() {
}
};
const handleAddEnhanceProPoolKey = () => {
const normalizedKey = normalizeEnhanceProKreaConfig({
apiKey: enhanceProConfig.apiKey,
}).apiKey;
if (!normalizedKey) {
toast.error("请先填写要加入 Key 池的 Krea Bearer Token");
return;
}
setEnhanceProConfig((prev) => {
const nextKeys = normalizeEnhanceProKreaConfig({
apiKeys: [...prev.apiKeys, normalizedKey],
}).apiKeys;
return {
...prev,
apiKey: "",
apiKeys: nextKeys,
};
});
setEnhanceProApiKeysDraft("");
toast.success("Krea Key 已加入池,保存配置后生效");
};
const handleDeleteEnhanceProPoolKey = (index: number) => {
setEnhanceProConfig((prev) => ({
...prev,
apiKeys: prev.apiKeys.filter((_, itemIndex) => itemIndex !== index),
}));
};
const handleEnhanceProPresetChange = <K extends keyof EnhanceProPreset>(
index: number,
key: K,
@ -5539,6 +5725,7 @@ export function AdminSettingsClient() {
fallbackModelId1: "",
fallbackModelId2: "",
excludeFromFailover: false,
capabilities: {},
imageGenConfig: createDefaultImageGenConfig(),
});
setVideoDurationOptionsInput("");
@ -8746,6 +8933,108 @@ export function AdminSettingsClient() {
</div>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label className="text-sm font-medium">
3D 生成附加功能积分
</Label>
<div className="text-xs text-muted-foreground">
这些费用会叠加到“3D模型单次积分消耗”上;填写 0 表示不额外扣分。
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
{THREE_D_GENERATION_POINT_FIELDS.map((field) => (
<div
key={field.key}
className="rounded-md border bg-muted/20 p-3"
>
<Label htmlFor={`points-${field.key}`}>
{field.label}
</Label>
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
{field.description}
</div>
<Input
id={`points-${field.key}`}
min={0}
type="number"
value={pointsSystemConfig[field.key]}
onChange={(e) =>
updatePointsCostField(field.key, e.target.value)
}
/>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label className="text-sm font-medium">
3D 编辑与优化积分
</Label>
<div className="text-xs text-muted-foreground">
独立 3D 编辑任务按这里配置的单价扣除;填写 0 表示免费。
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-4">
{THREE_D_EDIT_POINT_FIELDS.map((field) => (
<div
key={field.key}
className="rounded-md border bg-muted/20 p-3"
>
<Label htmlFor={`points-${field.key}`}>
{field.label}
</Label>
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
{field.description}
</div>
<Input
id={`points-${field.key}`}
min={0}
type="number"
value={pointsSystemConfig[field.key]}
onChange={(e) =>
updatePointsCostField(field.key, e.target.value)
}
/>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label className="text-sm font-medium">
图像小功能积分
</Label>
<div className="text-xs text-muted-foreground">
多视角、抠图、框选抠图、去水印、扩图、重绘按这里单独扣分;填写 0 表示免费。
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
{IMAGE_TOOL_POINT_FIELDS.map((field) => (
<div
key={field.key}
className="rounded-md border bg-muted/20 p-3"
>
<Label htmlFor={`points-${field.key}`}>
{field.label}
</Label>
<div className="mt-1 min-h-8 text-xs text-muted-foreground">
{field.description}
</div>
<Input
id={`points-${field.key}`}
min={0}
type="number"
value={pointsSystemConfig[field.key]}
onChange={(e) =>
updatePointsCostField(field.key, e.target.value)
}
/>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
@ -12734,6 +13023,67 @@ export function AdminSettingsClient() {
</Button>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="space-y-1">
<div className="text-sm font-medium">
前台分辨率显示
</div>
<p className="text-xs text-muted-foreground">
关闭某个档位后,前台视频参数条不会再显示该分辨率,服务端也会拒绝该档位。
</p>
</div>
<div className="grid grid-cols-3 gap-4">
{VIDEO_PRICING_PREVIEW_RESOLUTIONS.map(
(resolutionKey) => {
const checked = modelVideoSupportedResolutions.includes(
resolutionKey
);
return (
<div
key={`video-resolution-${resolutionKey}`}
className="flex items-center gap-2"
>
<Switch
checked={checked}
onCheckedChange={(nextChecked) => {
const currentSet = new Set(
modelVideoSupportedResolutions
);
if (nextChecked) {
currentSet.add(resolutionKey);
} else {
currentSet.delete(resolutionKey);
}
const nextResolutions =
VIDEO_PRICING_PREVIEW_RESOLUTIONS.filter(
(item) => currentSet.has(item)
);
if (nextResolutions.length === 0) {
toast.error(
"视频模型至少保留一个可用分辨率"
);
return;
}
setModelForm({
...modelForm,
capabilities:
updateAdminVideoSupportedResolutions(
modelForm.capabilities,
nextResolutions
),
});
}}
/>
<Label className="text-sm">
{resolutionKey}
</Label>
</div>
);
}
)}
</div>
</div>
<div className="space-y-2">
<Label>支持视频秒数</Label>
<Input
@ -13295,6 +13645,12 @@ export function AdminSettingsClient() {
(model as any).fallbackModelId2 || "",
excludeFromFailover:
(model as any).excludeFromFailover || false,
capabilities:
(model as any).capabilities &&
typeof (model as any).capabilities === "object" &&
!Array.isArray((model as any).capabilities)
? (model as any).capabilities
: {},
imageGenConfig:
(model as any).imageGenConfig || createDefaultImageGenConfig(),
});
@ -15934,18 +16290,31 @@ export function AdminSettingsClient() {
<div className="space-y-2">
<Label htmlFor="enhanceProApiKey">API Key</Label>
<Input
id="enhanceProApiKey"
type="password"
value={enhanceProConfig.apiKey}
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder="请输入 Krea Bearer Token"
/>
<div className="flex flex-col gap-2 md:flex-row">
<Input
id="enhanceProApiKey"
type="password"
value={enhanceProConfig.apiKey}
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder="请输入 Krea Bearer Token"
/>
<Button
type="button"
variant="outline"
onClick={handleAddEnhanceProPoolKey}
>
<Plus className="mr-2 h-4 w-4" />
添加为新 Key
</Button>
</div>
<div className="text-xs text-muted-foreground">
和 Tripo3D 一样,单个 Key 可以直接加入池;批量导入仍支持一次粘贴多行。
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
@ -16066,6 +16435,17 @@ export function AdminSettingsClient() {
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => handleDeleteEnhanceProPoolKey(item.index)}
>
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
</div>
</div>
))
) : (

View File

@ -4663,7 +4663,7 @@ export function AgentWorkflowSettingsClient({
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<CardContent className="grid gap-3 md:grid-cols-4">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启第一步:选择生图模式</div>
@ -4708,17 +4708,17 @@ export function AgentWorkflowSettingsClient({
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启套 PSD 模板</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启套 PSD 模板</div>
<div className="text-xs text-muted-foreground">
关闭后前台隐藏所有“套PSD”按钮和 PSD 模板编辑弹窗入口。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.psdTemplateEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
checked={workflowPageFeatureSettings.psdTemplateEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
@ -4727,9 +4727,31 @@ export function AgentWorkflowSettingsClient({
},
}))
}
/>
</div>
</CardContent>
/>
</div>
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div className="pr-4">
<div className="text-sm font-medium">开启批量处理</div>
<div className="text-xs text-muted-foreground">
关闭后前台顶部导航和无限画布工具栏都隐藏“批量处理”入口。
</div>
</div>
<Switch
checked={workflowPageFeatureSettings.batchProcessingEnabled}
onCheckedChange={(checked) =>
setConfig((prev) => ({
...prev,
pageFeatureSettings: {
...(prev.pageFeatureSettings ||
defaultAgentWorkflowPageFeatureSettings),
batchProcessingEnabled: checked,
},
}))
}
/>
</div>
</CardContent>
</Card>
</motion.div>

View File

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

View File

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

View File

@ -6,6 +6,9 @@ import {
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const runtime = "nodejs";
export const maxDuration = 60;
@ -40,6 +43,19 @@ export async function POST(request: NextRequest) {
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
reason: "image_box_cutout",
modelId: "box-cutout",
note: "框选抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeBoxCutoutJob({
userId: session.user.id,
imageUrl: resolvedImageUrl,
@ -52,6 +68,14 @@ export async function POST(request: NextRequest) {
sourceNodeId: body.sourceNodeId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume box cutout points:", pointsError);
}
}
return NextResponse.json(result);
} catch (error) {
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {

View File

@ -9,6 +9,12 @@ import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
getImageToolCreditCost,
type ImageToolCreditFeature,
} from "@/lib/points/config";
export const maxDuration = 120;
@ -16,6 +22,13 @@ interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;
}
function normalizeBillingFeature(
value: unknown
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
if (value === "multi-view" || value === "repaint") return value;
return null;
}
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
prompt: String(body.prompt || "").trim(),
@ -34,6 +47,8 @@ function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImage
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount
: 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)
@ -107,6 +122,30 @@ export async function POST(request: Request) {
);
}
const billingFeature = normalizeBillingFeature(body.billingFeature);
const pointsCharge = billingFeature
? await (async () => {
const pointsConfig = await getPointsSystemConfig();
return prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
reason:
billingFeature === "multi-view"
? "image_multi_view"
: "image_repaint",
modelId: "canvas-generate-image",
note:
billingFeature === "multi-view"
? "图片多视角生成消耗积分"
: "图片重绘消耗积分",
pointsConfig,
});
})()
: null;
if (pointsCharge && !pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const asyncMode = body.async === true;
if (asyncMode) {
const task = await createCanvasImageTask({
@ -114,6 +153,18 @@ export async function POST(request: Request) {
request: payload,
});
await enqueueCanvasImageTask(task.id);
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume({
note:
billingFeature === "multi-view"
? `图片多视角生成消耗积分(${task.id})`
: `图片重绘消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image task points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
@ -122,6 +173,13 @@ export async function POST(request: Request) {
}
const result = await generateCanvasImage(payload);
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image points:", pointsError);
}
}
return NextResponse.json({
success: true,
data: result,

View File

@ -1,6 +1,9 @@
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
@ -39,6 +42,19 @@ export async function POST(request: NextRequest) {
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageUrl,
@ -50,6 +66,14 @@ export async function POST(request: NextRequest) {
yScale: body.yScale ? Number(body.yScale) : undefined,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
return NextResponse.json({
success: true,
original_url: result.original_url,
@ -68,6 +92,19 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageBase64: buffer.toString("base64"),
@ -76,6 +113,14 @@ export async function POST(request: NextRequest) {
messageId: String(formData.get("messageId") || "") || null,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
return NextResponse.json({
success: true,
original_url: result.original_url,

View File

@ -6,6 +6,9 @@ import {
fetchImageBufferFromUrl,
RmbgServiceError,
} from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const runtime = "nodejs";
export const maxDuration = 60;
@ -79,6 +82,19 @@ export async function POST(request: NextRequest) {
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
reason: "image_remove_background",
modelId: "rmbg",
note: "图片抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeRmbgJob({
userId: session.user.id,
imageBuffer,
@ -90,6 +106,14 @@ export async function POST(request: NextRequest) {
sourceImageUrl,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume RMBG points:", pointsError);
}
}
return NextResponse.json({
success: true,
...result,

View File

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

View File

@ -6,9 +6,12 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service";
import {
createMediaAssetRelation,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
} from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
@ -380,6 +383,19 @@ export async function POST(request: NextRequest) {
console.log("[WatermarkRemove] 开始处理图片:", publicImageUrl);
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "watermark-remove"),
reason: "image_watermark_remove",
modelId: "watermark-remove",
note: "去水印消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
// 创建去水印任务
const formData = new FormData();
formData.append("sync", "0");
@ -416,6 +432,16 @@ export async function POST(request: NextRequest) {
);
}
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `去水印任务消耗积分(${taskId})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume watermark remove points:", pointsError);
}
}
try {
const result = await finalizeWatermarkTaskResult({
taskId,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -397,6 +397,7 @@ export function EnhanceProWorkspace({
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
const [faceOpen, setFaceOpen] = useState(true);
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const [comparePosition, setComparePosition] = useState(50);
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const previewDragStateRef = useRef<{
pointerId: number;
@ -434,6 +435,9 @@ export function EnhanceProWorkspace({
: null;
const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const showComparisonPreview = Boolean(
selectedGeneratedImage?.originalUrl && sourceImageUrl
);
const activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex)
@ -518,6 +522,10 @@ export function EnhanceProWorkspace({
previewDragStateRef.current = null;
}, [activePreviewUrl]);
useEffect(() => {
setComparePosition(50);
}, [selectedResultIndex]);
useEffect(() => {
if (formState.face_enhancement) {
setFaceOpen(true);
@ -733,16 +741,62 @@ export function EnhanceProWorkspace({
onPointerMove={handlePreviewPointerMove}
onPointerUp={finishPreviewDrag}
>
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<div className="overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
<img
alt={activePreviewLabel}
className="pointer-events-none max-h-[calc(100vh-17rem)] max-w-[min(1000px,calc(100vw-35rem))] object-contain transition-transform duration-75"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<div className="relative overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
<div
className="relative"
style={{
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
}}
/>
>
<img
alt={activePreviewLabel}
className="pointer-events-none block max-h-[calc(100vh-14rem)] max-w-[min(1200px,calc(100vw-31rem))] object-contain transition-transform duration-75"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
/>
{showComparisonPreview && sourceImageUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
}}
>
<img
alt="原图对比"
className="h-full w-full object-contain"
src={getDisplayImageUrl(sourceImageUrl, 1800)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_18px_rgba(0,0,0,0.35)]"
style={{ left: `${comparePosition}%` }}
/>
</>
) : null}
</div>
{showComparisonPreview ? (
<>
<div className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
原图
</div>
<div className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
增强后
</div>
<input
aria-label="原图和增强结果对比滑块"
className="pointer-events-auto absolute right-6 bottom-5 left-6 accent-[#111111]"
max={100}
min={0}
onChange={(event) =>
setComparePosition(Number(event.target.value) || 0)
}
onPointerDown={(event) => event.stopPropagation()}
type="range"
value={comparePosition}
/>
</>
) : null}
</div>
</div>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3.5 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm">

View File

@ -407,7 +407,13 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
const OFFICIAL_DROPDOWN_SCALE = 0.7;
const OFFICIAL_NINE_GRID_WIDTH = 930;
const OFFICIAL_NINE_GRID_HEIGHT = 520;
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid";
const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25";
const CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY = "cinematic-lighting-correction";
const CHARACTER_THREE_VIEW_FUNCTION_KEY = "character-three-view";
const FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY = "frame-prediction-plus-3s";
const FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY = "frame-prediction-minus-5s";
const PROMPT_COMPOSER_BASE_WIDTH = 895;
const PROMPT_COMPOSER_BASE_HEIGHT = 286;
const PRESET_COMPOSER_BASE_HEIGHT = 238;
@ -1033,6 +1039,7 @@ function createMediaResourceNode({
function createNineGridCompositeNode({
anchor,
columns,
functionKey = MULTI_VIEW_NINE_GRID_FUNCTION_KEY,
gridPrompt,
option,
rows,
@ -1042,6 +1049,7 @@ function createNineGridCompositeNode({
}: {
anchor: CloneNode;
columns: number;
functionKey?: string;
gridPrompt?: string;
option?: string;
rows: number;
@ -1073,6 +1081,7 @@ function createNineGridCompositeNode({
accent: "#8790ff",
draftValues: {
action: "IMAGE_GENERATE_NINE_GRID",
functionKey,
generated: true,
gridColumns: columns,
gridPrompt: resolvedGridPrompt,
@ -1084,6 +1093,7 @@ function createNineGridCompositeNode({
outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "",
params: {
columns,
functionKey,
mode: "grid",
option: option || "",
prompt: resolvedGridPrompt,
@ -1187,6 +1197,110 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node);
}
function getOfficialImagePresetFunctionKey(value?: unknown) {
const text = String(value || "").trim();
if (!text) return null;
if (/角色三视图|三视图生成|正侧背|character[-_\s]?(three|turnaround)/i.test(text)) {
return CHARACTER_THREE_VIEW_FUNCTION_KEY;
}
if (/剧情推演|四宫格|4\s*宫格|2\s*[x×]\s*2/.test(text)) {
return STORY_BEAT_GRID_FUNCTION_KEY;
}
if (/25\s*宫格|连贯分镜|5\s*[x×]\s*5/.test(text)) {
return CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY;
}
if (/电影级光影|光影校正|cinematic lighting/i.test(text)) {
return CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY;
}
if (/3\s*秒后|三秒后|\+\s*3|plus[-_\s]?3/i.test(text)) {
return FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY;
}
if (/5\s*秒前|五秒前|-\s*5|minus[-_\s]?5/i.test(text)) {
return FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY;
}
if (/多机位|九宫格|9\s*宫格|3\s*[x×]\s*3/.test(text)) {
return MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
}
return null;
}
function resolveOfficialImagePresetFunctionKey({
fallbackKey,
option,
title,
}: {
fallbackKey?: string;
option?: unknown;
title?: unknown;
}) {
const optionKey = getOfficialImagePresetFunctionKey(option);
if (optionKey) return optionKey;
const titleKey = getOfficialImagePresetFunctionKey(title);
if (titleKey) return titleKey;
const normalizedFallbackKey = String(fallbackKey || "").trim();
return normalizedFallbackKey || null;
}
function isCharacterThreeViewOption(value?: unknown) {
return getOfficialImagePresetFunctionKey(value) === CHARACTER_THREE_VIEW_FUNCTION_KEY;
}
function getImageGenerationBillingFeature(functionKey: string) {
if (
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ||
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY
) {
return "multi-view" as const;
}
if (functionKey === "image-repaint") {
return "repaint" as const;
}
return undefined;
}
function isWideImageGenerationFunction(functionKey: string) {
return (
functionKey === STORY_BEAT_GRID_FUNCTION_KEY ||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ||
functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY ||
functionKey === CINEMATIC_LIGHTING_CORRECTION_FUNCTION_KEY ||
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ||
functionKey === FRAME_PREDICTION_PLUS_3S_FUNCTION_KEY ||
functionKey === FRAME_PREDICTION_MINUS_5S_FUNCTION_KEY
);
}
function getPresetGridLayout(functionKey: string) {
if (functionKey === STORY_BEAT_GRID_FUNCTION_KEY) return { columns: 2, rows: 2 };
if (functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY) return { columns: 3, rows: 3 };
if (functionKey === CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY) return { columns: 5, rows: 5 };
return null;
}
function isPresetGridFunction(functionKey: string) {
return Boolean(getPresetGridLayout(functionKey));
}
function getNodeParams(node: CloneNode | null | undefined) {
const params = node?.draftValues?.params;
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
function getNodeFunctionKey(node: CloneNode | null | undefined) {
const params = getNodeParams(node);
return String(params.functionKey || node?.draftValues?.functionKey || "").trim();
}
function isCharacterThreeViewNode(node: CloneNode | null | undefined) {
if (!node || node.inputType !== "image") return false;
return (
getNodeFunctionKey(node) === CHARACTER_THREE_VIEW_FUNCTION_KEY ||
isCharacterThreeViewOption(node?.title)
);
}
function getConfiguredFunction(
config: VideoCanvasFunctionConfig,
key: string,
@ -1293,19 +1407,23 @@ function composeFunctionPrompt(
return `${base}\n\n用户补充:${extra}`;
}
function composeConfiguredImagePresetPrompt(
definition: VideoCanvasFunctionDefinition,
userPrompt?: string,
) {
const base = definition.prompt.trim() || getDefaultFunctionPrompt(definition.key);
const extra = String(userPrompt || "").trim();
const extraFunctionKey = getOfficialImagePresetFunctionKey(extra);
if (!extra || extra === base || extra === definition.label || extraFunctionKey === definition.key) return base;
if (!base) return extra;
return `${base}\n\n用户补充:${extra}`;
}
function composeMultiViewNineGridPrompt(
definition: VideoCanvasFunctionDefinition,
option?: string,
) {
const configuredPrompt = definition.prompt.trim() || getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY);
const selectedOption = String(option || "").trim();
const parts = [configuredPrompt];
if (selectedOption) {
parts.push(`用户选择:${selectedOption}`);
}
return parts.join("\n\n");
return composeConfiguredImagePresetPrompt(definition, option);
}
function getFirstGeneratedImageUrl(
@ -1616,15 +1734,50 @@ function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbac
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
const action = getDraftString(node, "action");
const isCharacterThreeViewResultNode = isCharacterThreeViewNode(node);
const isNineGridResultNode =
action === "IMAGE_GENERATE_NINE_GRID" ||
Boolean(node.draftValues?.nineGridComposite) ||
(node.inputType === "image" && /多机位九宫格/.test(node.title || ""));
!isCharacterThreeViewResultNode &&
(action === "IMAGE_GENERATE_NINE_GRID" ||
Boolean(node.draftValues?.nineGridComposite) ||
(node.inputType === "image" && /多机位九宫格/.test(node.title || "")));
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
return normalizeTextResourceNode(node, fallbackIndex);
}
if (isCharacterThreeViewResultNode) {
return {
...node,
width: OFFICIAL_NINE_GRID_WIDTH,
height: OFFICIAL_NINE_GRID_HEIGHT,
draftValues: {
...(node.draftValues || {}),
action: "IMAGE_GENERATE",
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
gridColumns: undefined,
gridRows: undefined,
nineGridCells: undefined,
nineGridComposite: false,
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536",
params: {
...getNodeParams(node),
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
mode: "character-three-view",
prompt: getDraftString(node, "prompt") || getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY),
},
},
};
}
if (isNineGridResultNode) {
const params = getNodeParams(node);
const gridFunctionKey =
resolveOfficialImagePresetFunctionKey({
fallbackKey: getNodeFunctionKey(node),
option: params.option || node.draftValues?.toolbarOption,
title: node.title,
}) ||
MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
const gridLayout = getPresetGridLayout(gridFunctionKey);
return {
...node,
width: OFFICIAL_NINE_GRID_WIDTH,
@ -1632,9 +1785,18 @@ function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNod
draftValues: {
...(node.draftValues || {}),
action: "IMAGE_GENERATE_NINE_GRID",
gridPrompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(MULTI_VIEW_NINE_GRID_FUNCTION_KEY),
functionKey: gridFunctionKey,
gridColumns: getDraftNumber(node, "gridColumns", gridLayout?.columns || 3),
gridPrompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey),
gridRows: getDraftNumber(node, "gridRows", gridLayout?.rows || 3),
nineGridComposite: true,
outputSizeLabel: getDraftString(node, "outputSizeLabel") || "2752 × 1536",
params: {
...params,
functionKey: gridFunctionKey,
mode: "grid",
prompt: getDraftString(node, "gridPrompt") || getDefaultFunctionPrompt(gridFunctionKey),
},
},
};
}
@ -1764,8 +1926,9 @@ function createStarterWorkflow(
}
if (starterTitle === "角色三视图") {
const preset = "character-three-view";
const preset = CHARACTER_THREE_VIEW_FUNCTION_KEY;
const distance = 100;
const prompt = getDefaultFunctionPrompt(CHARACTER_THREE_VIEW_FUNCTION_KEY);
const left = createPresetMediaNode({
action: "IMAGE_RESOURCE",
height: 520,
@ -1777,18 +1940,32 @@ function createStarterWorkflow(
x: Math.round(center.x - 874 - distance / 2),
y: Math.round(center.y - 320),
});
const right = createPresetMediaNode({
const rightBase = createPresetMediaNode({
action: "IMAGE_GENERATE",
generated: true,
height: 520,
mediaType: "image",
mediaUrl: CHARACTER_THREE_VIEW_URL,
preset,
title: "角色三视图",
width: 660,
prompt,
title: "角色三视图生成",
width: OFFICIAL_NINE_GRID_WIDTH,
x: Math.round(center.x + distance / 2),
y: Math.round(center.y - 320),
});
const right: CloneNode = {
...rightBase,
draftValues: {
...(rightBase.draftValues || {}),
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
params: {
...getNodeParams(rightBase),
functionKey: CHARACTER_THREE_VIEW_FUNCTION_KEY,
mode: "character-three-view",
prompt,
},
},
};
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
}
@ -2681,67 +2858,215 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
};
const title = labels[action];
if (action === "grid") {
const { columns, rows } = normalizeGridOption(option || "9宫格");
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, MULTI_VIEW_NINE_GRID_FUNCTION_KEY);
const prompt = composeMultiViewNineGridPrompt(gridDefinition, option);
const gridNode = createNineGridCompositeNode({
anchor,
columns,
gridPrompt: prompt,
option,
rows,
sourceUrl,
const functionKey =
getOfficialImagePresetFunctionKey(option) || MULTI_VIEW_NINE_GRID_FUNCTION_KEY;
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
const prompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, option)
: composeConfiguredImagePresetPrompt(definition, option);
const shouldGenerate = videoCanvasFunctionConfig.enabled && definition.enabled;
const resultTitle = option?.trim() || definition.label || title;
const gridLayout = getPresetGridLayout(functionKey);
if (gridLayout) {
const gridNode = createNineGridCompositeNode({
anchor,
columns: gridLayout.columns,
functionKey,
gridPrompt: prompt,
option: resultTitle,
rows: gridLayout.rows,
sourceUrl,
x: rightX,
y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
});
const pendingGridNode: CloneNode = {
...gridNode,
draftValues: {
...(gridNode.draftValues || {}),
functionKey,
generated: !shouldGenerate,
generationProgress: shouldGenerate ? 2 : 100,
generationStatus: shouldGenerate ? "pending" : "complete",
gridPrompt: prompt,
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"),
params: {
...getNodeParams(gridNode),
functionKey,
mode: "grid",
option: resultTitle,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
},
pendingLabel: "生成中",
prompt,
sourceNodeId: anchor.id,
sourceUrl,
toolbarOption: resultTitle,
url: shouldGenerate ? [] : gridNode.draftValues?.url,
},
};
setNodes((current) => [...current, pendingGridNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]);
setSelectedNodeId(pendingGridNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
if (shouldGenerate) {
const response = await imageGenerationService.generateImage({
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt,
promptExtend: false,
referenceImage: sourceUrl,
});
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
if (generatedUrl) {
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
functionKey,
generated: true,
generationProgress: 100,
generationStatus: "complete",
gridPrompt: prompt,
mediaUrl: generatedUrl,
params: {
...getNodeParams(node),
functionKey,
mode: "grid",
option: resultTitle,
prompt,
},
prompt,
sourceNodeId: anchor.id,
sourceUrl,
toolbarOption: resultTitle,
url: [generatedUrl],
},
}
: node
)
);
} else {
const errorMessage = response.error || `${definition.label}生成未返回结果`;
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
...(node.draftValues || {}),
generationStatus: "failed",
generationError: errorMessage,
},
}
: node
)
);
toast.error(`${errorMessage},已保留占位节点`);
}
}
toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`);
return;
}
const resultNode = createMediaResourceNode({
action: "IMAGE_GENERATE",
height: OFFICIAL_NINE_GRID_HEIGHT,
mediaType: "image",
mediaUrl: "",
prompt,
title: resultTitle,
width: OFFICIAL_NINE_GRID_WIDTH,
x: rightX,
y: centerY(OFFICIAL_NINE_GRID_HEIGHT),
});
const shouldGenerate = videoCanvasFunctionConfig.enabled && gridDefinition?.enabled;
const pendingGridNode: CloneNode = {
...gridNode,
const pendingNode: CloneNode = {
...resultNode,
subtitle: shouldGenerate ? "生成中" : "生成结果",
draftValues: {
...(gridNode.draftValues || {}),
generated: !shouldGenerate,
generationProgress: shouldGenerate ? 2 : 100,
generationStatus: shouldGenerate ? "pending" : "complete",
mediaUrl: shouldGenerate ? "" : getDraftString(gridNode, "mediaUrl"),
...(resultNode.draftValues || {}),
action: "IMAGE_GENERATE",
functionKey,
generated: false,
generationProgress: shouldGenerate ? 2 : 0,
generationStatus: shouldGenerate ? "pending" : "idle",
mediaType: "image",
mediaUrl: "",
nodeType: "image",
outputSizeLabel: "2752 × 1536",
params: {
functionKey,
mode: functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ? "character-three-view" : "image",
option: resultTitle,
prompt,
sourceNodeId: anchor.id,
sourceUrl,
},
pendingLabel: "生成中",
prompt,
sourceNodeId: anchor.id,
sourceUrl,
url: shouldGenerate ? [] : gridNode.draftValues?.url,
toolbarOption: resultTitle,
url: [],
},
};
setNodes((current) => [...current, pendingGridNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingGridNode.id }]);
setSelectedNodeId(pendingGridNode.id);
setNodes((current) => [...current, pendingNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]);
setSelectedNodeId(pendingNode.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) {
if (shouldGenerate) {
const response = await imageGenerationService.generateImage({
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, gridDefinition),
model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt,
promptExtend: false,
referenceImage: sourceUrl,
});
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
if (generatedUrl) {
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
subtitle: "生成结果",
draftValues: {
...(node.draftValues || {}),
functionKey,
generated: true,
generationProgress: 100,
generationStatus: "complete",
mediaUrl: generatedUrl,
params: {
...getNodeParams(node),
functionKey,
option: resultTitle,
prompt,
},
prompt,
sourceNodeId: anchor.id,
sourceUrl,
toolbarOption: resultTitle,
url: [generatedUrl],
},
}
@ -2749,10 +3074,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
)
);
} else {
const errorMessage = response.error || "九宫格生成未返回结果";
const errorMessage = response.error || `${definition.label}生成未返回结果`;
setNodes((current) =>
current.map((node) =>
node.id === pendingGridNode.id && getDraftString(node, "generationStatus") === "pending"
node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending"
? {
...node,
draftValues: {
@ -2764,10 +3089,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
: node
)
);
toast.error(`${errorMessage},已保留可切分占位节点`);
toast.error(`${errorMessage},已保留占位节点`);
}
}
toast.success(shouldGenerate ? "已创建生成中占位节点" : "已按预设提示词生成多机位九宫格结果图");
toast.success(shouldGenerate ? `已创建${definition.label}生成中占位节点` : `已创建${definition.label}节点`);
return;
}
@ -2844,9 +3169,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
} else if (definition.route === "canvas-generate-image") {
const response = await imageGenerationService.generateImage({
aspectRatio: "16:9",
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt,
promptExtend: false,
referenceImage: sourceUrl,
});
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -2955,30 +3282,40 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const starterMode = getPresetMode(anchor);
const slashScene = payload.slashScene || "";
const promptInput = payload.prompt.trim();
const slashFunctionKey = getOfficialImagePresetFunctionKey(slashScene);
const functionKey =
payload.slashScene && /九宫格|宫格|多机位/.test(payload.slashScene)
? MULTI_VIEW_NINE_GRID_FUNCTION_KEY
: starterMode === "character-three-view"
? "character-three-view"
: "image-repaint";
slashFunctionKey ||
(starterMode === CHARACTER_THREE_VIEW_FUNCTION_KEY
? CHARACTER_THREE_VIEW_FUNCTION_KEY
: "image-repaint");
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
const prompt = composeFunctionPrompt(
definition,
payload.prompt.trim() || payload.slashScene || "生成角色三视图",
);
const isWideGeneration = isWideImageGenerationFunction(functionKey);
const resultWidth = isWideGeneration ? OFFICIAL_NINE_GRID_WIDTH : 420;
const resultHeight = isWideGeneration ? OFFICIAL_NINE_GRID_HEIGHT : 280;
const resultTitle = slashScene || definition.label || "图片生成";
const gridLayout = getPresetGridLayout(functionKey);
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
const finalPrompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, payload.prompt.trim() || payload.slashScene || "多机位九宫格")
: prompt;
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
? composeMultiViewNineGridPrompt(definition, promptInput || slashScene || "多机位九宫格")
: isWideGeneration
? composeConfiguredImagePresetPrompt(definition, promptInput || slashScene || definition.label)
: composeFunctionPrompt(
definition,
promptInput || slashScene || "生成角色三视图",
);
let generatedUrl = "";
if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
const response = await imageGenerationService.generateImage({
aspectRatio: payload.settings.ratio,
imageCount: count,
aspectRatio: isWideGeneration ? "16:9" : payload.settings.ratio,
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: isWideGeneration ? 1 : count,
model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model),
prompt: finalPrompt,
promptExtend: !isWideGeneration,
referenceImage: sourceUrl,
});
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -2990,38 +3327,74 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const generatedNode = generatedUrl
? createMediaResourceNode({
action: "IMAGE_RESOURCE",
height: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_HEIGHT : 280,
height: resultHeight,
mediaType: "image",
mediaUrl: generatedUrl,
prompt: finalPrompt,
title: payload.slashScene || definition.label || "图片生成",
width: functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_WIDTH : 420,
title: resultTitle,
width: resultWidth,
x: rightX,
y: Math.round(anchor.y + anchor.height / 2 - (functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? OFFICIAL_NINE_GRID_HEIGHT : 280) / 2),
y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2),
})
: null;
const created = generatedNode || createTargetModuleNode("image", rightX, 0, nodes.length + 1);
const placeholderNode = isWideGeneration
? createMediaResourceNode({
action:
isPresetGridFunction(functionKey)
? "IMAGE_GENERATE_NINE_GRID"
: "IMAGE_GENERATE",
height: resultHeight,
mediaType: "image",
mediaUrl: "",
prompt: finalPrompt,
title: resultTitle,
width: resultWidth,
x: rightX,
y: Math.round(anchor.y + anchor.height / 2 - resultHeight / 2),
})
: createTargetModuleNode("image", rightX, 0, nodes.length + 1);
const created = generatedNode || placeholderNode;
const resultNode: CloneNode = {
...created,
title: payload.slashScene || definition.label || "图片生成",
title: resultTitle,
y:
generatedNode?.y ??
Math.round(anchor.y + anchor.height / 2 - created.height / 2),
(isWideGeneration
? placeholderNode.y
: Math.round(anchor.y + anchor.height / 2 - created.height / 2)),
draftValues: {
...(created.draftValues || {}),
functionKey,
generated: Boolean(generatedUrl) || created.draftValues?.generated,
generationStatus: generatedUrl ? "complete" : created.draftValues?.generationStatus,
gridColumns: gridLayout?.columns || created.draftValues?.gridColumns,
gridPrompt: gridLayout ? finalPrompt : created.draftValues?.gridPrompt,
gridRows: gridLayout?.rows || created.draftValues?.gridRows,
nineGridCells: gridLayout
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index))
: created.draftValues?.nineGridCells,
nineGridComposite: gridLayout ? true : created.draftValues?.nineGridComposite,
outputSizeLabel: isWideGeneration ? "2752 × 1536" : created.draftValues?.outputSizeLabel,
params: {
functionKey,
mode:
functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY
? "character-three-view"
: isPresetGridFunction(functionKey)
? "grid"
: "image",
prompt: finalPrompt,
references: payload.references,
settings: payload.settings,
slashScene: payload.slashScene,
slashScene,
sourceNodeId: anchor.id,
sourceUrl,
},
prompt: finalPrompt,
sourceNodeId: anchor.id,
sourceUrl,
targetPreset: starterMode || undefined,
toolbarOption: payload.slashScene || "",
toolbarOption: slashScene,
},
};
const referenceEdges = payload.references
@ -3233,8 +3606,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
try {
if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) {
const savedFunctionKey = String(params.functionKey || node.draftValues?.functionKey || "").trim();
const explicitFunctionKey =
resolveOfficialImagePresetFunctionKey({
fallbackKey: savedFunctionKey,
option: params.option || node.draftValues?.toolbarOption,
title: node.title,
}) || savedFunctionKey;
const functionKey =
action === "IMAGE_GENERATE_NINE_GRID"
explicitFunctionKey &&
(videoCanvasFunctionConfig.functions[explicitFunctionKey] ||
defaultVideoCanvasFunctionConfig.functions[explicitFunctionKey])
? explicitFunctionKey
: action === "IMAGE_GENERATE_NINE_GRID"
? MULTI_VIEW_NINE_GRID_FUNCTION_KEY
: node.draftValues?.generatorType === "ENHANCE"
? "hd-upscale"
@ -3244,7 +3628,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt =
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
? composeMultiViewNineGridPrompt(definition, String(params.option || node.title))
: isWideImageGenerationFunction(functionKey)
? composeConfiguredImagePresetPrompt(definition, rawPrompt || String(params.option || node.title))
: composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title));
const gridLayout = getPresetGridLayout(functionKey);
let generatedUrl = "";
if (definition.route === "enhance" && sourceUrl) {
@ -3253,10 +3640,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败");
} else {
const response = await imageGenerationService.generateImage({
aspectRatio: action === "IMAGE_GENERATE_NINE_GRID" ? "16:9" : undefined,
aspectRatio: isWideImageGenerationFunction(functionKey) ? "16:9" : undefined,
billingFeature: getImageGenerationBillingFeature(functionKey),
imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
prompt,
promptExtend: !isWideImageGenerationFunction(functionKey),
referenceImage: sourceUrl || undefined,
});
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
@ -3273,10 +3662,28 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
subtitle: "生成结果",
draftValues: {
...(item.draftValues || {}),
functionKey,
generated: true,
generationProgress: 100,
generationStatus: "complete",
gridColumns: gridLayout?.columns || item.draftValues?.gridColumns,
gridPrompt: gridLayout ? prompt : item.draftValues?.gridPrompt,
gridRows: gridLayout?.rows || item.draftValues?.gridRows,
nineGridCells: gridLayout
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index))
: item.draftValues?.nineGridCells,
nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite,
mediaType: "image",
mediaUrl: generatedUrl,
nodeType: "image",
outputSizeLabel: isWideImageGenerationFunction(functionKey)
? "2752 × 1536"
: item.draftValues?.outputSizeLabel,
params: {
...getNodeParams(item),
functionKey,
prompt,
},
prompt,
sourceUrl,
url: [generatedUrl],
@ -4523,8 +4930,8 @@ function PresetWorkflowControls({
onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void;
selectedNode: CloneNode;
}) {
if (mode === "character-three-view") {
const definition = getConfiguredFunction(functionConfig, "character-three-view");
if (mode === CHARACTER_THREE_VIEW_FUNCTION_KEY) {
const definition = getConfiguredFunction(functionConfig, CHARACTER_THREE_VIEW_FUNCTION_KEY);
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
@ -7382,7 +7789,7 @@ function PresetMediaCanvasNode({
) : mediaUrl ? (
<img
alt={node.title}
className={cn("pointer-events-none h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
className={cn("pointer-events-none h-full w-full", isCharacterThreeViewNode(node) ? "object-contain" : "object-cover")}
draggable={false}
onDragStart={preventNativeImageDrag}
src={mediaUrl}

View File

@ -1,6 +1,7 @@
"use client";
import {
ArrowLeft,
Circle as CircleIcon,
Eraser,
FolderOpen,
@ -850,8 +851,6 @@ export function RealtimeCanvasDialog({
defaultRealtimeConfig.transport.throttleInterval
);
const [refreshToken, setRefreshToken] = useState(0);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
const [modeIconConfig, setModeIconConfig] =
useState<RealtimeCanvasModeIconConfig>({
@ -908,17 +907,6 @@ export function RealtimeCanvasDialog({
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!data || cancelled) return;
if (typeof data.siteName === "string" && data.siteName.trim()) {
setSiteName(data.siteName.trim());
}
if (typeof data.siteLogo === "string" && data.siteLogo.trim()) {
setSiteLogo(data.siteLogo.trim());
} else if (
typeof data.siteLogoDark === "string" &&
data.siteLogoDark.trim()
) {
setSiteLogo(data.siteLogoDark.trim());
}
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
const iconConfig =
data.realtimeCanvasModeIconConfig &&
@ -3010,21 +2998,8 @@ export function RealtimeCanvasDialog({
}, [previewUrl]);
const handleCloseRealtime = useCallback(() => {
if (standalone && typeof window !== "undefined") {
if (window.history.length > 1) {
window.history.back();
return;
}
window.location.assign("/canvas");
return;
}
onOpenChange(false);
}, [onOpenChange, standalone]);
const handleGoHome = useCallback(() => {
if (typeof window === "undefined") return;
window.location.assign("/");
}, []);
}, [onOpenChange]);
const closeBrushSettingPopovers = useCallback(() => {
setBrushPopoverOpen(false);
@ -3157,27 +3132,15 @@ export function RealtimeCanvasDialog({
>
<button
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white"
onClick={handleGoHome}
title="返回首页"
onClick={handleCloseRealtime}
title={standalone ? "返回无限画布" : "关闭并返回画布"}
type="button"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)]">
{siteLogo ? (
<img
alt={siteName}
className="h-6 w-6 rounded-full object-contain"
src={
/^https?:\/\//i.test(siteLogo)
? buildImageProxyUrl(siteLogo)
: siteLogo
}
/>
) : (
<Sparkles className="h-3.5 w-3.5 text-[#111111]" />
)}
<ArrowLeft className="h-4 w-4 text-[#111111]" />
</span>
<span className="font-semibold text-[14px] tracking-[-0.02em]">
{siteName}
{standalone ? "返回无限画布" : "返回画布"}
</span>
</button>

View File

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

View File

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

View File

@ -30,6 +30,7 @@ export const defaultAgentWorkflowPageFeatureSettings: AgentWorkflowPageFeatureSe
presetStepEnabled: true,
materialStepEnabled: true,
psdTemplateEnabled: true,
batchProcessingEnabled: true,
};
export interface AgentWorkflowBrandCategoryOption {
@ -258,6 +259,10 @@ export function normalizeAgentWorkflowPageFeatureSettings(
record.psdTemplateEnabled ?? record.enablePsdTemplate,
defaultAgentWorkflowPageFeatureSettings.psdTemplateEnabled
),
batchProcessingEnabled: normalizeBoolean(
record.batchProcessingEnabled ?? record.enableBatchProcessing,
defaultAgentWorkflowPageFeatureSettings.batchProcessingEnabled
),
};
}
@ -1072,6 +1077,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
presetStepEnabled: raw.presetStepEnabled ?? raw.modeStepEnabled,
materialStepEnabled: raw.materialStepEnabled ?? raw.skuStepEnabled,
psdTemplateEnabled: raw.psdTemplateEnabled,
batchProcessingEnabled: raw.batchProcessingEnabled,
}
),
steps: sanitizedSteps,

View File

@ -424,14 +424,14 @@ function getModeConstraintOptions(
const constraint = mode ? modeConstraints?.[mode] : undefined;
const ratioOptions = constraint?.ratioOptions?.length ? constraint.ratioOptions : [];
const durationOptions = constraint?.durationOptions?.length ? constraint.durationOptions : [];
const resolutionOptions = constraint?.resolutionOptions?.length
? constraint.resolutionOptions
: [];
const hasResolutionOptions = Array.isArray(constraint?.resolutionOptions);
const resolutionOptions = hasResolutionOptions ? constraint?.resolutionOptions || [] : [];
return {
ratioOptions,
durationOptions,
resolutionOptions,
hasResolutionOptions,
defaults: {
...defaults,
...(constraint?.defaults || {}),
@ -439,6 +439,31 @@ function getModeConstraintOptions(
};
}
function restrictModeConstraintResolutions(
modeConstraints: Partial<Record<VideoToolMode, VideoToolModeConstraint>> | undefined,
allowedResolutions: VideoToolResolution[]
) {
if (!modeConstraints || allowedResolutions.length === 0) {
return modeConstraints;
}
const allowedSet = new Set<VideoToolResolution>(allowedResolutions);
return MODE_ORDER.reduce<Partial<Record<VideoToolMode, VideoToolModeConstraint>>>(
(acc, mode) => {
const current = modeConstraints[mode];
if (!current) return acc;
acc[mode] = {
...current,
resolutionOptions: Array.isArray(current.resolutionOptions)
? current.resolutionOptions.filter((item) => allowedSet.has(item))
: undefined,
};
return acc;
},
{}
);
}
function applyConfiguredDurationOptions(
capability: VideoToolCapabilityContract,
configuredDurationOptions: number[]
@ -501,6 +526,8 @@ export function getVideoToolOptionsForMode(
: capability.durationOptions,
resolutionOptions: scoped.resolutionOptions.length
? scoped.resolutionOptions
: scoped.hasResolutionOptions
? []
: capability.resolutionOptions,
defaults: scoped.defaults,
};
@ -593,9 +620,15 @@ export function buildVideoToolCapability(params: {
return acc;
}, {})
: base.modeConstraints;
const restrictedModeConstraints = restrictModeConstraintResolutions(
nextModeConstraints,
parsed.supportedResolutions && parsed.supportedResolutions.length
? parsed.supportedResolutions
: []
);
const defaultMode = pickPreferredMode(modeOptions, base.defaults.mode);
const modeScopedDefaults = getModeConstraintOptions(
nextModeConstraints,
restrictedModeConstraints,
defaultMode,
base.defaults
);
@ -605,7 +638,7 @@ export function buildVideoToolCapability(params: {
const effectiveDurationOptions = modeScopedDefaults.durationOptions.length
? modeScopedDefaults.durationOptions
: durationOptions;
const effectiveResolutionOptions = modeScopedDefaults.resolutionOptions.length
const effectiveResolutionOptions = modeScopedDefaults.hasResolutionOptions
? modeScopedDefaults.resolutionOptions
: resolutionOptions;
@ -617,7 +650,7 @@ export function buildVideoToolCapability(params: {
ratioOptions,
resolutionOptions,
durationOptions,
modeConstraints: nextModeConstraints,
modeConstraints: restrictedModeConstraints,
ui: {
...base.ui,
showReturnLastFrame:

View File

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

View File

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

View File

@ -30,12 +30,40 @@ export type PointsSystemConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
creditCostPer3DModel: number;
creditCostPer3DModelPbr: number;
creditCostPer3DModelSmartLowPoly: number;
creditCostPer3DModelQuad: number;
creditCostPer3DModelRefine: number;
creditCostPer3DModelTexture: number;
creditCostPer3DModelAutoRig: number;
creditCostPer3DModelHighpolyToLowpoly: number;
creditCostPer3DWorld: number;
creditCostPer3DWorldMarble11: number;
creditCostPer3DWorldMarble11Plus: number;
creditCostPerImageMultiView: number;
creditCostPerImageRemoveBackground: number;
creditCostPerImageBoxCutout: number;
creditCostPerImageWatermarkRemove: number;
creditCostPerImageExtend: number;
creditCostPerImageRepaint: number;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
export type ThreeDTaskCreditParams = {
taskType?: string | null;
pbr?: boolean | null;
quad?: boolean | null;
smartLowPoly?: boolean | null;
};
export type ImageToolCreditFeature =
| "multi-view"
| "remove-background"
| "box-cutout"
| "watermark-remove"
| "extend"
| "repaint";
export type PointsPackage = {
id: string;
name: string;
@ -49,9 +77,22 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false,
blockOnInsufficient: true,
creditCostPer3DModel: 0,
creditCostPer3DModelPbr: 0,
creditCostPer3DModelSmartLowPoly: 0,
creditCostPer3DModelQuad: 0,
creditCostPer3DModelRefine: 0,
creditCostPer3DModelTexture: 0,
creditCostPer3DModelAutoRig: 0,
creditCostPer3DModelHighpolyToLowpoly: 0,
creditCostPer3DWorld: 0,
creditCostPer3DWorldMarble11: 0,
creditCostPer3DWorldMarble11Plus: 0,
creditCostPerImageMultiView: 0,
creditCostPerImageRemoveBackground: 0,
creditCostPerImageBoxCutout: 0,
creditCostPerImageWatermarkRemove: 0,
creditCostPerImageExtend: 0,
creditCostPerImageRepaint: 0,
enhanceProPricing: {
enabled: false,
chargeMode: "scale_factor",
@ -390,6 +431,67 @@ export function get3DWorldCreditCost(
return normalizeCost(pointsConfig.creditCostPer3DWorld);
}
export function get3DTaskCreditCost(
pointsConfig: PointsSystemConfig,
params: ThreeDTaskCreditParams
) {
const taskType = String(params.taskType || "").trim();
if (
taskType === "text_to_model" ||
taskType === "image_to_model" ||
taskType === "multiview_to_model"
) {
let cost = normalizeCost(pointsConfig.creditCostPer3DModel);
if (params.pbr === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelPbr);
}
if (params.smartLowPoly === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelSmartLowPoly);
}
if (params.quad === true) {
cost += normalizeCost(pointsConfig.creditCostPer3DModelQuad);
}
return cost;
}
if (taskType === "refine_model") {
return normalizeCost(pointsConfig.creditCostPer3DModelRefine);
}
if (taskType === "texture_model") {
return normalizeCost(pointsConfig.creditCostPer3DModelTexture);
}
if (taskType === "animate_rig") {
return normalizeCost(pointsConfig.creditCostPer3DModelAutoRig);
}
if (taskType === "highpoly_to_lowpoly") {
return normalizeCost(pointsConfig.creditCostPer3DModelHighpolyToLowpoly);
}
return 0;
}
export function getImageToolCreditCost(
pointsConfig: PointsSystemConfig,
feature: ImageToolCreditFeature | null | undefined
) {
switch (feature) {
case "multi-view":
return normalizeCost(pointsConfig.creditCostPerImageMultiView);
case "remove-background":
return normalizeCost(pointsConfig.creditCostPerImageRemoveBackground);
case "box-cutout":
return normalizeCost(pointsConfig.creditCostPerImageBoxCutout);
case "watermark-remove":
return normalizeCost(pointsConfig.creditCostPerImageWatermarkRemove);
case "extend":
return normalizeCost(pointsConfig.creditCostPerImageExtend);
case "repaint":
return normalizeCost(pointsConfig.creditCostPerImageRepaint);
default:
return 0;
}
}
export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") {
return defaultPointsSystemConfig;
@ -410,6 +512,17 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DModel: normalizeCost(
raw.creditCostPer3DModel ?? legacy3DCost
),
creditCostPer3DModelPbr: normalizeCost(raw.creditCostPer3DModelPbr),
creditCostPer3DModelSmartLowPoly: normalizeCost(
raw.creditCostPer3DModelSmartLowPoly
),
creditCostPer3DModelQuad: normalizeCost(raw.creditCostPer3DModelQuad),
creditCostPer3DModelRefine: normalizeCost(raw.creditCostPer3DModelRefine),
creditCostPer3DModelTexture: normalizeCost(raw.creditCostPer3DModelTexture),
creditCostPer3DModelAutoRig: normalizeCost(raw.creditCostPer3DModelAutoRig),
creditCostPer3DModelHighpolyToLowpoly: normalizeCost(
raw.creditCostPer3DModelHighpolyToLowpoly
),
creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
),
@ -419,6 +532,16 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DWorldMarble11Plus: normalizeCost(
raw.creditCostPer3DWorldMarble11Plus ?? generic3DWorldCost
),
creditCostPerImageMultiView: normalizeCost(raw.creditCostPerImageMultiView),
creditCostPerImageRemoveBackground: normalizeCost(
raw.creditCostPerImageRemoveBackground
),
creditCostPerImageBoxCutout: normalizeCost(raw.creditCostPerImageBoxCutout),
creditCostPerImageWatermarkRemove: normalizeCost(
raw.creditCostPerImageWatermarkRemove
),
creditCostPerImageExtend: normalizeCost(raw.creditCostPerImageExtend),
creditCostPerImageRepaint: normalizeCost(raw.creditCostPerImageRepaint),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing,
legacyEnhanceProCost

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[] = [
{
key: "character-three-view",
label: "角色三视图",
key: "story-beat-grid-4",
label: "剧情推演四宫格",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。画面中只能有 4 个画面区域,只允许一条竖向分割线和一条横向分割线;严禁出现 3 行 3 列、9 个画面区域或九宫格布局。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
description: "官方剧情推演四宫格:基于参考图生成 2x2 连续剧情分镜合成图。",
},
{
key: "character-three-view",
label: "角色三视图生成",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
description: "官方角色三视图预设,使用参考图生图/编辑模型。",
},
{
@ -65,6 +76,50 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
},
{
key: "continuous-storyboard-grid-25",
label: "25宫格连贯分镜",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,5x5 等分排版,使用清晰分割线,不能出现文字、水印、编号或多余边框说明。",
description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。",
},
{
key: "cinematic-lighting-correction",
label: "电影级光影校正",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片为唯一参考,在保持角色身份、发型、服装、姿态和构图一致的前提下,进行电影级光影校正。增强自然逆光、轮廓光、皮肤通透感、背景层次和柔和景深,保留原始角色与场景,不改变人物数量,不生成宫格或分镜。输出单张 16:9 图片,不能出现文字、水印、编号或额外边框。",
description: "官方电影级光影校正:对参考图做单张电影感光影重塑。",
},
{
key: "frame-prediction-plus-3s",
label: "画面推演 - 3秒后",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为当前画面,推演同一镜头时间线 3 秒后的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作、表情、衣袖/头发摆动、视线或镜头位置发生合理连续变化。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 3 秒后的单帧结果。",
},
{
key: "frame-prediction-minus-5s",
label: "画面推演 - 5秒前",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为当前画面,反向推演同一镜头时间线 5 秒前的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作起势、姿态、表情、衣袖/头发位置或镜头位置回到合理的前一状态。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 5 秒前的单帧结果。",
},
{
key: "grid-split",
label: "宫格切分",
@ -242,6 +297,58 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const legacyFunctionPrompts: Record<string, string[]> = {
"story-beat-grid-4": [
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
],
"character-three-view": [
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
],
};
const legacyFunctionLabels: Record<string, string[]> = {
"character-three-view": ["角色三视图"],
};
function normalizeComparableText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeFunctionLabel(
key: string,
value: unknown,
fallbackLabel: string,
) {
const label = typeof value === "string" ? value.trim() : "";
if (!label) return fallbackLabel;
const normalizedLabel = normalizeComparableText(label);
const isLegacyLabel = (legacyFunctionLabels[key] || []).some(
(legacyLabel) => normalizeComparableText(legacyLabel) === normalizedLabel,
);
return isLegacyLabel ? fallbackLabel : label;
}
function normalizeFunctionPrompt(
key: string,
value: unknown,
fallbackPrompt: string,
) {
const prompt = typeof value === "string" ? value.trim() : "";
if (!prompt) return fallbackPrompt;
const normalizedPrompt = normalizeComparableText(prompt);
const isStoryPromptClearlyNineGrid =
key === "story-beat-grid-4" &&
/(?:生成一张\s*3\s*[x×]\s*3|生成一张[^。]*九宫格|九格分别|必须是\s*9\s*个)/.test(
normalizedPrompt,
);
if (isStoryPromptClearlyNineGrid) return fallbackPrompt;
const isLegacyPrompt = (legacyFunctionPrompts[key] || []).some(
(legacyPrompt) => normalizeComparableText(legacyPrompt) === normalizedPrompt,
);
return isLegacyPrompt ? fallbackPrompt : prompt;
}
function normalizeFunctionDefinition(
key: string,
value: unknown,
@ -251,7 +358,7 @@ function normalizeFunctionDefinition(
const normalized: VideoCanvasFunctionDefinition = {
...fallback,
key,
label: String(incoming.label || fallback.label),
label: normalizeFunctionLabel(key, incoming.label, fallback.label),
kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind,
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
@ -260,7 +367,7 @@ function normalizeFunctionDefinition(
typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost)
? Math.max(0, incoming.creditCost)
: fallback.creditCost,
prompt: String(incoming.prompt || fallback.prompt),
prompt: normalizeFunctionPrompt(key, incoming.prompt, fallback.prompt),
description: String(incoming.description || fallback.description),
requiresBackend:
typeof incoming.requiresBackend === "boolean"

View File

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

View File

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