fix: align canvas billing and 3d task routing
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-27 16:56:40 +08:00
parent 1870f87beb
commit 432d315bc6
8 changed files with 616 additions and 91 deletions

View File

@ -5,6 +5,7 @@ import {
useState,
useEffect,
useMemo,
useRef,
type ChangeEvent,
type DragEvent,
} from "react";
@ -1044,6 +1045,43 @@ const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = {
video: true,
};
function stableStringifyComparableValue(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value) ?? "null";
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringifyComparableValue(item)).join(",")}]`;
}
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyComparableValue(item)}`)
.join(",")}}`;
}
function areComparableValuesEqual(left: unknown, right: unknown) {
return stableStringifyComparableValue(left) === stableStringifyComparableValue(right);
}
function pickChangedTopLevelFields<T extends Record<string, unknown>>(
current: T,
baseline: Partial<T> | null | undefined
) {
if (!baseline) return { ...current };
const changed: Partial<T> = {};
for (const [key, value] of Object.entries(current) as Array<[keyof T, T[keyof T]]>) {
if (!areComparableValuesEqual(value, baseline[key])) {
changed[key] = value;
}
}
return changed;
}
const defaultContactConfig: ContactConfig = {
phone: "",
wechatQrUrl: "",
@ -1588,6 +1626,10 @@ export function AdminSettingsClient() {
htmlContent: "",
updatedAt: null,
});
const lastLoadedSiteSettingsRef = useRef<SiteSettings | null>(null);
const lastLoadedHomeAnimationConfigRef = useRef<HomeAnimationConfig | null>(
null
);
const [newOverrideUserId, setNewOverrideUserId] = useState("");
const [newOverrideEmail, setNewOverrideEmail] = useState("");
const [newOverridePlanId, setNewOverridePlanId] = useState("");
@ -1614,7 +1656,7 @@ export function AdminSettingsClient() {
(siteSettingsData as any).dulupayConfig
);
setSiteSettings({
const nextSiteSettings: SiteSettings = {
siteName: siteSettingsData.siteName || "AI Chatbot",
siteLogo: siteSettingsData.siteLogo || "",
siteLogoDark: siteSettingsData.siteLogoDark || "",
@ -1828,7 +1870,9 @@ export function AdminSettingsClient() {
: ["全部", "惊喜"],
membershipConfig: normalizedMembershipConfig,
membershipUserOverrides: normalizedMembershipOverrides,
});
};
setSiteSettings(nextSiteSettings);
lastLoadedSiteSettingsRef.current = nextSiteSettings;
}
}, [siteSettingsData]);
@ -1840,7 +1884,7 @@ export function AdminSettingsClient() {
? (homeAnimationSettingData.setting.value as Record<string, unknown>)
: {};
setHomeAnimationConfig({
const nextHomeAnimationConfig = {
enabled: raw.enabled === true,
fileName: String(raw.fileName || "").trim(),
htmlContent:
@ -1848,7 +1892,9 @@ export function AdminSettingsClient() {
? raw.htmlContent.replace(/^\uFEFF/, "").replace(/\u0000/g, "")
: "",
updatedAt: String(raw.updatedAt || "").trim() || null,
});
};
setHomeAnimationConfig(nextHomeAnimationConfig);
lastLoadedHomeAnimationConfigRef.current = nextHomeAnimationConfig;
}, [homeAnimationSettingData]);
useEffect(() => {
@ -3482,25 +3528,42 @@ export function AdminSettingsClient() {
const parsedPointsSystemConfig = normalizePointsSystemConfig(
siteSettings.pointsSystemConfig
);
const normalizedHomeAnimationConfig: HomeAnimationConfig = {
enabled: homeAnimationConfig.enabled,
fileName: homeAnimationConfig.fileName.trim(),
htmlContent: normalizedHomeAnimationHtml,
updatedAt: homeAnimationConfig.updatedAt,
};
const siteSettingsPayload: Partial<SiteSettings> = {
...siteSettings,
};
delete siteSettingsPayload.agentWorkflowUiConfig;
const payload = {
...siteSettingsPayload,
homeAnimationConfig: {
enabled: homeAnimationConfig.enabled,
fileName: homeAnimationConfig.fileName.trim(),
htmlContent: normalizedHomeAnimationHtml,
updatedAt: homeAnimationConfig.updatedAt,
},
membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides,
pointsSystemConfig: parsedPointsSystemConfig,
pointsPackages: parsedPointsPackages,
};
delete siteSettingsPayload.agentWorkflowUiConfig;
const changedSiteSettingsPayload = pickChangedTopLevelFields(
siteSettingsPayload as Record<string, unknown>,
lastLoadedSiteSettingsRef.current as Partial<Record<string, unknown>> | null
) as Partial<SiteSettings>;
const shouldSaveHomeAnimation = !areComparableValuesEqual(
normalizedHomeAnimationConfig,
lastLoadedHomeAnimationConfigRef.current
);
const payload = {
...changedSiteSettingsPayload,
...(shouldSaveHomeAnimation
? { homeAnimationConfig: normalizedHomeAnimationConfig }
: {}),
};
if (Object.keys(payload).length === 0) {
toast.info("没有需要保存的改动");
return;
}
const response = await fetch("/api/site-settings", {
method: "PUT",
@ -3517,14 +3580,23 @@ export function AdminSettingsClient() {
...prev,
...nextSiteSettings,
}));
setHomeAnimationConfig((prev) => ({
...prev,
htmlContent: normalizedHomeAnimationHtml,
updatedAt: new Date().toISOString(),
}));
lastLoadedSiteSettingsRef.current = {
...(lastLoadedSiteSettingsRef.current || siteSettings),
...siteSettingsPayload,
};
if (shouldSaveHomeAnimation) {
const committedHomeAnimationConfig: HomeAnimationConfig = {
...normalizedHomeAnimationConfig,
updatedAt: new Date().toISOString(),
};
setHomeAnimationConfig(committedHomeAnimationConfig);
lastLoadedHomeAnimationConfigRef.current = committedHomeAnimationConfig;
}
toast.success("网站设置保存成功");
mutateSiteSettings();
mutateHomeAnimationSetting();
if (shouldSaveHomeAnimation) {
mutateHomeAnimationSetting();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存网站设置失败");
}

View File

@ -11,7 +11,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@ -28,6 +27,7 @@ import {
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
@ -260,7 +260,7 @@ export function VideoCanvasSettingsClient() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词、默认模型和扣点。前端优先按复刻目标触发,后端缺口可以先留空或后续替换。
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p>
</div>
<div className="flex gap-2">
@ -327,6 +327,9 @@ export function VideoCanvasSettingsClient() {
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频画布运行时会通过 /api/video-canvas/config 读取这里保存的提示词。比如“多机位九宫格”的分镜规则、黑色分割线要求和镜头说明,都由管理员在这里维护,前端不再写死覆盖。
</p>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
计费说明只展示来源,不在这里改价;需要调整积分请回到全站积分设置、模型设置或对应功能设置统一修改。
</p>
</div>
<div className="grid gap-4">
@ -355,7 +358,7 @@ export function VideoCanvasSettingsClient() {
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
<div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
@ -366,17 +369,13 @@ export function VideoCanvasSettingsClient() {
value={item.model || ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`credit-${item.key}`}>扣点</Label>
<Input
id={`credit-${item.key}`}
min={0}
onChange={(event) =>
updateFunction(item.key, { creditCost: Number(event.target.value || 0) })
}
type="number"
value={item.creditCost ?? 0}
/>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground">
积分来源:{getVideoCanvasFunctionBillingCopy(item).shortLabel}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{getVideoCanvasFunctionBillingCopy(item).description}
</div>
</div>
</div>
</CardHeader>

View File

@ -6,7 +6,10 @@ import {
pickTaskPreviewImageUrl,
tripoRequest,
} from "@/lib/tripo/client";
import { getTripoProviderConfig } from "@/lib/tripo/server";
import {
getTripoProviderConfig,
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import type { TripoTaskData } from "@/lib/tripo/types";
import { uploadFile } from "@/lib/storage/service";
@ -227,7 +230,10 @@ export async function GET(request: Request, { params }: RouteParams) {
}
const { searchParams } = new URL(request.url);
const config = await getTripoProviderConfig(searchParams.get("providerId"));
const preferredProviderId = searchParams.get("providerId");
const config = preferredProviderId
? await getTripoProviderConfig(preferredProviderId)
: await resolveTripoProviderConfigForTaskId(normalizedTaskId);
const result = await tripoRequest<TripoTaskData>(config, {
method: "GET",
path: `/task/${encodeURIComponent(normalizedTaskId)}`,

View File

@ -5,7 +5,10 @@ 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 {
getTripoProviderConfig,
resolveTripoProviderConfigForTaskId,
} from "@/lib/tripo/server";
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
export const runtime = "nodejs";
@ -366,9 +369,27 @@ export async function POST(request: Request) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const config = await getTripoProviderConfig(
readOptionalTrimmedText(body.providerId)
);
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const dependencyTaskId =
taskType === "refine_model"
? String(body.draftModelTaskId || "").trim()
: taskType === "convert_model" ||
taskType === "stylize_model" ||
taskType === "texture_model" ||
taskType === "animate_prerigcheck" ||
taskType === "animate_rig" ||
taskType === "animate_retarget" ||
taskType === "mesh_segmentation" ||
taskType === "mesh_completion" ||
taskType === "highpoly_to_lowpoly"
? String(body.originalModelTaskId || "").trim()
: "";
const config = dependencyTaskId
? await resolveTripoProviderConfigForTaskId(
dependencyTaskId,
preferredProviderId,
)
: await getTripoProviderConfig(preferredProviderId);
const result = await tripoRequest<Record<string, unknown>>(config, {
method: "POST",
path: "/task",

View File

@ -1554,10 +1554,19 @@ export default function CanvasModel3DPage() {
exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null;
}) => {
const normalizedPayload =
params.provider === "tripo-3d" &&
!String(params.payload.providerId || "").trim() &&
params.assetPayload?.providerId
? {
...params.payload,
providerId: params.assetPayload.providerId,
}
: params.payload;
const createResult =
params.provider === "worldlabs-3d"
? await worldLabsGenerationService.createTask(params.payload)
: await model3DGenerationService.createTask(params.payload);
? await worldLabsGenerationService.createTask(normalizedPayload)
: await model3DGenerationService.createTask(normalizedPayload);
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建任务失败");
}
@ -1622,6 +1631,8 @@ export default function CanvasModel3DPage() {
promptText: string;
sourceTaskId: string;
successMessage: string;
assetPayload?: Model3DTaskAssetPayload | null;
modelLabel?: string;
}) => {
setIsSubmitting(true);
try {
@ -1630,8 +1641,9 @@ export default function CanvasModel3DPage() {
payload: params.payload,
mode: params.mode,
promptText: params.promptText,
modelLabel: modelVersion,
modelLabel: params.modelLabel || modelVersion,
sourceTaskId: params.sourceTaskId,
assetPayload: params.assetPayload ?? null,
});
toast.success(params.successMessage);
} catch (error) {
@ -2292,6 +2304,7 @@ export default function CanvasModel3DPage() {
modelLabel: readyTask.modelVersion,
sourceTaskId: baseTaskId,
exportFormat,
assetPayload: readyTask.assetPayload ?? null,
});
setAutoDownloadTaskId(task.taskId);
toast.success(`已提交${exportFormat}转换任务,完成后自动下载`);
@ -2319,6 +2332,7 @@ export default function CanvasModel3DPage() {
promptText: `精修任务(来源 ${readyTask.taskId.slice(0, 8)})`,
modelLabel: readyTask.modelVersion,
sourceTaskId: readyTask.taskId,
assetPayload: readyTask.assetPayload ?? null,
});
toast.success("已提交模型精修任务");
} catch (error) {
@ -2348,6 +2362,8 @@ export default function CanvasModel3DPage() {
promptText: `纹理重绘(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交纹理重绘任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2368,6 +2384,8 @@ export default function CanvasModel3DPage() {
promptText: `风格化 ${stylizeStyle}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交风格化任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2384,6 +2402,8 @@ export default function CanvasModel3DPage() {
promptText: `可绑定检测(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交可绑定检测任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2403,6 +2423,8 @@ export default function CanvasModel3DPage() {
promptText: `自动绑定 ${rigType}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交自动绑定任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2424,6 +2446,8 @@ export default function CanvasModel3DPage() {
promptText: `动作 ${retargetAnimation}(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交动作重定向任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2440,6 +2464,8 @@ export default function CanvasModel3DPage() {
promptText: `网格分割(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交网格分割任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2462,6 +2488,8 @@ export default function CanvasModel3DPage() {
promptText: `网格补全(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交网格补全任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};
@ -2483,6 +2511,8 @@ export default function CanvasModel3DPage() {
promptText: `低模优化(来源 ${readyTask.taskId.slice(0, 8)})`,
sourceTaskId: readyTask.taskId,
successMessage: "已提交低模优化任务",
assetPayload: readyTask.assetPayload ?? null,
modelLabel: readyTask.modelVersion,
});
};

View File

@ -46,10 +46,18 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import {
defaultPointsSystemConfig,
getImageToolCreditCost,
normalizePointsSystemConfig,
type ImageToolCreditFeature,
type PointsSystemConfig,
} from "@/lib/points/config";
import { cn, fetcher } from "@/lib/utils";
import {
canvasService,
@ -58,6 +66,7 @@ import {
workflowAnalysisService,
} from "@/services/canvas";
import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
defaultVideoCanvasFunctionConfig,
@ -251,6 +260,13 @@ type CanvasModelOption = {
isDefault?: boolean;
isSubModel?: boolean;
capabilities?: unknown;
imageGenConfig?: {
creditCostPerImage?: number | null;
creditCostPerVideo?: number | null;
videoDurationOptions?: number[] | null;
creditCostPerVideoByDuration?: Record<string, number> | null;
creditCostPerVideoByResolution?: Record<string, number> | null;
} | null;
};
type CanvasModelKind = "image" | "video" | "multimodal" | "any";
@ -312,6 +328,7 @@ function getSiteShowcaseName(siteName?: string | null) {
}
type MembershipSummaryResponse = {
isHighVip?: boolean;
currentPlanId?: string;
currentPlan?: {
id?: string;
@ -332,6 +349,7 @@ type MembershipSummaryResponse = {
remainingThisMonth?: number | null;
totalImages?: number | null;
};
pointsSystemConfig?: PointsSystemConfig | null;
};
type MembershipPlan = {
@ -1729,6 +1747,130 @@ function getCanvasModelDisplayName(
return match?.displayName || normalized;
}
type VideoCanvasBillingDisplay = {
label: string;
message: string;
};
function getCanvasModelOptionByRef(
modelRef: string | undefined,
models: CanvasModelOption[],
) {
const normalized = normalizeModelRef(modelRef);
if (!normalized) return null;
return (
models.find(
(model) =>
model.id === normalized ||
model.modelId === normalized ||
getCanvasModelRef(model) === normalized,
) || null
);
}
function getVideoCanvasFallbackBillingDisplay(
definition?: Pick<VideoCanvasFunctionDefinition, "key" | "route"> | null,
): VideoCanvasBillingDisplay {
const copy = definition
? getVideoCanvasFunctionBillingCopy(definition)
: {
shortLabel: "按站点",
description: "沿用站点统一模型与功能积分规则,不在视频画布单独改价。",
};
return {
label: copy.shortLabel,
message: copy.description,
};
}
function resolveVideoCanvasBillingDisplay(params: {
membershipSummary?: MembershipSummaryResponse;
modelOptions?: CanvasModelOption[];
modelRef?: string;
modelKind?: CanvasModelKind;
imageCount?: number;
duration?: number | null;
resolution?: string | null;
billingFeature?: ImageToolCreditFeature | null;
functionDefinition?: Pick<VideoCanvasFunctionDefinition, "key" | "route"> | null;
}): VideoCanvasBillingDisplay {
const fallback = getVideoCanvasFallbackBillingDisplay(params.functionDefinition);
const pointsConfig = normalizePointsSystemConfig(
params.membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig,
);
if (params.membershipSummary?.isHighVip) {
return {
label: "VIP",
message: "当前高阶会员按站点规则免扣积分。",
};
}
if (pointsConfig.enabled !== true) {
return {
label: "免费",
message: "当前站点未启用积分扣费。",
};
}
if (params.billingFeature) {
const featureCost = getImageToolCreditCost(pointsConfig, params.billingFeature);
if (featureCost > 0) {
return {
label: String(featureCost),
message: `当前功能按全站积分设置消耗 ${featureCost} 积分。`,
};
}
return fallback;
}
const matchedModel = getCanvasModelOptionByRef(
params.modelRef,
params.modelOptions || [],
);
if (!matchedModel?.imageGenConfig) {
return fallback;
}
if (params.modelKind === "video") {
const videoCost = getVideoCreditCostFromConfig(
matchedModel.imageGenConfig,
params.duration,
params.resolution,
);
if (videoCost > 0) {
return {
label: String(videoCost),
message: `当前视频模型按站点配置预计消耗 ${videoCost} 积分。`,
};
}
return fallback;
}
if (params.modelKind === "image") {
const unitCost = Math.max(
0,
Math.round(Number(matchedModel.imageGenConfig.creditCostPerImage ?? 0)),
);
if (unitCost > 0) {
const safeImageCount = Math.max(
1,
Math.round(Number(params.imageCount || 1)),
);
const totalCost = unitCost * safeImageCount;
return {
label: String(unitCost),
message:
safeImageCount > 1
? `当前图片模型按站点配置 ${unitCost} 积分/张,本次共 ${totalCost} 积分。`
: `当前图片模型按站点配置消耗 ${unitCost} 积分/张。`,
};
}
}
return fallback;
}
function composeFunctionPrompt(
definition: VideoCanvasFunctionDefinition,
userPrompt?: string,
@ -2552,6 +2694,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
"/api/models",
fetcher
);
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const videoCanvasFunctionConfig = useMemo(
() => normalizeVideoCanvasFunctionConfig(videoCanvasFunctionSettings?.config),
[videoCanvasFunctionSettings?.config]
@ -5398,6 +5544,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{selectedPresetAnchor && selectedPresetControlNode ? (
<PresetWorkflowControls
anchor={selectedPresetAnchor}
membershipSummary={membershipSummary}
mode={selectedPresetMode ?? "character-three-view"}
functionConfig={videoCanvasFunctionConfig}
modelOptions={canvasModelOptions}
@ -5473,10 +5620,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
<TextTargetModuleComposer
anchor={selectedTargetAnchor}
billingDefinition={getConfiguredFunction(
videoCanvasFunctionConfig,
"story-script",
)}
defaultModel={getFunctionModelValue(
videoCanvasFunctionConfig,
getConfiguredFunction(videoCanvasFunctionConfig, "story-script"),
)}
membershipSummary={membershipSummary}
modelOptions={canvasModelOptions}
node={selectedNode}
onSubmit={(prompt, model) => {
@ -5509,6 +5661,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
<VideoTargetModuleComposer
anchor={selectedTargetAnchor}
functionConfig={videoCanvasFunctionConfig}
membershipSummary={membershipSummary}
modelOptions={canvasModelOptions}
node={selectedNode}
sourceNodes={selectedTargetSourceNodes}
@ -6221,14 +6374,14 @@ function MagnetGlyph() {
function GridSplitActionToolbar({
anchor,
creditCost,
billingLabel,
onClearSelection,
onCreateNodes,
onUpscale,
selectedCount,
}: {
anchor?: PresetControlAnchor;
creditCost: number;
billingLabel: string;
onClearSelection: () => void;
onCreateNodes: () => void;
onUpscale: (scaleFactor: number) => void;
@ -6287,7 +6440,7 @@ function GridSplitActionToolbar({
<SceneToolIcon kind="hd" />
<span className="whitespace-nowrap">生成高清图片</span>
<span className="rounded-[7px] bg-white/10 px-2 py-0.5 text-[13px] text-white/72">
↑{Math.max(0, creditCost)}
{billingLabel}
</span>
</button>
<div className="relative">
@ -6700,6 +6853,7 @@ function VideoAnalysisFloatingPanel({
function PresetWorkflowControls({
anchor,
functionConfig,
membershipSummary,
mode,
modelOptions,
nodes,
@ -6713,6 +6867,7 @@ function PresetWorkflowControls({
}: {
anchor: PresetControlAnchor;
functionConfig: VideoCanvasFunctionConfig;
membershipSummary?: MembershipSummaryResponse;
mode: Exclude<ActivePresetControlMode, null>;
modelOptions: CanvasModelOption[];
nodes: CloneNode[];
@ -6732,7 +6887,9 @@ function PresetWorkflowControls({
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<GridSplitActionToolbar
anchor={anchor}
creditCost={(getConfiguredFunction(functionConfig, "hd-upscale").creditCost || 0) * gridSplit.selectedIndices.length}
billingLabel={getVideoCanvasFunctionBillingCopy(
getConfiguredFunction(functionConfig, "hd-upscale")
).shortLabel}
onClearSelection={onClearGridSplitSelection}
onCreateNodes={onGridSplitCreateNodes}
onUpscale={onGridSplitUpscale}
@ -6746,7 +6903,10 @@ function PresetWorkflowControls({
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
<PromptComposer
anchor={anchor}
billingFeature={getImageGenerationBillingFeature(mode)}
defaultModel={getFunctionModelValue(functionConfig, definition)}
functionDefinition={definition}
membershipSummary={membershipSummary}
modelKind="image"
modelOptions={modelOptions}
nodes={nodes}
@ -6772,8 +6932,9 @@ function PresetWorkflowControls({
/>
<PresetComposerPanel
anchor={anchor}
credit="8"
billingDefinition={definition}
defaultModel={getFunctionModelValue(functionConfig, definition)}
membershipSummary={membershipSummary}
modelKind="multimodal"
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
@ -6800,8 +6961,9 @@ function PresetWorkflowControls({
/>
<PresetComposerPanel
anchor={anchor}
credit="28"
billingDefinition={definition}
defaultModel={getFunctionModelValue(functionConfig, definition)}
membershipSummary={membershipSummary}
modelKind="video"
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
@ -6819,7 +6981,9 @@ function PresetWorkflowControls({
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<AudioToVideoComposerPanel
anchor={anchor}
billingDefinition={definition}
defaultModel={getFunctionModelValue(functionConfig, definition)}
membershipSummary={membershipSummary}
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
/>
@ -6829,13 +6993,17 @@ function PresetWorkflowControls({
function TextTargetModuleComposer({
anchor,
billingDefinition,
defaultModel = "auto",
membershipSummary,
modelOptions = [],
node,
onSubmit,
}: {
anchor: PresetControlAnchor;
billingDefinition?: VideoCanvasFunctionDefinition;
defaultModel?: string;
membershipSummary?: MembershipSummaryResponse;
modelOptions?: CanvasModelOption[];
node: CloneNode;
onSubmit: (prompt: string, model: string) => void;
@ -6859,6 +7027,18 @@ function TextTargetModuleComposer({
setSelectedModel(initialModel);
}, [initialModel, node.id]);
const billingDisplay = useMemo(
() =>
resolveVideoCanvasBillingDisplay({
functionDefinition: billingDefinition,
membershipSummary,
modelKind: "multimodal",
modelOptions,
modelRef: selectedModel,
}),
[billingDefinition, membershipSummary, modelOptions, selectedModel],
);
return (
<div
className="pointer-events-auto absolute z-30 overflow-visible"
@ -6903,7 +7083,12 @@ function TextTargetModuleComposer({
/>
<div className="min-w-0 flex-1" />
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
<ComposerControl label="6" icon="⚡" muted onClick={() => toast.info("积分估算接入本地积分系统。")} />
<ComposerControl
label={billingDisplay.label}
icon="⚡"
muted
onClick={() => toast.info(billingDisplay.message)}
/>
</div>
<button
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] leading-none text-black transition hover:bg-white"
@ -6920,6 +7105,7 @@ function TextTargetModuleComposer({
function VideoTargetModuleComposer({
anchor,
functionConfig,
membershipSummary,
modelOptions = [],
node,
onSubmit,
@ -6927,6 +7113,7 @@ function VideoTargetModuleComposer({
}: {
anchor: PresetControlAnchor;
functionConfig: VideoCanvasFunctionConfig;
membershipSummary?: MembershipSummaryResponse;
modelOptions?: CanvasModelOption[];
node: CloneNode;
onSubmit: (payload: VideoTargetComposerSubmitPayload) => void;
@ -7010,6 +7197,27 @@ function VideoTargetModuleComposer({
});
}, [activeModelFallback]);
const billingDisplay = useMemo(
() =>
resolveVideoCanvasBillingDisplay({
duration: normalizeVideoDurationValue(settings.duration),
functionDefinition: activeDefinition,
membershipSummary,
modelKind: "video",
modelOptions,
modelRef: settings.model,
resolution: normalizeVideoResolutionValue(settings.resolution),
}),
[
activeDefinition,
membershipSummary,
modelOptions,
settings.duration,
settings.model,
settings.resolution,
],
);
return (
<div
className="pointer-events-auto absolute z-30 overflow-visible"
@ -7162,10 +7370,10 @@ function VideoTargetModuleComposer({
<ComposerControl label="" icon="⌘" onClick={() => toast.info("高级视频参数后端待接入。")} />
<ComposerControl label="1个" dropdown onClick={() => toast.info("当前仅支持单次生成 1 个视频结果。")} />
<ComposerControl
label={String(activeDefinition.creditCost || 14)}
label={billingDisplay.label}
icon="⚡"
muted
onClick={() => toast.info("积分估算接入本地积分系统。")}
onClick={() => toast.info(billingDisplay.message)}
/>
</div>
<button
@ -7267,12 +7475,16 @@ function CharacterPresetToolbar({
function AudioToVideoComposerPanel({
anchor,
billingDefinition,
defaultModel = "auto",
membershipSummary,
modelOptions = [],
onSubmit,
}: {
anchor?: PresetControlAnchor;
billingDefinition?: VideoCanvasFunctionDefinition;
defaultModel?: string;
membershipSummary?: MembershipSummaryResponse;
modelOptions?: CanvasModelOption[];
onSubmit?: (prompt: string, settings?: ComposerSettings) => void;
}) {
@ -7291,6 +7503,20 @@ function AudioToVideoComposerPanel({
setSettings((current) => ({ ...current, model: normalizeModelRef(defaultModel) || "auto" }));
}, [defaultModel]);
const billingDisplay = useMemo(
() =>
resolveVideoCanvasBillingDisplay({
duration: 5,
functionDefinition: billingDefinition,
membershipSummary,
modelKind: "video",
modelOptions,
modelRef: settings.model,
resolution: normalizeVideoResolutionValue(settings.resolution),
}),
[billingDefinition, membershipSummary, modelOptions, settings.model, settings.resolution],
);
return (
<div
className="pointer-events-auto absolute overflow-visible"
@ -7381,7 +7607,12 @@ function AudioToVideoComposerPanel({
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
<ComposerControl label="" icon="⌘" onClick={() => toast.info("高级设置后端待接入。")} />
<ComposerControl label="1个" dropdown onClick={() => toast.info("生成数量后端待接入。")} />
<ComposerControl label="108 / 135" icon="⚡" muted onClick={() => toast.info("积分估算接入本地积分系统。")} />
<ComposerControl
label={billingDisplay.label}
icon="⚡"
muted
onClick={() => toast.info(billingDisplay.message)}
/>
</div>
<button
className="absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white text-[34px] leading-none text-black hover:bg-white/90"
@ -7612,7 +7843,6 @@ function StoryScriptScene() {
<ScriptGeneratorPanel />
</div>
<PresetComposerPanel
credit="8"
prompt={STORY_SCRIPT_PROMPT}
submitMessage="故事脚本生成接口待接入。"
title="脚本生成器"
@ -7638,7 +7868,7 @@ function FirstFrameVideoScene() {
<VideoResultPanel label="视频" />
</div>
<PresetComposerPanel
credit="28"
modelKind="video"
prompt="以当前图为首帧生成视频。"
submitMessage="首帧图生视频接口待接入。"
thumbnailUrl={FIRST_FRAME_IMAGE_URL}
@ -7668,7 +7898,7 @@ function AudioToVideoScene() {
<VideoResultPanel label="视频" />
</div>
<PresetComposerPanel
credit="32"
modelKind="video"
prompt={AUDIO_TO_VIDEO_PROMPT}
submitMessage="音频生视频接口待接入。"
thumbnailUrl={AUDIO_REFERENCE_IMAGE_URL}
@ -7869,8 +8099,9 @@ function AudioResourcePanel() {
function PresetComposerPanel({
anchor,
credit,
billingDefinition,
defaultModel = "auto",
membershipSummary,
modelKind = "image",
modelOptions = [],
onSubmit,
@ -7880,8 +8111,9 @@ function PresetComposerPanel({
title,
}: {
anchor?: PresetControlAnchor;
credit: string;
billingDefinition?: VideoCanvasFunctionDefinition;
defaultModel?: string;
membershipSummary?: MembershipSummaryResponse;
modelKind?: CanvasModelKind;
modelOptions?: CanvasModelOption[];
onSubmit?: (prompt: string, settings?: ComposerSettings) => void;
@ -7909,6 +8141,21 @@ function PresetComposerPanel({
setSettings((current) => ({ ...current, model: normalizeModelRef(defaultModel) || "auto" }));
}, [defaultModel]);
const billingDisplay = useMemo(
() =>
resolveVideoCanvasBillingDisplay({
duration: modelKind === "video" ? 5 : null,
functionDefinition: billingDefinition,
imageCount: 1,
membershipSummary,
modelKind,
modelOptions,
modelRef: settings.model,
resolution: modelKind === "video" ? "720p" : null,
}),
[billingDefinition, membershipSummary, modelKind, modelOptions, settings.model],
);
return (
<div
className="pointer-events-auto absolute overflow-visible"
@ -7966,7 +8213,12 @@ function PresetComposerPanel({
<ComposerControl label="2K" dropdown onClick={() => toast.info("清晰度选择接口待接入。")} />
<ComposerControl label="摄像机" icon="▣" onClick={() => toast.info("镜头配置接口待接入。")} />
<ComposerControl label="1张" dropdown onClick={() => toast.info("生成数量接口待接入。")} />
<ComposerControl label={credit} icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
<ComposerControl
label={billingDisplay.label}
icon="⚡"
muted
onClick={() => toast.info(billingDisplay.message)}
/>
</div>
<button
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
@ -8455,7 +8707,10 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
function PromptComposer({
anchor,
billingFeature = null,
defaultModel = "auto",
functionDefinition,
membershipSummary,
modelKind = "image",
modelOptions = [],
nodes = [],
@ -8463,7 +8718,10 @@ function PromptComposer({
referenceThumbnailUrl = CHARACTER_IMAGE_URL,
}: {
anchor?: PresetControlAnchor;
billingFeature?: ImageToolCreditFeature | null;
defaultModel?: string;
functionDefinition?: VideoCanvasFunctionDefinition;
membershipSummary?: MembershipSummaryResponse;
modelKind?: CanvasModelKind;
modelOptions?: CanvasModelOption[];
nodes?: CloneNode[];
@ -8490,6 +8748,27 @@ function PromptComposer({
() => nodes.filter((node) => node.kind !== "script" && node.inputType !== "audio"),
[nodes]
);
const billingDisplay = useMemo(
() =>
resolveVideoCanvasBillingDisplay({
billingFeature,
functionDefinition,
imageCount: Math.max(1, Number.parseInt(settings.count, 10) || 1),
membershipSummary,
modelKind,
modelOptions,
modelRef: settings.model,
}),
[
billingFeature,
functionDefinition,
membershipSummary,
modelKind,
modelOptions,
settings.count,
settings.model,
],
);
const insertReference = useCallback((node: CloneNode) => {
const label = node.title || "节点";
@ -8637,7 +8916,12 @@ function PromptComposer({
<ComposerControl active={menu === "translate"} label="文" onClick={() => setMenu((current) => current === "translate" ? null : "translate")} />
<ComposerControl active={menu === "settings"} label="" icon="⌘" onClick={() => setMenu((current) => current === "settings" ? null : "settings")} />
<ComposerControl active={menu === "count"} label={settings.count} dropdown onClick={() => setMenu((current) => current === "count" ? null : "count")} />
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
<ComposerControl
label={billingDisplay.label}
icon="⚡"
muted
onClick={() => toast.info(billingDisplay.message)}
/>
</div>
<button
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"

View File

@ -1,6 +1,7 @@
import "server-only";
import { getAiProvidersByType } from "@/lib/db/queries";
import { TripoApiError, tripoRequest } from "./client";
import type { TripoProviderConfig } from "./types";
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
@ -21,6 +22,16 @@ function sanitizeTripoBaseUrl(value: string): string {
.trim();
}
function isTripoTaskNotFoundError(error: unknown): error is TripoApiError {
if (!(error instanceof TripoApiError)) return false;
if (error.httpStatus === 404) return true;
const normalizedMessage = String(error.message || "").toLowerCase();
return (
normalizedMessage.includes("task is not found") ||
normalizedMessage.includes("task not found")
);
}
export async function getTripoProviderConfigs(): Promise<TripoProviderConfig[]> {
const providers = await getAiProvidersByType({
type: "tripo-3d",
@ -86,3 +97,58 @@ export async function getTripoProviderConfig(
throw new Error("未配置 Tripo3D API,请在系统设置-本地AI新增 Tripo3D 提供商");
}
export async function resolveTripoProviderConfigForTaskId(
taskId: string,
preferredProviderId?: string | null,
): Promise<TripoProviderConfig> {
const normalizedTaskId = String(taskId || "").trim();
if (!normalizedTaskId) {
return getTripoProviderConfig(preferredProviderId);
}
const providers = await getTripoProviderConfigs();
if (providers.length <= 1) {
return getTripoProviderConfig(preferredProviderId);
}
const normalizedPreferredProviderId = String(preferredProviderId || "").trim();
const preferredProvider = normalizedPreferredProviderId
? providers.find((provider) => provider.providerId === normalizedPreferredProviderId)
: null;
const orderedProviders = preferredProvider
? [
preferredProvider,
...providers.filter(
(provider) => provider.providerId !== preferredProvider.providerId,
),
]
: providers;
let lastTaskNotFoundError: TripoApiError | null = null;
let lastUnexpectedError: unknown = null;
for (const provider of orderedProviders) {
try {
await tripoRequest<Record<string, unknown>>(provider, {
method: "GET",
path: `/task/${encodeURIComponent(normalizedTaskId)}`,
});
return provider;
} catch (error) {
if (isTripoTaskNotFoundError(error)) {
lastTaskNotFoundError = error;
continue;
}
lastUnexpectedError = error;
}
}
if (lastUnexpectedError) {
throw lastUnexpectedError;
}
if (lastTaskNotFoundError) {
throw lastTaskNotFoundError;
}
return getTripoProviderConfig(preferredProviderId);
}

View File

@ -28,12 +28,16 @@ export type VideoCanvasFunctionDefinition = {
route: VideoCanvasBackendRoute;
enabled: boolean;
model?: string;
creditCost?: number;
prompt: string;
description: string;
requiresBackend?: boolean;
};
export type VideoCanvasFunctionBillingCopy = {
shortLabel: string;
description: string;
};
export type VideoCanvasFunctionConfig = {
enabled: boolean;
defaultImageModel: string;
@ -49,7 +53,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
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 连续剧情分镜合成图。",
@ -60,7 +63,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
description: "官方角色三视图预设,使用参考图生图/编辑模型。",
@ -71,7 +73,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
@ -82,7 +83,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,必须严格 5x5 等分排版,25 个宫格的宽高一致、间距一致、边界完整,所有宫格之间使用清晰纯黑分割线,分割线等宽且横线竖线都要贯穿整张图;禁止使用白线、浅色线、柔边、渐变线、不规则拼贴、错位排版、跨格大图、留白边距、文字、水印、编号或额外边框说明。",
description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。",
@ -93,7 +93,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片为唯一参考,在保持角色身份、发型、服装、姿态和构图一致的前提下,进行电影级光影校正。增强自然逆光、轮廓光、皮肤通透感、背景层次和柔和景深,保留原始角色与场景,不改变人物数量,不生成宫格或分镜。输出单张 16:9 图片,不能出现文字、水印、编号或额外边框。",
description: "官方电影级光影校正:对参考图做单张电影感光影重塑。",
@ -104,7 +103,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为当前画面,推演同一镜头时间线 3 秒后的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作、表情、衣袖/头发摆动、视线或镜头位置发生合理连续变化。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 3 秒后的单帧结果。",
@ -115,7 +113,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片作为当前画面,反向推演同一镜头时间线 5 秒前的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作起势、姿态、表情、衣袖/头发位置或镜头位置回到合理的前一状态。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 5 秒前的单帧结果。",
@ -126,7 +123,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "utility",
route: "local",
enabled: true,
creditCost: 0,
prompt: "按当前图的标准宫格布局进行固定等分切分,将合成图切分为可单独调整的镜头图片。",
description: "对齐官方画布:4/9/16/25 和自定义宫格都按当前图的行列固定等分切分,并支持在当前图上多选宫格后继续创建生图或高清节点。",
},
@ -136,7 +132,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"在保持主体、构图和身份一致的前提下,对图片进行电影级光影重塑,增强轮廓光、层次、对比和空间感,保留自然材质细节。",
description: "图片编辑生图模型,复刻官方打光工具。",
@ -147,7 +142,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "utility",
route: "enhance",
enabled: true,
creditCost: 8,
prompt: "提升图片清晰度、细节和分辨率,保持原图内容不变。",
description: "接入现有图片增强接口;高清重绘可在模型字段指定增强 Pro。",
},
@ -157,7 +151,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "extend",
enabled: true,
creditCost: 14,
prompt:
"基于当前图片向四周扩展为 720 全景视野,保持主体与场景一致,补全自然环境和空间透视,避免变形和重复纹理。",
description: "优先使用拓图接口,必要时可切到图片编辑生图模型。",
@ -168,7 +161,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "utility",
route: "extend",
enabled: true,
creditCost: 10,
prompt: "在保持主体不变的前提下扩展画面边界,补全自然背景和构图。",
description: "对应官方高清菜单里的扩图。",
},
@ -178,7 +170,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt: "保持主体结构和身份一致,按照用户补充提示重绘画面局部或整体风格。",
description: "对应官方高清菜单里的重绘。",
},
@ -188,7 +179,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 10,
prompt: "根据用户标记或文字说明移除指定元素,并自然补全背景纹理。",
description: "当前以前端提示词占位接图片编辑模型,后续可替换为蒙版编辑接口。",
},
@ -198,7 +188,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "utility",
route: "remove-bg",
enabled: true,
creditCost: 4,
prompt: "移除图片背景,保留主体透明或干净背景结果。",
description: "接入现有抠图接口。",
},
@ -208,7 +197,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "utility",
route: "local",
enabled: true,
creditCost: 0,
prompt: "按用户选择的裁剪比例或宫格切片范围处理图片。",
description: "本地画布裁剪/宫格切分能力。",
},
@ -218,7 +206,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "image-edit",
route: "angle-edit",
enabled: true,
creditCost: 1,
prompt: "根据水平环绕、垂直俯仰和景别缩放参数生成新的角色/主体视角。",
description: "接现有 /api/angle-edit,多角度编辑器提交时触发。",
},
@ -228,7 +215,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
creditCost: 6,
prompt: "根据输入文本、图片或视频参考,生成结构化分镜脚本,包含镜号、场景、画面、声音和镜头节奏。",
description: "多模态大模型/文本模型生成脚本表格。",
},
@ -238,7 +224,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
creditCost: 6,
prompt:
"解析当前视频的镜头节奏、景别变化、主体动作、背景音乐、人声/音效、关键帧要点,并输出可直接衔接后续图像生成与视频生成节点的结构化故事表。",
description: "多模态视频解析:抽取关键帧并输出视频故事表。",
@ -249,7 +234,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
creditCost: 3,
prompt: "分析参考图片,输出结构化中文提示词,包括主体、环境、构图、光影、镜头语言和风格关键词。",
description: "多模态识图生成提示词。",
},
@ -259,7 +243,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "根据文本描述生成短视频,保持镜头运动、节奏和电影级质感。",
description: "视频生成模型,走现有视频生成接口。",
},
@ -269,7 +252,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "以参考图片作为视频首帧,生成自然流畅的短视频。",
description: "图片到视频/首帧生视频。",
},
@ -279,7 +261,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "以两张参考图作为首帧和尾帧,生成首尾连贯的短视频。",
description: "首尾帧视频生成。",
},
@ -289,7 +270,6 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "根据上传音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。",
description: "官方为音频+参考图到视频;当前先接视频生成提示词和参考图,音频节拍解析接口后续补充。",
requiresBackend: true,
@ -377,10 +357,6 @@ function normalizeFunctionDefinition(
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
model: typeof incoming.model === "string" ? incoming.model : fallback.model,
creditCost:
typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost)
? Math.max(0, incoming.creditCost)
: fallback.creditCost,
prompt: normalizeFunctionPrompt(key, incoming.prompt, fallback.prompt),
description: String(incoming.description || fallback.description),
requiresBackend:
@ -408,7 +384,6 @@ export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasF
kind: (customValue.kind as VideoCanvasFunctionKind) || "workflow",
route: (customValue.route as VideoCanvasBackendRoute) || "local",
enabled: customValue.enabled !== false,
creditCost: typeof customValue.creditCost === "number" ? Math.max(0, customValue.creditCost) : 0,
prompt: String(customValue.prompt || ""),
description: String(customValue.description || "自定义视频画布功能"),
};
@ -432,3 +407,75 @@ export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasF
functions,
};
}
export function getVideoCanvasFunctionBillingCopy(
definition: Pick<VideoCanvasFunctionDefinition, "key" | "route">
): VideoCanvasFunctionBillingCopy {
if (definition.route === "local") {
return {
shortLabel: "本地",
description: "本地画布工具,不单独扣积分。",
};
}
if (definition.key === "character-three-view" || definition.key === "multi-view-nine-grid") {
return {
shortLabel: "多视角",
description: "跟随全站“图片多视角”积分设置,不在视频画布单独改价。",
};
}
if (definition.key === "image-repaint") {
return {
shortLabel: "重绘",
description: "跟随全站“图片重绘”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "remove-bg") {
return {
shortLabel: "抠图",
description: "跟随全站“抠图”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "extend") {
return {
shortLabel: "扩图",
description: "跟随全站“图像拓展”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "enhance-pro") {
return {
shortLabel: "增强Pro",
description: "跟随全站“图像增强 Pro”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "enhance") {
return {
shortLabel: "按站点",
description: "沿用站点统一高清/增强能力,不在视频画布单独配置扣点。",
};
}
if (definition.route === "canvas-generate-video") {
return {
shortLabel: "按站点",
description: "沿用站点统一视频模型与积分规则,不在视频画布单独改价。",
};
}
if (definition.route === "canvas-workflow-analyze" || definition.route === "angle-edit") {
return {
shortLabel: "按站点",
description: "沿用站点统一模型与功能规则,不在视频画布单独改价。",
};
}
return {
shortLabel: "按站点",
description: "沿用站点统一图片生成与功能积分规则,不在视频画布单独改价。",
};
}