ai-chatbot/app/(admin)/settings/video-canvas/video-canvas-settings-client.tsx

4505 lines
179 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasReferenceBehavior,
type VideoCanvasReferenceModelRule,
type VideoCanvasRolePreset,
type VideoCanvasSubjectVoicePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
import {
VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS,
VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES,
VIDEO_CANVAS_HISTORY_ZOOM_LEVELS,
type VideoCanvasHistoryPickerMediaMode,
type VideoCanvasHistoryZoomPercent,
} from "@/lib/video-canvas/history-assets";
import type { VideoHdResolution } from "@/lib/video-canvas/video-hd";
import {
VIDEO_CANVAS_MODEL_ICON_KEYS,
type VideoCanvasModelCatalogEntry,
} from "@/lib/video-canvas/video-model-catalog";
import type {
VideoCanvasToolboxConfig,
VideoCanvasToolboxTemplate,
} from "@/lib/video-canvas/toolbox-workflows";
type SettingsResponse = {
setting?: {
value?: unknown;
};
};
type AiModelOption = {
id: string;
modelId: string;
displayName: string;
providerName?: string;
providerType?: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
isEnabled?: boolean;
isHidden?: boolean;
isMultimodal?: boolean;
capabilities?: unknown;
};
type AiModelsResponse = {
models?: AiModelOption[];
};
const INHERIT_MODEL_VALUE = "__inherit__";
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
audio: "音频",
image: "图片生成",
"image-edit": "图片编辑",
multimodal: "多模态",
utility: "工具",
video: "视频生成",
workflow: "工作流",
};
function getModelOptionValue(model: AiModelOption) {
return String(model.modelId || model.id || "").trim();
}
function getModelOptionLabel(model: AiModelOption) {
const name = model.displayName || model.modelId || model.id;
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
return `${name}${id}`;
}
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({
allowInherit = false,
kind,
models,
onChange,
value,
}: {
allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"];
models: AiModelOption[];
onChange: (value: string) => void;
value: string;
}) {
const filteredModels = filterModelsForFunctionKind(models, kind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent =
normalizedValue === "auto" ||
normalizedValue === INHERIT_MODEL_VALUE ||
filteredModels.some((model) => getModelOptionValue(model) === normalizedValue);
return (
<Select
onValueChange={(nextValue) => {
if (nextValue === INHERIT_MODEL_VALUE) {
onChange("");
return;
}
onChange(nextValue);
}}
value={normalizedValue}
>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{allowInherit ? (
<SelectItem value={INHERIT_MODEL_VALUE}>继承全局默认模型</SelectItem>
) : (
<SelectItem value="auto">自动选择可用模型</SelectItem>
)}
{!hasCurrent && normalizedValue ? (
<SelectItem value={normalizedValue}>当前保存值:{normalizedValue}</SelectItem>
) : null}
{filteredModels.map((model) => {
const optionValue = getModelOptionValue(model);
return (
<SelectItem key={`${model.id}-${optionValue}`} value={optionValue}>
{getModelOptionLabel(model)}
</SelectItem>
);
})}
{filteredModels.length === 0 ? (
<SelectItem disabled value="__no_models__">
暂无已启用模型
</SelectItem>
) : null}
</SelectContent>
</Select>
);
}
function DirectorCameraPresetsEditor({
onChange,
value,
}: {
onChange: (value: VideoCanvasFunctionConfig["directorDesk"]["cameraPresets"]) => void;
value: VideoCanvasFunctionConfig["directorDesk"]["cameraPresets"];
}) {
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
useEffect(() => {
setDraft(JSON.stringify(value, null, 2));
}, [value]);
const applyDraft = () => {
try {
const parsed = JSON.parse(draft) as unknown;
if (!Array.isArray(parsed)) throw new Error("机位配置必须是数组");
const normalized = normalizeVideoCanvasFunctionConfig({
...defaultVideoCanvasFunctionConfig,
directorDesk: {
...defaultVideoCanvasFunctionConfig.directorDesk,
cameraPresets: parsed,
},
}).directorDesk.cameraPresets;
onChange(normalized);
setDraft(JSON.stringify(normalized, null, 2));
toast.success(`已应用 ${normalized.length} 套导演台机位`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "机位 JSON 无效");
}
};
return (
<div className="max-w-4xl space-y-2 border-t pt-4">
<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="video-canvas-director-camera-presets">机位视角预设 JSON</Label>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
source=current 使用当前视角;fixed 使用 position、target 和 fov 创建固定机位。
</p>
</div>
<Button onClick={applyDraft} size="sm" type="button" variant="outline">
应用机位 JSON
</Button>
</div>
<Textarea
className="min-h-[320px] font-mono text-xs leading-5"
id="video-canvas-director-camera-presets"
onChange={(event) => setDraft(event.target.value)}
spellCheck={false}
value={draft}
/>
</div>
);
}
function DirectorPosePresetsEditor({
onChange,
value,
}: {
onChange: (value: VideoCanvasFunctionConfig["directorDesk"]["posePresets"]) => void;
value: VideoCanvasFunctionConfig["directorDesk"]["posePresets"];
}) {
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
useEffect(() => {
setDraft(JSON.stringify(value, null, 2));
}, [value]);
const applyDraft = () => {
try {
const parsed = JSON.parse(draft) as unknown;
if (!Array.isArray(parsed)) throw new Error("姿势配置必须是数组");
const normalized = normalizeVideoCanvasFunctionConfig({
...defaultVideoCanvasFunctionConfig,
directorDesk: {
...defaultVideoCanvasFunctionConfig.directorDesk,
posePresets: parsed,
},
}).directorDesk.posePresets;
onChange(normalized);
setDraft(JSON.stringify(normalized, null, 2));
toast.success(`已应用 ${normalized.length} 套导演台姿势`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "姿势 JSON 无效");
}
};
return (
<div className="max-w-4xl space-y-2 border-t pt-4">
<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="video-canvas-director-pose-presets">角色姿势预设 JSON</Label>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
每项包含 id、label 和 controls;controls 使用骨骼控制键与角度值。
</p>
</div>
<Button onClick={applyDraft} size="sm" type="button" variant="outline">
应用姿势 JSON
</Button>
</div>
<Textarea
className="min-h-[280px] font-mono text-xs leading-5"
id="video-canvas-director-pose-presets"
onChange={(event) => setDraft(event.target.value)}
spellCheck={false}
value={draft}
/>
</div>
);
}
const videoModelIconLabels = {
generic: "通用",
hailuo: "海螺",
"happy-horse": "Happy Horse",
kling: "Kling",
omnihuman: "OmniHuman",
pixverse: "Pixverse",
seedance: "Seedance",
vidu: "Vidu",
wan: "Wan",
} satisfies Record<(typeof VIDEO_CANVAS_MODEL_ICON_KEYS)[number], string>;
function VideoModelCatalogEditor({
models,
onChange,
value,
}: {
models: AiModelOption[];
onChange: (value: VideoCanvasModelCatalogEntry[]) => void;
value: VideoCanvasModelCatalogEntry[];
}) {
const updateEntry = (
index: number,
updates: Partial<VideoCanvasModelCatalogEntry>,
) => {
onChange(
value.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, ...updates } : entry,
),
);
};
const moveEntry = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= value.length) return;
const next = [...value];
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
onChange(next);
};
const addEntry = () => {
onChange([
...value,
{
badge: "",
description: "新视频模型",
discountRate: 1,
durationLabel: "2min",
enabled: true,
icon: "generic",
id: `custom-video-model-${Date.now()}`,
label: "新视频模型",
membershipOnly: false,
modelRef: "auto",
pointsPerSecondByResolution: {},
shortLabel: "新模型",
},
]);
};
return (
<div className="divide-y rounded-md border">
{value.map((entry, index) => (
<details className="group" key={entry.id}>
<summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 hover:bg-muted/40">
<span className="w-7 shrink-0 text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{entry.label}
</span>
{entry.badge ? (
<span className="shrink-0 rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-900 dark:bg-amber-950 dark:text-amber-200">
{entry.badge}
</span>
) : null}
<span className="max-w-56 truncate text-xs text-muted-foreground">
{entry.modelRef === "auto" ? "后台默认视频模型" : entry.modelRef}
</span>
<span className="text-xs text-muted-foreground group-open:hidden">展开</span>
<span className="hidden text-xs text-muted-foreground group-open:inline">收起</span>
</summary>
<div className="space-y-4 border-t bg-muted/15 px-4 py-4">
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor={`video-model-label-${index}`}>完整名称</Label>
<Input
id={`video-model-label-${index}`}
onChange={(event) => updateEntry(index, { label: event.target.value })}
value={entry.label}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-model-short-label-${index}`}>按钮短名称</Label>
<Input
id={`video-model-short-label-${index}`}
onChange={(event) =>
updateEntry(index, { shortLabel: event.target.value })
}
value={entry.shortLabel}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-model-duration-${index}`}>预计耗时</Label>
<Input
id={`video-model-duration-${index}`}
onChange={(event) =>
updateEntry(index, { durationLabel: event.target.value })
}
value={entry.durationLabel}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-model-badge-${index}`}>活动标识</Label>
<Input
id={`video-model-badge-${index}`}
onChange={(event) => updateEntry(index, { badge: event.target.value })}
placeholder="例如:720P限时9折"
value={entry.badge}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.5fr_1fr_1fr]">
<div className="space-y-2">
<Label htmlFor={`video-model-description-${index}`}>模型说明</Label>
<Input
id={`video-model-description-${index}`}
onChange={(event) =>
updateEntry(index, { description: event.target.value })
}
value={entry.description}
/>
</div>
<div className="space-y-2">
<Label>实际后端视频模型</Label>
<ModelSelect
kind="video"
models={models}
onChange={(modelRef) => updateEntry(index, { modelRef })}
value={entry.modelRef}
/>
</div>
<div className="space-y-2">
<Label>菜单图标品牌</Label>
<Select
onValueChange={(icon) =>
updateEntry(index, {
icon: icon as VideoCanvasModelCatalogEntry["icon"],
})
}
value={entry.icon}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{VIDEO_CANVAS_MODEL_ICON_KEYS.map((icon) => (
<SelectItem key={icon} value={icon}>
{videoModelIconLabels[icon]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
{["480P", "720P", "1080P", "4K"].map((resolution) => (
<div className="space-y-2" key={resolution}>
<Label htmlFor={`video-model-price-${index}-${resolution}`}>
{resolution} 积分/秒
</Label>
<Input
id={`video-model-price-${index}-${resolution}`}
min={0}
onChange={(event) => {
const raw = event.target.value;
const pointsPerSecondByResolution = {
...entry.pointsPerSecondByResolution,
};
if (raw.trim()) {
pointsPerSecondByResolution[resolution] = Number(raw);
} else {
delete pointsPerSecondByResolution[resolution];
}
updateEntry(index, { pointsPerSecondByResolution });
}}
placeholder="继承全局"
step="0.01"
type="number"
value={entry.pointsPerSecondByResolution[resolution] ?? ""}
/>
</div>
))}
<div className="space-y-2">
<Label htmlFor={`video-model-discount-${index}`}>折扣系数</Label>
<Input
id={`video-model-discount-${index}`}
max={1}
min={0.01}
onChange={(event) =>
updateEntry(index, { discountRate: Number(event.target.value) })
}
step="0.01"
type="number"
value={entry.discountRate}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-5">
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
id={`video-model-enabled-${index}`}
onCheckedChange={(enabled) => updateEntry(index, { enabled })}
/>
<Label htmlFor={`video-model-enabled-${index}`}>菜单显示</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={entry.membershipOnly}
id={`video-model-membership-${index}`}
onCheckedChange={(membershipOnly) =>
updateEntry(index, { membershipOnly })
}
/>
<Label htmlFor={`video-model-membership-${index}`}>显示会员标识</Label>
</div>
</div>
<div className="flex items-center gap-2">
<Button
aria-label={`上移 ${entry.label}`}
disabled={index === 0}
onClick={() => moveEntry(index, -1)}
size="icon"
title="上移"
type="button"
variant="outline"
>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button
aria-label={`下移 ${entry.label}`}
disabled={index === value.length - 1}
onClick={() => moveEntry(index, 1)}
size="icon"
title="下移"
type="button"
variant="outline"
>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
<Button
aria-label={`删除 ${entry.label}`}
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
size="icon"
title="删除"
type="button"
variant="destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</details>
))}
<div className="p-3">
<Button onClick={addEntry} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加视频模型
</Button>
</div>
</div>
);
}
function ToolboxWorkflowEditor({
onChange,
value,
}: {
onChange: (value: VideoCanvasToolboxConfig) => void;
value: VideoCanvasToolboxConfig;
}) {
const updateTemplate = (
index: number,
updates: Partial<VideoCanvasToolboxTemplate>,
) => {
onChange({
...value,
templates: value.templates.map((template, templateIndex) =>
templateIndex === index ? { ...template, ...updates } : template,
),
});
};
const moveTemplate = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= value.templates.length) return;
const templates = [...value.templates];
[templates[index], templates[targetIndex]] = [
templates[targetIndex],
templates[index],
];
onChange({ ...value, templates });
};
const addTemplate = () => {
const categoryId = value.categories[0]?.id || "my-toolbox";
onChange({
...value,
templates: [
...value.templates,
{
categoryId,
coverUrl: value.templates[0]?.coverUrl || "",
enabled: true,
id: `custom-toolbox-template-${Date.now()}`,
name: "新工具箱模板",
prompt: "描述模板需要生成的镜头、主体一致性和画面约束。",
referenceUrl: value.templates[0]?.referenceUrl || "",
resultUrl: value.templates[0]?.resultUrl || "",
},
],
});
};
return (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="toolbox-help-text">说明文案</Label>
<Input
id="toolbox-help-text"
onChange={(event) => onChange({ ...value, helpText: event.target.value })}
value={value.helpText}
/>
</div>
<div className="space-y-2">
<Label htmlFor="toolbox-help-url">详细教程地址</Label>
<Input
id="toolbox-help-url"
onChange={(event) => onChange({ ...value, helpUrl: event.target.value })}
value={value.helpUrl}
/>
</div>
</div>
<div className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium">顶部分类</h3>
<p className="text-xs text-muted-foreground">分类顺序就是画布浮层的页签顺序。</p>
</div>
<Button
onClick={() =>
onChange({
...value,
categories: [
...value.categories,
{
enabled: true,
id: `toolbox-category-${Date.now()}`,
label: "新分类",
},
],
})
}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1.5 h-4 w-4" />
添加分类
</Button>
</div>
<div className="space-y-3">
{value.categories.map((category, index) => (
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]" key={category.id}>
<div className="space-y-1">
<Label htmlFor={`toolbox-category-label-${index}`}>显示名称</Label>
<Input
id={`toolbox-category-label-${index}`}
onChange={(event) =>
onChange({
...value,
categories: value.categories.map((item, itemIndex) =>
itemIndex === index
? { ...item, label: event.target.value }
: item,
),
})
}
value={category.label}
/>
</div>
<div className="space-y-1">
<Label>分类 ID</Label>
<Input disabled value={category.id} />
</div>
<div className="flex items-end pb-2">
<Switch
checked={category.enabled}
id={`toolbox-category-enabled-${index}`}
onCheckedChange={(enabled) =>
onChange({
...value,
categories: value.categories.map((item, itemIndex) =>
itemIndex === index ? { ...item, enabled } : item,
),
})
}
/>
</div>
</div>
))}
</div>
</div>
<details className="rounded-md border">
<summary className="cursor-pointer list-none px-4 py-3 text-sm font-medium hover:bg-muted/40">
三节点展开布局
</summary>
<div className="space-y-4 border-t bg-muted/15 p-4">
<div className="grid gap-4 md:grid-cols-4">
{[
["groupWidth", "分组宽度"],
["groupHeight", "分组高度"],
["placementOffsetX", "视口中心偏移 X"],
["placementOffsetY", "视口中心偏移 Y"],
].map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`toolbox-layout-${key}`}>{label}</Label>
<Input
id={`toolbox-layout-${key}`}
onChange={(event) =>
onChange({
...value,
layout: {
...value.layout,
[key]: Number(event.target.value),
},
})
}
type="number"
value={value.layout[key as keyof Pick<VideoCanvasToolboxConfig["layout"], "groupWidth" | "groupHeight" | "placementOffsetX" | "placementOffsetY">]}
/>
</div>
))}
</div>
{(["reference", "prompt", "result"] as const).map((section) => (
<div className="grid gap-4 md:grid-cols-5" key={section}>
<div className="flex items-end pb-2 text-sm font-medium">
{section === "reference" ? "参考图" : section === "prompt" ? "提示词" : "成品"}
</div>
{(["x", "y", "width", "height"] as const).map((key) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`toolbox-layout-${section}-${key}`}>{key}</Label>
<Input
id={`toolbox-layout-${section}-${key}`}
onChange={(event) =>
onChange({
...value,
layout: {
...value.layout,
[section]: {
...value.layout[section],
[key]: Number(event.target.value),
},
},
})
}
type="number"
value={value.layout[section][key]}
/>
</div>
))}
</div>
))}
</div>
</details>
<div className="divide-y rounded-md border">
{value.templates.map((template, index) => (
<details className="group" key={template.id}>
<summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-3 hover:bg-muted/40">
<span className="w-7 shrink-0 text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{template.name}
</span>
<span className="max-w-40 truncate text-xs text-muted-foreground">
{value.categories.find((category) => category.id === template.categoryId)?.label || template.categoryId}
</span>
<span className="text-xs text-muted-foreground group-open:hidden">展开</span>
<span className="hidden text-xs text-muted-foreground group-open:inline">收起</span>
</summary>
<div className="space-y-4 border-t bg-muted/15 px-4 py-4">
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr_auto]">
<div className="space-y-2">
<Label htmlFor={`toolbox-template-name-${index}`}>模板名称</Label>
<Input
id={`toolbox-template-name-${index}`}
onChange={(event) => updateTemplate(index, { name: event.target.value })}
value={template.name}
/>
</div>
<div className="space-y-2">
<Label>所属分类</Label>
<Select
onValueChange={(categoryId) => updateTemplate(index, { categoryId })}
value={template.categoryId}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{value.categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={template.enabled}
id={`toolbox-template-enabled-${index}`}
onCheckedChange={(enabled) => updateTemplate(index, { enabled })}
/>
<Label htmlFor={`toolbox-template-enabled-${index}`}>显示</Label>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`toolbox-template-cover-${index}`}>卡片封面</Label>
<Input
id={`toolbox-template-cover-${index}`}
onChange={(event) => updateTemplate(index, { coverUrl: event.target.value })}
value={template.coverUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`toolbox-template-reference-${index}`}>参考图源</Label>
<Input
id={`toolbox-template-reference-${index}`}
onChange={(event) => updateTemplate(index, { referenceUrl: event.target.value })}
value={template.referenceUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`toolbox-template-result-${index}`}>成品视频源</Label>
<Input
id={`toolbox-template-result-${index}`}
onChange={(event) => updateTemplate(index, { resultUrl: event.target.value })}
value={template.resultUrl}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`toolbox-template-prompt-${index}`}>模板提示词</Label>
<Textarea
className="min-h-28"
id={`toolbox-template-prompt-${index}`}
onChange={(event) => updateTemplate(index, { prompt: event.target.value })}
value={template.prompt}
/>
</div>
<div className="flex justify-end gap-2">
<Button
aria-label={`上移 ${template.name}`}
disabled={index === 0}
onClick={() => moveTemplate(index, -1)}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft className="h-4 w-4 rotate-90" />
</Button>
<Button
aria-label={`下移 ${template.name}`}
disabled={index === value.templates.length - 1}
onClick={() => moveTemplate(index, 1)}
size="icon"
type="button"
variant="outline"
>
<ArrowLeft className="h-4 w-4 -rotate-90" />
</Button>
<Button
aria-label={`删除 ${template.name}`}
onClick={() =>
onChange({
...value,
templates: value.templates.filter((_, itemIndex) => itemIndex !== index),
})
}
size="icon"
type="button"
variant="destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</details>
))}
<div className="p-3">
<Button onClick={addTemplate} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加工具箱模板
</Button>
</div>
</div>
</div>
);
}
export function VideoCanvasSettingsClient() {
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
fetcher,
);
const { data: aiModelsData } = useSWR<AiModelsResponse>("/api/admin/ai-models", fetcher);
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
const [saving, setSaving] = useState(false);
const modelOptions = useMemo(
() => (aiModelsData?.models || []).filter((model) => model.isEnabled !== false),
[aiModelsData?.models],
);
useEffect(() => {
if (!data) return;
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
}, [data]);
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const compare = (left: string, right: string) =>
left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}),
[config.functions],
);
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
setConfig((current) => ({ ...current, ...updates }));
};
const updateNodeCreation = <
K extends keyof VideoCanvasFunctionConfig["nodeCreation"],
>(
section: K,
updates: Partial<VideoCanvasFunctionConfig["nodeCreation"][K]>,
) => {
setConfig((current) => ({
...current,
nodeCreation: {
...current.nodeCreation,
[section]: {
...current.nodeCreation[section],
...updates,
},
},
}));
};
const updateVideoAdvancedSettings = (
updates: Partial<VideoCanvasFunctionConfig["videoAdvancedSettings"]>,
) => {
setConfig((current) => ({
...current,
videoAdvancedSettings: {
...current.videoAdvancedSettings,
...updates,
},
}));
};
const updateVideoGenerationParameters = (
updates: Partial<VideoCanvasFunctionConfig["videoGenerationParameters"]>,
) => {
setConfig((current) => ({
...current,
videoGenerationParameters: {
...current.videoGenerationParameters,
...updates,
},
}));
};
const updateVideoReferenceDefaultRule = (
updates: Partial<VideoCanvasReferenceBehavior>,
) => {
setConfig((current) => ({
...current,
videoReferenceSelection: {
...current.videoReferenceSelection,
defaultRule: {
...current.videoReferenceSelection.defaultRule,
...updates,
},
},
}));
};
const updateVideoReferenceModelRule = (
index: number,
updates: Partial<VideoCanvasReferenceModelRule>,
) => {
setConfig((current) => ({
...current,
videoReferenceSelection: {
...current.videoReferenceSelection,
modelRules: current.videoReferenceSelection.modelRules.map((rule, ruleIndex) =>
ruleIndex === index ? { ...rule, ...updates } : rule,
),
},
}));
};
const addVideoReferenceModelRule = () => {
setConfig((current) => ({
...current,
videoReferenceSelection: {
...current.videoReferenceSelection,
modelRules: [
...current.videoReferenceSelection.modelRules,
{
...current.videoReferenceSelection.defaultRule,
modelPattern: "新模型",
},
],
},
}));
};
const removeVideoReferenceModelRule = (index: number) => {
setConfig((current) => ({
...current,
videoReferenceSelection: {
...current.videoReferenceSelection,
modelRules: current.videoReferenceSelection.modelRules.filter(
(_, ruleIndex) => ruleIndex !== index,
),
},
}));
};
const updateVideoDownload = (
updates: Partial<VideoCanvasFunctionConfig["videoDownload"]>,
) => {
setConfig((current) => ({
...current,
videoDownload: { ...current.videoDownload, ...updates },
}));
};
const updateVideoHd = (updates: Partial<VideoCanvasFunctionConfig["videoHd"]>) => {
setConfig((current) => ({
...current,
videoHd: { ...current.videoHd, ...updates },
}));
};
const updateVideoSubtitleErase = (
updates: Partial<VideoCanvasFunctionConfig["videoSubtitleErase"]>,
) => {
setConfig((current) => ({
...current,
videoSubtitleErase: { ...current.videoSubtitleErase, ...updates },
}));
};
const updateVideoAudioSeparation = (
updates: Partial<VideoCanvasFunctionConfig["videoAudioSeparation"]>,
) => {
setConfig((current) => ({
...current,
videoAudioSeparation: { ...current.videoAudioSeparation, ...updates },
}));
};
const updateVideoHdResolution = (
resolution: VideoHdResolution,
updates: Partial<VideoCanvasFunctionConfig["videoHd"]["resolutions"][VideoHdResolution]>,
) => {
setConfig((current) => ({
...current,
videoHd: {
...current.videoHd,
resolutions: {
...current.videoHd.resolutions,
[resolution]: {
...current.videoHd.resolutions[resolution],
...updates,
},
},
},
}));
};
const updateFunction = (
key: string,
updates: Partial<VideoCanvasFunctionDefinition>,
) => {
setConfig((current) => ({
...current,
functions: {
...current.functions,
[key]: {
...current.functions[key],
...updates,
},
},
}));
};
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const updateMotionPreset = (
index: number,
updates: Partial<VideoCanvasMotionPreset>,
) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addMotionPreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
motionPresets: [
...current.motionPresets,
{
id: `motion-${createdAt}`,
name: "新运镜预设",
previewUrl: current.effectPresets[0]?.thumbnailUrl || "",
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
},
],
}));
};
const removeMotionPreset = (index: number) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.filter(
(_, presetIndex) => presetIndex !== index,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
authorAvatarUrl: "",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: current.effectPresets[0]?.thumbnailUrl || "",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
searchKeywords: "新视频特效",
commercial: true,
featured: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateRolePreset = (
index: number,
updates: Partial<VideoCanvasRolePreset>,
) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addRolePreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
rolePresets: [
...current.rolePresets,
{
id: `role-${createdAt}`,
assetId: `public-role:role-${createdAt}`,
name: "新角色预设",
accent: "#525252",
description: "管理员配置的公共角色素材。",
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
tags: "角色",
compliant: true,
images: {
expression: "",
face: "",
full: "/video-canvas/effects/product-light-sweep.webp",
threeView: "",
},
filters: {
age: "青年",
build: "匀称",
culture: "东亚",
era: "现代",
gender: "中性",
hairColor: "黑发",
species: "人类",
},
},
],
}));
};
const removeRolePreset = (index: number) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateSubjectLibrary = (
updates: Partial<VideoCanvasFunctionConfig["subjectLibrary"]>,
) => {
setConfig((current) => ({
...current,
subjectLibrary: { ...current.subjectLibrary, ...updates },
}));
};
const updateSmartStoryboard = (
updates: Partial<VideoCanvasFunctionConfig["smartStoryboard"]>,
) => {
setConfig((current) => ({
...current,
smartStoryboard: { ...current.smartStoryboard, ...updates },
}));
};
const updateSubjectSmartDescription = (
updates: Partial<VideoCanvasFunctionConfig["subjectLibrary"]["smartDescription"]>,
) => {
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
smartDescription: {
...current.subjectLibrary.smartDescription,
...updates,
},
},
}));
};
const updateSubjectVideoUsage = (
updates: Partial<VideoCanvasFunctionConfig["subjectLibrary"]["videoUsage"]>,
) => {
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
videoUsage: {
...current.subjectLibrary.videoUsage,
...updates,
},
},
}));
};
const updateSubjectSmartCompletion = (
updates: Partial<VideoCanvasFunctionConfig["subjectLibrary"]["smartCompletion"]>,
) => {
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
smartCompletion: {
...current.subjectLibrary.smartCompletion,
...updates,
},
},
}));
};
const updateSubjectVoice = (
index: number,
updates: Partial<VideoCanvasSubjectVoicePreset>,
) => {
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
voices: current.subjectLibrary.voices.map((voice, voiceIndex) =>
voiceIndex === index ? { ...voice, ...updates } : voice,
),
},
}));
};
const addSubjectVoice = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
voices: [
...current.subjectLibrary.voices,
{
badge: "官方",
id: `subject-voice-${createdAt}`,
name: "新音色",
previewUrl: "",
},
],
},
}));
};
const removeSubjectVoice = (index: number) => {
setConfig((current) => ({
...current,
subjectLibrary: {
...current.subjectLibrary,
voices: current.subjectLibrary.voices.filter(
(_, voiceIndex) => voiceIndex !== index,
),
},
}));
};
const save = async () => {
setSaving(true);
try {
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
value: config,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "保存失败");
}
toast.success("视频画布功能配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
};
const resetToDefault = () => {
setConfig(defaultVideoCanvasFunctionConfig);
toast.info("已恢复默认配置,保存后生效");
};
return (
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<Button asChild className="mb-2" size="sm" variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词、默认模型和节点费用展示;真实扣费仍由后端任务接口统一校验。
</p>
</div>
<div className="flex gap-2">
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
恢复默认
</Button>
<Button disabled={saving || isLoading} onClick={save} type="button">
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">全局模型与开关</CardTitle>
<CardDescription>
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
<Switch
checked={config.enabled}
id="video-canvas-enabled"
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
<ModelSelect
kind="image-edit"
models={modelOptions}
onChange={(value) => updateRoot({ defaultImageModel: value || "auto" })}
value={config.defaultImageModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
<ModelSelect
kind="video"
models={modelOptions}
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
value={config.defaultVideoModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
<ModelSelect
kind="multimodal"
models={modelOptions}
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
value={config.defaultMultimodalModel}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">教程与联系入口</CardTitle>
<CardDescription>
配置画布底部教程菜单的跳转、客服 iframe 和两个运营二维码;不要把第三方客服账号或二维码写死在前端。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="video-canvas-tutorial-url">使用教程 URL</Label>
<Input
id="video-canvas-tutorial-url"
onChange={(event) =>
updateRoot({
tutorialSupport: {
...config.tutorialSupport,
tutorialUrl: event.target.value,
},
})
}
placeholder="https://example.com/video-canvas-guide"
value={config.tutorialSupport.tutorialUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-customer-service-url">
客服 iframe URL
</Label>
<Input
id="video-canvas-customer-service-url"
onChange={(event) =>
updateRoot({
tutorialSupport: {
...config.tutorialSupport,
customerServiceUrl: event.target.value,
},
})
}
placeholder="https://support.example.com/client"
value={config.tutorialSupport.customerServiceUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-sales-qr-url">
联系销售二维码 URL
</Label>
<Input
id="video-canvas-sales-qr-url"
onChange={(event) =>
updateRoot({
tutorialSupport: {
...config.tutorialSupport,
salesQrImageUrl: event.target.value,
},
})
}
placeholder="https://example.com/sales-qr.png"
value={config.tutorialSupport.salesQrImageUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-official-account-qr-url">
公众号二维码 URL
</Label>
<Input
id="video-canvas-official-account-qr-url"
onChange={(event) =>
updateRoot({
tutorialSupport: {
...config.tutorialSupport,
officialAccountQrImageUrl: event.target.value,
},
})
}
placeholder="https://example.com/official-account-qr.jpg"
value={config.tutorialSupport.officialAccountQrImageUrl}
/>
</div>
<p className="text-xs leading-5 text-muted-foreground md:col-span-2">
URL 支持 HTTP(S) 或同源绝对路径。客服未配置时仍显示同尺寸站内占位窗,二维码未配置时显示明确的后台配置提示。
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">历史资产</CardTitle>
<CardDescription>
历史面板读取当前账号的真实媒体资产;数量上限、默认缩放、预览播放和卡片标识由这里统一控制。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-history-enabled">启用历史记录</Label>
<Switch
checked={config.historyAssets.enabled}
id="video-canvas-history-enabled"
onCheckedChange={(enabled) =>
updateRoot({
historyAssets: { ...config.historyAssets, enabled },
})
}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-canvas-history-limit">最多加载记录</Label>
<Input
id="video-canvas-history-limit"
max={500}
min={1}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
maxItems: Number(event.target.value),
},
})
}
type="number"
value={config.historyAssets.maxItems}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-picker-page-size">选择器每页条数</Label>
<Input
id="video-canvas-history-picker-page-size"
max={50}
min={1}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
pageSize: Number(event.target.value),
},
},
})
}
type="number"
value={config.historyAssets.picker.pageSize}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-picker-max-selection">选择上限</Label>
<Input
id="video-canvas-history-picker-max-selection"
max={50}
min={1}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
maxSelection: Number(event.target.value),
},
},
})
}
type="number"
value={config.historyAssets.picker.maxSelection}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-picker-short-side">媒体节点短边</Label>
<Input
id="video-canvas-history-picker-short-side"
max={1000}
min={120}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
mediaShortSide: Number(event.target.value),
},
},
})
}
type="number"
value={config.historyAssets.picker.mediaShortSide}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-picker-gap">多选节点间距</Label>
<Input
id="video-canvas-history-picker-gap"
max={2000}
min={40}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
placementGap: Number(event.target.value),
},
},
})
}
type="number"
value={config.historyAssets.picker.placementGap}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-zoom">默认缩放</Label>
<Select
onValueChange={(value) =>
updateRoot({
historyAssets: {
...config.historyAssets,
defaultZoomPercent: Number(
value,
) as VideoCanvasHistoryZoomPercent,
},
})
}
value={String(config.historyAssets.defaultZoomPercent)}
>
<SelectTrigger id="video-canvas-history-zoom">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VIDEO_CANVAS_HISTORY_ZOOM_LEVELS.map((level) => (
<SelectItem key={level.percent} value={String(level.percent)}>
{level.percent}%
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-history-watermark-text">卡片标识</Label>
<Input
id="video-canvas-history-watermark-text"
maxLength={12}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
watermarkBadgeText: event.target.value,
},
})
}
value={config.historyAssets.watermarkBadgeText}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-canvas-history-autoplay">预览自动播放</Label>
<p className="text-sm text-muted-foreground">打开视频或音频预览后立即尝试播放。</p>
</div>
<Switch
checked={config.historyAssets.previewAutoplay}
id="video-canvas-history-autoplay"
onCheckedChange={(previewAutoplay) =>
updateRoot({
historyAssets: {
...config.historyAssets,
previewAutoplay,
},
})
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-canvas-history-watermark">显示卡片标识</Label>
<p className="text-sm text-muted-foreground">在图片历史缩略图左上角显示站点标识。</p>
</div>
<Switch
checked={config.historyAssets.showWatermarkBadge}
id="video-canvas-history-watermark"
onCheckedChange={(showWatermarkBadge) =>
updateRoot({
historyAssets: {
...config.historyAssets,
showWatermarkBadge,
},
})
}
/>
</div>
<div className="space-y-3 md:col-span-3">
<div>
<Label>选择器来源与媒体页签</Label>
<p className="mt-1 text-sm text-muted-foreground">来源顺序即前台顺序;“读取本站资产”决定该来源是否展示当前账号历史记录。</p>
</div>
<div className="space-y-2">
{config.historyAssets.picker.sources.map((source, sourceIndex) => (
<div
className="grid items-center gap-3 rounded-md border p-3 md:grid-cols-[minmax(150px,1fr)_auto_auto_minmax(300px,2fr)]"
key={source.id}
>
<Input
aria-label={`来源 ${sourceIndex + 1} 名称`}
onChange={(event) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
sources: config.historyAssets.picker.sources.map((item, index) =>
index === sourceIndex
? { ...item, label: event.target.value }
: item,
),
},
},
})
}
value={source.label}
/>
<div className="flex items-center gap-2">
<Label htmlFor={`history-picker-source-${source.id}-enabled`}>显示</Label>
<Switch
checked={source.enabled}
id={`history-picker-source-${source.id}-enabled`}
onCheckedChange={(enabled) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
sources: config.historyAssets.picker.sources.map((item, index) =>
index === sourceIndex ? { ...item, enabled } : item,
),
},
},
})
}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`history-picker-source-${source.id}-local`}>读取本站资产</Label>
<Switch
checked={source.usesLocalAssets}
id={`history-picker-source-${source.id}-local`}
onCheckedChange={(usesLocalAssets) =>
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
sources: config.historyAssets.picker.sources.map((item, index) =>
index === sourceIndex
? { ...item, usesLocalAssets }
: item,
),
},
},
})
}
/>
</div>
<div className="flex flex-wrap gap-2">
{VIDEO_CANVAS_HISTORY_PICKER_MEDIA_MODES.map((mode) => {
const active = source.mediaModes.includes(mode);
return (
<button
aria-pressed={active}
className={active ? "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground" : "rounded-md border px-3 py-1.5 text-xs text-muted-foreground"}
key={mode}
onClick={() => {
const nextModes = active
? source.mediaModes.filter((item) => item !== mode)
: [...source.mediaModes, mode];
if (nextModes.length === 0) return;
updateRoot({
historyAssets: {
...config.historyAssets,
picker: {
...config.historyAssets.picker,
sources: config.historyAssets.picker.sources.map((item, index) =>
index === sourceIndex
? {
...item,
mediaModes: nextModes as VideoCanvasHistoryPickerMediaMode[],
}
: item,
),
},
},
});
}}
type="button"
>
{VIDEO_CANVAS_HISTORY_PICKER_MEDIA_LABELS[mode]}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">工具箱工作流模板</CardTitle>
<CardDescription>
配置工具箱分类、30 项模板封面、参考素材、成品素材、提示词和三节点展开布局;画布不再维护独立硬编码目录。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-toolbox-enabled">启用工具箱</Label>
<Switch
checked={config.toolbox.enabled}
id="video-canvas-toolbox-enabled"
onCheckedChange={(enabled) =>
updateRoot({ toolbox: { ...config.toolbox, enabled } })
}
/>
</div>
</CardHeader>
<CardContent>
<ToolboxWorkflowEditor
onChange={(toolbox) => updateRoot({ toolbox })}
value={config.toolbox}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">新建节点默认值</CardTitle>
<CardDescription>
控制“添加节点”创建的初始尺寸、图片参数、视频模型、资源上传与音频调节展开状态;节点创建后保存的选择优先。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className="space-y-4 border-b pb-6">
<div>
<h3 className="text-sm font-medium">图片节点</h3>
<p className="mt-1 text-xs text-muted-foreground">用于新建空白图片生成节点的卡片与参数摘要。</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="node-create-image-width">节点宽度</Label>
<Input
id="node-create-image-width"
min={240}
onChange={(event) =>
updateNodeCreation("image", { width: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.image.width}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-image-height">节点高度</Label>
<Input
id="node-create-image-height"
min={240}
onChange={(event) =>
updateNodeCreation("image", { height: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.image.height}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-image-model-label">模型显示名</Label>
<Input
id="node-create-image-model-label"
onChange={(event) =>
updateNodeCreation("image", { modelLabel: event.target.value })
}
value={config.nodeCreation.image.modelLabel}
/>
</div>
<div className="space-y-2">
<Label>默认比例</Label>
<Select
onValueChange={(ratio) => updateNodeCreation("image", { ratio })}
value={config.nodeCreation.image.ratio}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.ratioOptions.map((ratio) => (
<SelectItem key={`node-image-ratio-${ratio}`} value={ratio}>{ratio}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-image-quality">默认画质</Label>
<Input
id="node-create-image-quality"
onChange={(event) =>
updateNodeCreation("image", { quality: event.target.value })
}
value={config.nodeCreation.image.quality}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-image-size">默认清晰度</Label>
<Input
id="node-create-image-size"
onChange={(event) =>
updateNodeCreation("image", { size: event.target.value })
}
value={config.nodeCreation.image.size}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-image-count">默认张数</Label>
<Input
id="node-create-image-count"
max={4}
min={1}
onChange={(event) =>
updateNodeCreation("image", { count: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.image.count}
/>
</div>
</div>
</section>
<section className="space-y-4 border-b pb-6">
<div>
<h3 className="text-sm font-medium">视频节点</h3>
<p className="mt-1 text-xs text-muted-foreground">默认模型引用下方视频模型目录中的条目 ID。</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="node-create-video-width">节点宽度</Label>
<Input
id="node-create-video-width"
min={240}
onChange={(event) =>
updateNodeCreation("video", { width: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.video.width}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-video-height">节点高度</Label>
<Input
id="node-create-video-height"
min={240}
onChange={(event) =>
updateNodeCreation("video", { height: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.video.height}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>默认视频模型</Label>
<Select
onValueChange={(modelCatalogId) =>
updateNodeCreation("video", { modelCatalogId })
}
value={config.nodeCreation.video.modelCatalogId}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoModelCatalog.map((entry) => (
<SelectItem key={`node-video-model-${entry.id}`} value={entry.id}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>默认比例</Label>
<Select
onValueChange={(ratio) => updateNodeCreation("video", { ratio })}
value={config.nodeCreation.video.ratio}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.ratioOptions.map((ratio) => (
<SelectItem key={`node-video-ratio-${ratio}`} value={ratio}>{ratio}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>默认清晰度</Label>
<Select
onValueChange={(resolution) =>
updateNodeCreation("video", { resolution })
}
value={config.nodeCreation.video.resolution}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.resolutionOptions.map((resolution) => (
<SelectItem key={`node-video-resolution-${resolution}`} value={resolution}>
{resolution}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-video-duration">默认时长</Label>
<Input
id="node-create-video-duration"
min={1}
onChange={(event) =>
updateNodeCreation("video", {
durationSeconds: Number(event.target.value),
})
}
type="number"
value={config.nodeCreation.video.durationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-video-count">默认数量</Label>
<Input
id="node-create-video-count"
max={4}
min={1}
onChange={(event) =>
updateNodeCreation("video", { count: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.video.count}
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="node-create-video-audio">默认生成音频</Label>
<p className="text-sm text-muted-foreground">写入新视频节点的独立参数,之后可在节点中修改。</p>
</div>
<Switch
checked={config.nodeCreation.video.generateAudio}
id="node-create-video-audio"
onCheckedChange={(generateAudio) =>
updateNodeCreation("video", { generateAudio })
}
/>
</div>
</section>
<section className="space-y-4 border-b pb-6">
<div>
<h3 className="text-sm font-medium">资源上传</h3>
<p className="mt-1 text-xs text-muted-foreground">主菜单上传会先写入真实存储和当前账号历史资产,再创建可持久化节点。</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="node-create-upload-accept">允许的文件类型</Label>
<Input
id="node-create-upload-accept"
onChange={(event) =>
updateNodeCreation("upload", { accept: event.target.value })
}
value={config.nodeCreation.upload.accept}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-upload-max-files">单次最多文件</Label>
<Input
id="node-create-upload-max-files"
max={50}
min={1}
onChange={(event) =>
updateNodeCreation("upload", { maxFiles: Number(event.target.value) })
}
type="number"
value={config.nodeCreation.upload.maxFiles}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-upload-max-size">单文件上限(MB)</Label>
<Input
id="node-create-upload-max-size"
max={500}
min={1}
onChange={(event) =>
updateNodeCreation("upload", {
maxFileSizeMb: Number(event.target.value),
})
}
type="number"
value={config.nodeCreation.upload.maxFileSizeMb}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-create-upload-placement-gap">多文件节点间距</Label>
<Input
id="node-create-upload-placement-gap"
max={2000}
min={40}
onChange={(event) =>
updateNodeCreation("upload", {
placementGap: Number(event.target.value),
})
}
type="number"
value={config.nodeCreation.upload.placementGap}
/>
</div>
</div>
</section>
<section className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="node-create-audio-settings">音频节点默认展开调节区</Label>
<p className="text-sm text-muted-foreground">创建后直接显示语速、声调、音量、音色效果和音效。</p>
</div>
<Switch
checked={config.nodeCreation.audio.advancedSettingsOpen}
id="node-create-audio-settings"
onCheckedChange={(advancedSettingsOpen) =>
updateNodeCreation("audio", { advancedSettingsOpen })
}
/>
</section>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">视频模型选择器</CardTitle>
<CardDescription>
菜单顺序、短名称、活动标识、预计耗时、积分和实际后端模型均由这里配置。选择“自动”时使用当前功能或全局默认视频模型。
</CardDescription>
</CardHeader>
<CardContent>
<VideoModelCatalogEditor
models={modelOptions}
onChange={(videoModelCatalog) => updateRoot({ videoModelCatalog })}
value={config.videoModelCatalog}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">视频高级设置默认值</CardTitle>
<CardDescription>
新节点和没有保存过对应字段的旧节点使用这里的默认值;用户在画布中的切换结果按节点单独保存。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-default-search-enabled">默认启用联网搜索</Label>
<p className="text-muted-foreground text-sm">随视频生成请求传递模型联网搜索偏好。</p>
</div>
<Switch
checked={config.videoAdvancedSettings.defaultSearchEnabled}
id="video-default-search-enabled"
onCheckedChange={(defaultSearchEnabled) =>
updateVideoAdvancedSettings({ defaultSearchEnabled })
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-default-auto-validate-material">
默认自动校验素材
</Label>
<p className="text-muted-foreground text-sm">随请求传递业务素材自动校验偏好。</p>
</div>
<Switch
checked={config.videoAdvancedSettings.defaultAutoValidateMaterial}
id="video-default-auto-validate-material"
onCheckedChange={(defaultAutoValidateMaterial) =>
updateVideoAdvancedSettings({ defaultAutoValidateMaterial })
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">视频生成参数面板</CardTitle>
<CardDescription>
配置节点参数选项、默认值和按清晰度计算的每秒积分;节点已保存的选择优先于这里的默认值。
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-parameter-ratios">比例选项</Label>
<Input
id="video-parameter-ratios"
onChange={(event) =>
updateVideoGenerationParameters({
ratioOptions: event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
})
}
value={config.videoGenerationParameters.ratioOptions.join(", ")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-parameter-resolutions">清晰度选项</Label>
<Input
id="video-parameter-resolutions"
onChange={(event) =>
updateVideoGenerationParameters({
resolutionOptions: event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
})
}
value={config.videoGenerationParameters.resolutionOptions.join(", ")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-parameter-counts">生成数量选项</Label>
<Input
id="video-parameter-counts"
onChange={(event) =>
updateVideoGenerationParameters({
countOptions: event.target.value
.split(",")
.map((item) => Number(item.trim()))
.filter(Number.isFinite),
})
}
value={config.videoGenerationParameters.countOptions.join(", ")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>默认比例</Label>
<Select
onValueChange={(defaultRatio) =>
updateVideoGenerationParameters({ defaultRatio })
}
value={config.videoGenerationParameters.defaultRatio}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.ratioOptions.map((ratio) => (
<SelectItem key={ratio} value={ratio}>{ratio}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>默认清晰度</Label>
<Select
onValueChange={(defaultResolution) =>
updateVideoGenerationParameters({ defaultResolution })
}
value={config.videoGenerationParameters.defaultResolution}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.resolutionOptions.map((resolution) => (
<SelectItem key={resolution} value={resolution}>{resolution}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>默认生成数量</Label>
<Select
onValueChange={(value) =>
updateVideoGenerationParameters({ defaultCount: Number(value) })
}
value={String(config.videoGenerationParameters.defaultCount)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{config.videoGenerationParameters.countOptions.map((count) => (
<SelectItem key={count} value={String(count)}>{count} 个</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
{[
["minDurationSeconds", "最短时长"],
["maxDurationSeconds", "最长时长"],
["durationStepSeconds", "时长步长"],
["defaultDurationSeconds", "默认时长"],
].map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`video-parameter-${key}`}>{label}(秒)</Label>
<Input
id={`video-parameter-${key}`}
min={1}
onChange={(event) =>
updateVideoGenerationParameters({
[key]: Number(event.target.value),
})
}
type="number"
value={config.videoGenerationParameters[
key as keyof Pick<
VideoCanvasFunctionConfig["videoGenerationParameters"],
| "minDurationSeconds"
| "maxDurationSeconds"
| "durationStepSeconds"
| "defaultDurationSeconds"
>
] as number}
/>
</div>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-parameter-default-audio">默认生成音频</Label>
<p className="text-muted-foreground text-sm">模型支持音频输出时随生成请求传递。</p>
</div>
<Switch
checked={config.videoGenerationParameters.defaultGenerateAudio}
id="video-parameter-default-audio"
onCheckedChange={(defaultGenerateAudio) =>
updateVideoGenerationParameters({ defaultGenerateAudio })
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="video-parameter-pricing-enabled">启用参数计价</Label>
<p className="text-muted-foreground text-sm">按清晰度每秒积分 × 时长 × 数量展示预估价格。</p>
</div>
<Switch
checked={config.videoGenerationParameters.parameterPricingEnabled}
id="video-parameter-pricing-enabled"
onCheckedChange={(parameterPricingEnabled) =>
updateVideoGenerationParameters({ parameterPricingEnabled })
}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
{config.videoGenerationParameters.resolutionOptions.map((resolution) => (
<div className="space-y-2" key={resolution}>
<Label htmlFor={`video-parameter-price-${resolution}`}>
{resolution} 每秒积分
</Label>
<Input
id={`video-parameter-price-${resolution}`}
min={0}
onChange={(event) =>
updateVideoGenerationParameters({
pointsPerSecondByResolution: {
...config.videoGenerationParameters.pointsPerSecondByResolution,
[resolution]: Number(event.target.value),
},
})
}
type="number"
value={
config.videoGenerationParameters.pointsPerSecondByResolution[resolution] || 0
}
/>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">视频参考素材规则</CardTitle>
<CardDescription>
按模型名称匹配参考图上限、生成模式和选图后的退出行为;模型规则优先于默认规则。
</CardDescription>
</div>
<Button onClick={addVideoReferenceModelRule} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-3.5 w-3.5" />
添加模型规则
</Button>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 border-b pb-5 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="video-reference-default-max">默认图片上限</Label>
<Input
id="video-reference-default-max"
max={20}
min={1}
onChange={(event) =>
updateVideoReferenceDefaultRule({ maxImages: Number(event.target.value) })
}
type="number"
value={config.videoReferenceSelection.defaultRule.maxImages}
/>
</div>
<div className="space-y-2">
<Label>默认生成模式</Label>
<Select
onValueChange={(mode) =>
updateVideoReferenceDefaultRule({
mode: mode as VideoCanvasReferenceBehavior["mode"],
})
}
value={config.videoReferenceSelection.defaultRule.mode}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="reference-images">全能参考</SelectItem>
<SelectItem value="first-frame">图生视频</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-3 pb-2">
<Switch
checked={config.videoReferenceSelection.defaultRule.autoExit}
id="video-reference-default-auto-exit"
onCheckedChange={(autoExit) => updateVideoReferenceDefaultRule({ autoExit })}
/>
<Label htmlFor="video-reference-default-auto-exit">选图后退出</Label>
</div>
<div className="flex items-end gap-3 pb-2">
<Switch
checked={config.videoReferenceSelection.defaultRule.hideActionAtLimit}
id="video-reference-default-hide-limit"
onCheckedChange={(hideActionAtLimit) =>
updateVideoReferenceDefaultRule({ hideActionAtLimit })
}
/>
<Label htmlFor="video-reference-default-hide-limit">满额隐藏入口</Label>
</div>
</div>
<div className="space-y-3">
{config.videoReferenceSelection.modelRules.map((rule, index) => (
<div
className="grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(180px,1.4fr)_110px_150px_150px_170px_36px] md:items-end"
key={`${rule.modelPattern}-${index}`}
>
<div className="space-y-2">
<Label htmlFor={`video-reference-model-${index}`}>模型名称匹配</Label>
<Input
id={`video-reference-model-${index}`}
onChange={(event) =>
updateVideoReferenceModelRule(index, {
modelPattern: event.target.value,
})
}
value={rule.modelPattern}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-reference-max-${index}`}>图片上限</Label>
<Input
id={`video-reference-max-${index}`}
max={20}
min={1}
onChange={(event) =>
updateVideoReferenceModelRule(index, {
maxImages: Number(event.target.value),
})
}
type="number"
value={rule.maxImages}
/>
</div>
<div className="space-y-2">
<Label>生成模式</Label>
<Select
onValueChange={(mode) =>
updateVideoReferenceModelRule(index, {
mode: mode as VideoCanvasReferenceModelRule["mode"],
})
}
value={rule.mode}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="reference-images">全能参考</SelectItem>
<SelectItem value="first-frame">图生视频</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3 pb-2">
<Switch
checked={rule.autoExit}
id={`video-reference-auto-exit-${index}`}
onCheckedChange={(autoExit) =>
updateVideoReferenceModelRule(index, { autoExit })
}
/>
<Label htmlFor={`video-reference-auto-exit-${index}`}>选图后退出</Label>
</div>
<div className="flex items-center gap-3 pb-2">
<Switch
checked={rule.hideActionAtLimit}
id={`video-reference-hide-limit-${index}`}
onCheckedChange={(hideActionAtLimit) =>
updateVideoReferenceModelRule(index, { hideActionAtLimit })
}
/>
<Label htmlFor={`video-reference-hide-limit-${index}`}>满额隐藏入口</Label>
</div>
<Button
aria-label={`删除参考规则 ${index + 1}`}
onClick={() => removeVideoReferenceModelRule(index)}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">视频下载与水印规则</CardTitle>
<CardDescription>
配置首次下载弹窗文案和规范链接;文案中的 {"{{siteName}}"} 会替换为当前站点名称。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="video-download-rules-title">规则标题</Label>
<Input
id="video-download-rules-title"
onChange={(event) => updateVideoDownload({ rulesTitle: event.target.value })}
value={config.videoDownload.rulesTitle}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-download-rules-url">水印规范链接</Label>
<Input
id="video-download-rules-url"
onChange={(event) => updateVideoDownload({ rulesUrl: event.target.value })}
value={config.videoDownload.rulesUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-download-rules-link-label">规范链接文案</Label>
<Input
id="video-download-rules-link-label"
onChange={(event) => updateVideoDownload({ rulesLinkLabel: event.target.value })}
value={config.videoDownload.rulesLinkLabel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-download-welcome-text">欢迎文案</Label>
<Input
id="video-download-welcome-text"
onChange={(event) => updateVideoDownload({ welcomeText: event.target.value })}
value={config.videoDownload.welcomeText}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-download-application-text">申请说明</Label>
<Textarea
id="video-download-application-text"
onChange={(event) => updateVideoDownload({ applicationText: event.target.value })}
value={config.videoDownload.applicationText}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-download-responsibility-text">责任说明</Label>
<Textarea
id="video-download-responsibility-text"
onChange={(event) => updateVideoDownload({ responsibilityText: event.target.value })}
value={config.videoDownload.responsibilityText}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-download-acknowledgement-text">确认说明</Label>
<Textarea
id="video-download-acknowledgement-text"
onChange={(event) => updateVideoDownload({ acknowledgementText: event.target.value })}
value={config.videoDownload.acknowledgementText}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">视频音频分离</CardTitle>
<CardDescription>
配置真实音视频拆轨及本地中置声道人声/背景音后备算法。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-audio-separation-enabled">启用视频音频分离</Label>
<Switch
checked={config.videoAudioSeparation.enabled}
id="video-audio-separation-enabled"
onCheckedChange={(enabled) => updateVideoAudioSeparation({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-4">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-audio-separation-model">后备算法标识</Label>
<Input
id="video-audio-separation-model"
onChange={(event) => updateVideoAudioSeparation({ model: event.target.value })}
value={config.videoAudioSeparation.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-audio-separation-voice-gain">人声增益</Label>
<Input
id="video-audio-separation-voice-gain"
max={4}
min={0}
onChange={(event) => updateVideoAudioSeparation({ voiceGain: Number(event.target.value) })}
step={0.1}
type="number"
value={config.videoAudioSeparation.voiceGain}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-audio-separation-background-gain">背景音增益</Label>
<Input
id="video-audio-separation-background-gain"
max={4}
min={0}
onChange={(event) => updateVideoAudioSeparation({ backgroundGain: Number(event.target.value) })}
step={0.1}
type="number"
value={config.videoAudioSeparation.backgroundGain}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-audio-separation-frame-rate">无声视频帧率</Label>
<Input
id="video-audio-separation-frame-rate"
max={60}
min={12}
onChange={(event) => updateVideoAudioSeparation({ frameRate: Number(event.target.value) })}
type="number"
value={config.videoAudioSeparation.frameRate}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">智能去字幕</CardTitle>
<CardDescription>
配置智能字幕带检测范围、边缘阈值、区域扩边和逐帧修复参数。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-subtitle-erase-enabled">启用智能去字幕</Label>
<Switch
checked={config.videoSubtitleErase.enabled}
id="video-subtitle-erase-enabled"
onCheckedChange={(enabled) => updateVideoSubtitleErase({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-4">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-subtitle-erase-model">模型标识</Label>
<Input
id="video-subtitle-erase-model"
onChange={(event) => updateVideoSubtitleErase({ model: event.target.value })}
value={config.videoSubtitleErase.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-start">扫描起点(%)</Label>
<Input
id="video-subtitle-erase-start"
max={94}
min={0}
onChange={(event) => updateVideoSubtitleErase({ smartSearchStartRatio: Number(event.target.value) / 100 })}
type="number"
value={Math.round(config.videoSubtitleErase.smartSearchStartRatio * 100)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-end">扫描终点(%)</Label>
<Input
id="video-subtitle-erase-end"
max={100}
min={4}
onChange={(event) => updateVideoSubtitleErase({ smartSearchEndRatio: Number(event.target.value) / 100 })}
type="number"
value={Math.round(config.videoSubtitleErase.smartSearchEndRatio * 100)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-edge">边缘阈值</Label>
<Input
id="video-subtitle-erase-edge"
max={255}
min={1}
onChange={(event) => updateVideoSubtitleErase({ edgeThreshold: Number(event.target.value) })}
type="number"
value={config.videoSubtitleErase.edgeThreshold}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-min-band">最小文字带(%)</Label>
<Input
id="video-subtitle-erase-min-band"
max={20}
min={0.5}
onChange={(event) => updateVideoSubtitleErase({ minBandHeightRatio: Number(event.target.value) / 100 })}
step={0.1}
type="number"
value={Number((config.videoSubtitleErase.minBandHeightRatio * 100).toFixed(1))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-padding">区域扩边(%)</Label>
<Input
id="video-subtitle-erase-padding"
max={10}
min={0}
onChange={(event) => updateVideoSubtitleErase({ paddingRatio: Number(event.target.value) / 100 })}
step={0.1}
type="number"
value={Number((config.videoSubtitleErase.paddingRatio * 100).toFixed(1))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-blur">修复模糊半径</Label>
<Input
id="video-subtitle-erase-blur"
max={40}
min={0}
onChange={(event) => updateVideoSubtitleErase({ blurRadius: Number(event.target.value) })}
type="number"
value={config.videoSubtitleErase.blurRadius}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-subtitle-erase-frame-rate">输出帧率</Label>
<Input
id="video-subtitle-erase-frame-rate"
max={60}
min={12}
onChange={(event) => updateVideoSubtitleErase({ frameRate: Number(event.target.value) })}
type="number"
value={config.videoSubtitleErase.frameRate}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">视频高清</CardTitle>
<CardDescription>
配置高清视频节点的模型名称、输出档位、积分显示、补帧帧率和慢放倍数。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-video-hd-enabled">启用视频高清</Label>
<Switch
checked={config.videoHd.enabled}
id="video-canvas-video-hd-enabled"
onCheckedChange={(enabled) => updateVideoHd({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-4">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-hd-model">模型显示名称</Label>
<Input
id="video-hd-model"
onChange={(event) => updateVideoHd({ model: event.target.value })}
value={config.videoHd.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-hd-setup-cost">固定任务费用</Label>
<Input
id="video-hd-setup-cost"
min={0}
onChange={(event) => updateVideoHd({ setupCost: Number(event.target.value) || 0 })}
type="number"
value={config.videoHd.setupCost}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-hd-frame-rates">补帧帧率</Label>
<Input
id="video-hd-frame-rates"
onChange={(event) => updateVideoHd({
frameRates: event.target.value
.split(/[,,\s]+/)
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0),
})}
value={config.videoHd.frameRates.join(", ")}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-hd-slow-multipliers">慢放倍数</Label>
<Input
id="video-hd-slow-multipliers"
onChange={(event) => updateVideoHd({
slowMultipliers: event.target.value
.split(/[,,\s]+/)
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0),
})}
value={config.videoHd.slowMultipliers.join(", ")}
/>
</div>
</div>
<div className="space-y-3">
{(["1080P", "2K", "4K"] as VideoHdResolution[]).map((resolution) => {
const item = config.videoHd.resolutions[resolution];
return (
<div className="grid gap-3 rounded-md border p-3 md:grid-cols-[100px_repeat(4,minmax(0,1fr))]" key={resolution}>
<div className="flex items-end pb-2 text-sm font-medium">{resolution}</div>
<div className="space-y-2">
<Label htmlFor={`video-hd-${resolution}-width`}>宽度</Label>
<Input
id={`video-hd-${resolution}-width`}
min={2}
onChange={(event) => updateVideoHdResolution(resolution, { width: Number(event.target.value) || 2 })}
type="number"
value={item.width}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-hd-${resolution}-height`}>高度</Label>
<Input
id={`video-hd-${resolution}-height`}
min={2}
onChange={(event) => updateVideoHdResolution(resolution, { height: Number(event.target.value) || 2 })}
type="number"
value={item.height}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-hd-${resolution}-base-cost`}>基础费用</Label>
<Input
id={`video-hd-${resolution}-base-cost`}
min={0}
onChange={(event) => updateVideoHdResolution(resolution, { baseCost: Number(event.target.value) || 0 })}
type="number"
value={item.baseCost}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`video-hd-${resolution}-interpolation-cost`}>补帧附加费用</Label>
<Input
id={`video-hd-${resolution}-interpolation-cost`}
min={0}
onChange={(event) => updateVideoHdResolution(resolution, { interpolationExtraCost: Number(event.target.value) || 0 })}
type="number"
value={item.interpolationExtraCost}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">3D导演台</CardTitle>
<CardDescription>
控制画布导演台是否启用以及加载哪个同源子应用。地址必须以单个 / 开头。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-director-desk-enabled">启用3D导演台</Label>
<Switch
checked={config.directorDesk.enabled}
id="video-canvas-director-desk-enabled"
onCheckedChange={(checked) =>
updateRoot({
directorDesk: { ...config.directorDesk, enabled: checked },
})
}
/>
</div>
</CardHeader>
<CardContent className="space-y-5">
<div className="max-w-2xl space-y-2">
<Label htmlFor="video-canvas-director-desk-url">同源嵌入地址</Label>
<Input
id="video-canvas-director-desk-url"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
embedUrl: event.target.value,
},
})
}
placeholder="/vendor/storyai-director/index.html?theme=dark"
value={config.directorDesk.embedUrl}
/>
<p className="text-xs leading-5 text-muted-foreground">
当前内置版本固定自 StoryAI 3D Director Desk,宿主桥负责场景隔离、全景图连接和截图回写。
</p>
</div>
<div className="max-w-2xl space-y-2">
<Label htmlFor="video-canvas-director-desk-help-url">帮助文档地址</Label>
<Input
id="video-canvas-director-desk-help-url"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
helpUrl: event.target.value,
},
})
}
placeholder="https://resonate.feishu.cn/wiki/..."
value={config.directorDesk.helpUrl}
/>
<p className="text-xs leading-5 text-muted-foreground">
点击导演台右上角“帮助”时在新标签页打开;支持 HTTP(S) 地址或同源路径。
</p>
</div>
<div className="max-w-2xl space-y-2">
<Label htmlFor="video-canvas-director-default-aspect-ratio">
新导演台默认画幅
</Label>
<Select
onValueChange={(defaultAspectRatio) =>
updateRoot({
directorDesk: {
...config.directorDesk,
defaultAspectRatio:
defaultAspectRatio as VideoCanvasFunctionConfig["directorDesk"]["defaultAspectRatio"],
},
})
}
value={config.directorDesk.defaultAspectRatio}
>
<SelectTrigger id="video-canvas-director-default-aspect-ratio">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="21:9">21:9</SelectItem>
<SelectItem value="16:9">16:9</SelectItem>
<SelectItem value="4:3">4:3</SelectItem>
<SelectItem value="1:1">1:1</SelectItem>
<SelectItem value="3:4">3:4</SelectItem>
<SelectItem value="9:16">9:16</SelectItem>
</SelectContent>
</Select>
<p className="text-xs leading-5 text-muted-foreground">
仅用于尚未保存画幅的新导演台;项目内切换后的画幅会随节点保存并优先恢复。
</p>
</div>
<div className="flex max-w-2xl items-center justify-between gap-4 border-t pt-4">
<div className="space-y-1">
<Label htmlFor="video-canvas-director-desk-batch-capture">
显示四方位/十二方位截图
</Label>
<p className="text-xs leading-5 text-muted-foreground">
竞品默认只显示当前视角截图;开启后显示导演台扩展的批量环绕截图入口。
</p>
</div>
<Switch
checked={config.directorDesk.showBatchCaptureControls}
id="video-canvas-director-desk-batch-capture"
onCheckedChange={(checked) =>
updateRoot({
directorDesk: {
...config.directorDesk,
showBatchCaptureControls: checked,
},
})
}
/>
</div>
<div className="space-y-4 border-t pt-4">
<div className="flex max-w-3xl items-start justify-between gap-4">
<div>
<h3 className="text-sm font-medium">AI 识图导入</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
配置导演台识图弹窗、远程多模态费用和可选的同源 SAM 分割服务;模型与提示词由下方同名功能配置控制。
</p>
</div>
<Switch
checked={config.directorDesk.aiImport.enabled}
id="video-canvas-director-ai-import-enabled"
onCheckedChange={(checked) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
enabled: checked,
},
},
})
}
/>
</div>
<div className="grid max-w-3xl gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-canvas-director-ai-import-cost">远程识图费用</Label>
<Input
id="video-canvas-director-ai-import-cost"
min={0}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
cost: Number(event.target.value),
},
},
})
}
type="number"
value={config.directorDesk.aiImport.cost}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-ai-import-history">历史记录上限</Label>
<Input
id="video-canvas-director-ai-import-history"
min={1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
historyLimit: Number(event.target.value),
},
},
})
}
type="number"
value={config.directorDesk.aiImport.historyLimit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-ai-import-accept">允许上传格式</Label>
<Input
id="video-canvas-director-ai-import-accept"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
uploadAccept: event.target.value,
},
},
})
}
value={config.directorDesk.aiImport.uploadAccept}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-ai-import-function">远程功能键</Label>
<Input
id="video-canvas-director-ai-import-function"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
functionKey: event.target.value,
},
},
})
}
value={config.directorDesk.aiImport.functionKey}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="video-canvas-director-ai-import-sam">本地 SAM 同源地址</Label>
<Input
id="video-canvas-director-ai-import-sam"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
aiImport: {
...config.directorDesk.aiImport,
localSamEndpoint: event.target.value,
},
},
})
}
placeholder="/api/local-sam/person-mask(留空时使用浏览器前景分割)"
value={config.directorDesk.aiImport.localSamEndpoint}
/>
</div>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div>
<h3 className="text-sm font-medium">群众阵列</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
配置“群众”悬浮面板首次打开时使用的默认行数、列数和人物间距。
</p>
</div>
<div className="grid max-w-3xl gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-canvas-director-crowd-rows">默认行数</Label>
<Input
id="video-canvas-director-crowd-rows"
min={1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
crowd: {
...config.directorDesk.crowd,
defaultRows: Number(event.target.value),
},
},
})
}
step={1}
type="number"
value={config.directorDesk.crowd.defaultRows}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-crowd-columns">默认列数</Label>
<Input
id="video-canvas-director-crowd-columns"
min={1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
crowd: {
...config.directorDesk.crowd,
defaultColumns: Number(event.target.value),
},
},
})
}
step={1}
type="number"
value={config.directorDesk.crowd.defaultColumns}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-crowd-spacing">默认间距</Label>
<Input
id="video-canvas-director-crowd-spacing"
min={0.1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
crowd: {
...config.directorDesk.crowd,
defaultSpacing: Number(event.target.value),
},
},
})
}
step={0.1}
type="number"
value={config.directorDesk.crowd.defaultSpacing}
/>
</div>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div>
<h3 className="text-sm font-medium">高斯泼溅</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
配置导演台高斯场景的上传格式、单文件上限与地面射线命中透明度。
</p>
</div>
<div className="grid max-w-3xl gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-canvas-director-gaussian-accept">允许上传格式</Label>
<Input
id="video-canvas-director-gaussian-accept"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
gaussian: {
...config.directorDesk.gaussian,
uploadAccept: event.target.value,
},
},
})
}
value={config.directorDesk.gaussian.uploadAccept}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-gaussian-max-size">单文件上限(MB)</Label>
<Input
id="video-canvas-director-gaussian-max-size"
max={500}
min={1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
gaussian: {
...config.directorDesk.gaussian,
maxFileSizeMb: Number(event.target.value),
},
},
})
}
type="number"
value={config.directorDesk.gaussian.maxFileSizeMb}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-gaussian-raycast-opacity">射线最小透明度</Label>
<Input
id="video-canvas-director-gaussian-raycast-opacity"
max={1}
min={0}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
gaussian: {
...config.directorDesk.gaussian,
minRaycastOpacity: Number(event.target.value),
},
},
})
}
step={0.05}
type="number"
value={config.directorDesk.gaussian.minRaycastOpacity}
/>
</div>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div>
<h3 className="text-sm font-medium">全景图入口</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
导演台从这里读取上传格式、AI 生成费用显示、预处理时间与历史数量;模型和提示词由下方的“导演台 AI 全景图”功能配置控制。
</p>
</div>
<div className="grid max-w-3xl gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-accept">允许上传格式</Label>
<Input
id="video-canvas-director-panorama-accept"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
uploadAccept: event.target.value,
},
},
})
}
placeholder=".png"
value={config.directorDesk.panorama.uploadAccept}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-cost">AI 生成费用显示</Label>
<Input
id="video-canvas-director-panorama-cost"
min={0}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
aiCost: Number(event.target.value),
},
},
})
}
type="number"
value={config.directorDesk.panorama.aiCost}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-delay">AI 预处理毫秒</Label>
<Input
id="video-canvas-director-panorama-delay"
min={0}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
aiPreprocessDelayMs: Number(event.target.value),
},
},
})
}
step={100}
type="number"
value={config.directorDesk.panorama.aiPreprocessDelayMs}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-history">历史记录上限</Label>
<Input
id="video-canvas-director-panorama-history"
min={1}
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
historyLimit: Number(event.target.value),
},
},
})
}
type="number"
value={config.directorDesk.panorama.historyLimit}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-ratio">AI 全景比例</Label>
<Input
id="video-canvas-director-panorama-ratio"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
aiAspectRatio: event.target.value,
},
},
})
}
value={config.directorDesk.panorama.aiAspectRatio}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-canvas-director-panorama-size">AI 图像尺寸</Label>
<Input
id="video-canvas-director-panorama-size"
onChange={(event) =>
updateRoot({
directorDesk: {
...config.directorDesk,
panorama: {
...config.directorDesk.panorama,
aiImageSize: event.target.value,
},
},
})
}
value={config.directorDesk.panorama.aiImageSize}
/>
</div>
</div>
</div>
<DirectorCameraPresetsEditor
onChange={(cameraPresets) =>
updateRoot({
directorDesk: { ...config.directorDesk, cameraPresets },
})
}
value={config.directorDesk.cameraPresets}
/>
<DirectorPosePresetsEditor
onChange={(posePresets) =>
updateRoot({
directorDesk: { ...config.directorDesk, posePresets },
})
}
value={config.directorDesk.posePresets}
/>
</CardContent>
</Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和生成提示词都从这里读取。用户选择后会插入结构化运镜标签,允许组合和重复;提交生成时才按顺序解析这里配置的提示词。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>生成提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">Kling O3 智能分镜</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
控制视频节点的镜头拆分、时长约束和服务端隐藏提示词组装规则。
</p>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="smart-storyboard-enabled">启用智能分镜</Label>
<Switch
checked={config.smartStoryboard.enabled}
id="smart-storyboard-enabled"
onCheckedChange={(enabled) => updateSmartStoryboard({ enabled })}
/>
</div>
</div>
<div className="grid gap-4 rounded-lg border border-border bg-card p-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-2">
<Label htmlFor="smart-storyboard-model-patterns">支持模型关键字</Label>
<Input
id="smart-storyboard-model-patterns"
onChange={(event) =>
updateSmartStoryboard({
modelPatterns: event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
})
}
value={config.smartStoryboard.modelPatterns.join(",")}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="smart-storyboard-total-min">总时长下限</Label>
<Input
id="smart-storyboard-total-min"
min={1}
onChange={(event) =>
updateSmartStoryboard({
minTotalDurationSeconds: Number(event.target.value) || 1,
})
}
type="number"
value={config.smartStoryboard.minTotalDurationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smart-storyboard-total-max">总时长上限</Label>
<Input
id="smart-storyboard-total-max"
min={1}
onChange={(event) =>
updateSmartStoryboard({
maxTotalDurationSeconds: Number(event.target.value) || 1,
})
}
type="number"
value={config.smartStoryboard.maxTotalDurationSeconds}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="smart-storyboard-shot-min">单镜头最短</Label>
<Input
id="smart-storyboard-shot-min"
min={1}
onChange={(event) =>
updateSmartStoryboard({
minShotDurationSeconds: Number(event.target.value) || 1,
})
}
type="number"
value={config.smartStoryboard.minShotDurationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smart-storyboard-shot-default">新增默认秒数</Label>
<Input
id="smart-storyboard-shot-default"
min={1}
onChange={(event) =>
updateSmartStoryboard({
defaultShotDurationSeconds: Number(event.target.value) || 1,
})
}
type="number"
value={config.smartStoryboard.defaultShotDurationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smart-storyboard-max-shots">最多镜头</Label>
<Input
id="smart-storyboard-max-shots"
min={1}
onChange={(event) =>
updateSmartStoryboard({
maxShots: Number(event.target.value) || 1,
})
}
type="number"
value={config.smartStoryboard.maxShots}
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="smart-storyboard-prompt-template">隐藏总提示词模板</Label>
<Textarea
className="min-h-40 font-mono text-xs"
id="smart-storyboard-prompt-template"
onChange={(event) =>
updateSmartStoryboard({ promptTemplate: event.target.value })
}
value={config.smartStoryboard.promptTemplate}
/>
<p className="text-xs text-muted-foreground">
可用变量:{"{{prompt}}"}、{"{{shots}}"}、{"{{shotCount}}"}、{"{{totalDuration}}"}。
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="smart-storyboard-shot-template">单镜头提示词模板</Label>
<Textarea
className="min-h-24 font-mono text-xs"
id="smart-storyboard-shot-template"
onChange={(event) =>
updateSmartStoryboard({ shotPromptTemplate: event.target.value })
}
value={config.smartStoryboard.shotPromptTemplate}
/>
<p className="text-xs text-muted-foreground">
可用变量:{"{{index}}"}、{"{{duration}}"}、{"{{description}}"}。
</p>
</div>
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、内联附件、模型约束、搜索排序和生成提示词都从这里读取。选择后直接写入目标视频节点,不会额外创建画布素材节点。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-author-avatar-${index}`}>作者头像地址</Label>
<Input
id={`effect-author-avatar-${index}`}
onChange={(event) =>
updateEffectPreset(index, { authorAvatarUrl: event.target.value })
}
value={preset.authorAvatarUrl}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-search-keywords-${index}`}>
搜索关键词 / 排序权重
</Label>
<Textarea
id={`effect-search-keywords-${index}`}
onChange={(event) =>
updateEffectPreset(index, { searchKeywords: event.target.value })
}
placeholder="商品广告,产品扫光:80"
value={preset.searchKeywords}
/>
<p className="text-xs text-muted-foreground">
使用逗号分隔;可用“关键词:1-100”提高特定搜索词下的排序。
</p>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.featured}
id={`effect-featured-${index}`}
onCheckedChange={(checked) =>
updateEffectPreset(index, { featured: checked })
}
/>
<Label htmlFor={`effect-featured-${index}`}>在“推荐”目录展示</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">可灵主体库与创建主体</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
控制资产管理中的主体图片、视频、名称、描述、标签和音色规则。智能描述使用全局默认多模态模型,系统提示词和采样参数在这里维护。
</p>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="subject-library-enabled">启用主体库</Label>
<Switch
checked={config.subjectLibrary.enabled}
id="subject-library-enabled"
onCheckedChange={(enabled) => updateSubjectLibrary({ enabled })}
/>
</div>
</div>
<div className="grid gap-4 rounded-lg border border-border bg-card p-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="subject-image-accept">图片上传类型</Label>
<Input
id="subject-image-accept"
onChange={(event) => updateSubjectLibrary({ imageAccept: event.target.value })}
value={config.subjectLibrary.imageAccept}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-video-accept">视频上传类型</Label>
<Input
id="subject-video-accept"
onChange={(event) => updateSubjectLibrary({ videoAccept: event.target.value })}
value={config.subjectLibrary.videoAccept}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="subject-image-min">最少图片</Label>
<Input
id="subject-image-min"
min={1}
onChange={(event) => updateSubjectLibrary({ imageMinCount: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.imageMinCount}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-image-max">最多图片</Label>
<Input
id="subject-image-max"
min={1}
onChange={(event) => updateSubjectLibrary({ imageMaxCount: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.imageMaxCount}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-image-size">图片 MB</Label>
<Input
id="subject-image-size"
min={1}
onChange={(event) => updateSubjectLibrary({ imageMaxFileSizeMb: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.imageMaxFileSizeMb}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label htmlFor="subject-video-min">最短秒数</Label>
<Input
id="subject-video-min"
min={1}
onChange={(event) => updateSubjectLibrary({ videoMinDurationSeconds: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.videoMinDurationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-video-max">最长秒数</Label>
<Input
id="subject-video-max"
min={1}
onChange={(event) => updateSubjectLibrary({ videoMaxDurationSeconds: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.videoMaxDurationSeconds}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-video-size">视频 MB</Label>
<Input
id="subject-video-size"
min={1}
onChange={(event) => updateSubjectLibrary({ videoMaxFileSizeMb: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.videoMaxFileSizeMb}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="subject-name-max">名称字数</Label>
<Input
id="subject-name-max"
min={1}
onChange={(event) => updateSubjectLibrary({ nameMaxLength: Number(event.target.value) || 1 })}
type="number"
value={config.subjectLibrary.nameMaxLength}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-description-max">描述字数</Label>
<Input
id="subject-description-max"
min={10}
onChange={(event) => updateSubjectLibrary({ descriptionMaxLength: Number(event.target.value) || 10 })}
type="number"
value={config.subjectLibrary.descriptionMaxLength}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject-labels">标签</Label>
<Input
id="subject-labels"
onChange={(event) =>
updateSubjectLibrary({
labels: event.target.value.split(",").map((item) => item.trim()).filter(Boolean),
})
}
value={config.subjectLibrary.labels.join(",")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-unselected-category">未选择标签时归类</Label>
<Input
id="subject-unselected-category"
onChange={(event) => updateSubjectLibrary({ unselectedCategory: event.target.value })}
value={config.subjectLibrary.unselectedCategory}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subject-voice-help">音色使用说明</Label>
<Input
id="subject-voice-help"
onChange={(event) => updateSubjectLibrary({ voiceHelpText: event.target.value })}
value={config.subjectLibrary.voiceHelpText}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={config.subjectLibrary.requireClearFrontFace}
id="subject-front-face"
onCheckedChange={(requireClearFrontFace) => updateSubjectLibrary({ requireClearFrontFace })}
/>
<Label htmlFor="subject-front-face">第一张人物图片要求清晰正脸</Label>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={config.subjectLibrary.historyLibraryEnabled}
id="subject-history-library"
onCheckedChange={(historyLibraryEnabled) => updateSubjectLibrary({ historyLibraryEnabled })}
/>
<Label htmlFor="subject-history-library">允许从历史图库选择主体素材</Label>
</div>
</div>
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-medium">视频节点主体引用</h3>
<p className="mt-1 text-xs text-muted-foreground">
控制哪些视频模型显示“主体”,以及结构化主体进入生成请求时使用的隐藏一致性提示词。
</p>
</div>
<Switch
checked={config.subjectLibrary.videoUsage.enabled}
id="subject-video-usage-enabled"
onCheckedChange={(enabled) => updateSubjectVideoUsage({ enabled })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="subject-video-model-patterns">支持模型关键字</Label>
<Input
id="subject-video-model-patterns"
onChange={(event) =>
updateSubjectVideoUsage({
modelPatterns: event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
})
}
value={config.subjectLibrary.videoUsage.modelPatterns.join(",")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-video-max-subjects">单节点最多主体</Label>
<Input
id="subject-video-max-subjects"
max={12}
min={1}
onChange={(event) =>
updateSubjectVideoUsage({
maxSubjects: Number(event.target.value) || 1,
})
}
type="number"
value={config.subjectLibrary.videoUsage.maxSubjects}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject-video-prompt-template">隐藏一致性提示词模板</Label>
<Textarea
className="min-h-36 font-mono text-xs"
id="subject-video-prompt-template"
onChange={(event) =>
updateSubjectVideoUsage({ promptTemplate: event.target.value })
}
value={config.subjectLibrary.videoUsage.promptTemplate}
/>
<p className="text-xs text-muted-foreground">
可用变量:{"{{index}}"}、{"{{name}}"}、{"{{description}}"}、{"{{category}}"}、{"{{voiceName}}"}。
</p>
</div>
</div>
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-medium">主体描述智能生成</h3>
<p className="mt-1 text-xs text-muted-foreground">默认模型:{config.defaultMultimodalModel}</p>
</div>
<Switch
checked={config.subjectLibrary.smartDescription.enabled}
id="subject-smart-enabled"
onCheckedChange={(enabled) => updateSubjectSmartDescription({ enabled })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="subject-smart-cost">每次消耗积分</Label>
<Input
id="subject-smart-cost"
min={0}
onChange={(event) => updateSubjectSmartDescription({ cost: Number(event.target.value) || 0 })}
type="number"
value={config.subjectLibrary.smartDescription.cost}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-smart-tokens">最大输出 Tokens</Label>
<Input
id="subject-smart-tokens"
min={32}
onChange={(event) => updateSubjectSmartDescription({ maxTokens: Number(event.target.value) || 32 })}
type="number"
value={config.subjectLibrary.smartDescription.maxTokens}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subject-smart-temperature">温度</Label>
<Input
id="subject-smart-temperature"
max={1}
min={0}
onChange={(event) => updateSubjectSmartDescription({ temperature: Number(event.target.value) || 0 })}
step={0.05}
type="number"
value={config.subjectLibrary.smartDescription.temperature}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject-smart-prompt">系统提示词</Label>
<Textarea
className="min-h-40 font-mono text-xs"
id="subject-smart-prompt"
onChange={(event) => updateSubjectSmartDescription({ prompt: event.target.value })}
value={config.subjectLibrary.smartDescription.prompt}
/>
</div>
</div>
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-medium">主体其他视角智能补全</h3>
<p className="mt-1 text-xs text-muted-foreground">
以第一张图片为主参考,生成候选视角并保留用户选中的 1-3 张。
</p>
</div>
<Switch
checked={config.subjectLibrary.smartCompletion.enabled}
id="subject-completion-enabled"
onCheckedChange={(enabled) => updateSubjectSmartCompletion({ enabled })}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="subject-completion-cost">每次消耗积分</Label>
<Input id="subject-completion-cost" min={0} onChange={(event) => updateSubjectSmartCompletion({ cost: Number(event.target.value) || 0 })} type="number" value={config.subjectLibrary.smartCompletion.cost} />
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-candidates">候选图数量</Label>
<Input id="subject-completion-candidates" min={1} max={12} onChange={(event) => updateSubjectSmartCompletion({ candidateCount: Number(event.target.value) || 1 })} type="number" value={config.subjectLibrary.smartCompletion.candidateCount} />
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-max-selected">最多选择</Label>
<Input id="subject-completion-max-selected" min={1} onChange={(event) => updateSubjectSmartCompletion({ maxSelected: Number(event.target.value) || 1 })} type="number" value={config.subjectLibrary.smartCompletion.maxSelected} />
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-model">图片模型</Label>
<Input id="subject-completion-model" onChange={(event) => updateSubjectSmartCompletion({ model: event.target.value })} value={config.subjectLibrary.smartCompletion.model} />
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-size">图片尺寸</Label>
<Input id="subject-completion-size" onChange={(event) => updateSubjectSmartCompletion({ imageSize: event.target.value })} value={config.subjectLibrary.smartCompletion.imageSize} />
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-ratio">图片比例</Label>
<Input id="subject-completion-ratio" onChange={(event) => updateSubjectSmartCompletion({ aspectRatio: event.target.value })} value={config.subjectLibrary.smartCompletion.aspectRatio} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject-completion-prompt">生成提示词</Label>
<Textarea className="min-h-40 font-mono text-xs" id="subject-completion-prompt" onChange={(event) => updateSubjectSmartCompletion({ prompt: event.target.value })} value={config.subjectLibrary.smartCompletion.prompt} />
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-sm font-medium">主体音色列表</h3>
<Button onClick={addSubjectVoice} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加音色
</Button>
</div>
{config.subjectLibrary.voices.map((voice, index) => (
<details className="rounded-lg border border-border bg-card px-4 py-3" key={`${voice.id}-${index}`}>
<summary className="flex cursor-pointer list-none items-center gap-3 text-sm">
<span className="min-w-0 flex-1 truncate">{voice.name} {voice.badge}</span>
<Button
aria-label={`删除音色 ${voice.name}`}
onClick={(event) => {
event.preventDefault();
removeSubjectVoice(index);
}}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`subject-voice-id-${index}`}>音色 ID</Label>
<Input id={`subject-voice-id-${index}`} onChange={(event) => updateSubjectVoice(index, { id: event.target.value })} value={voice.id} />
</div>
<div className="space-y-2">
<Label htmlFor={`subject-voice-name-${index}`}>名称</Label>
<Input id={`subject-voice-name-${index}`} onChange={(event) => updateSubjectVoice(index, { name: event.target.value })} value={voice.name} />
</div>
<div className="space-y-2">
<Label htmlFor={`subject-voice-badge-${index}`}>标记</Label>
<Input id={`subject-voice-badge-${index}`} onChange={(event) => updateSubjectVoice(index, { badge: event.target.value })} value={voice.badge} />
</div>
<div className="space-y-2">
<Label htmlFor={`subject-voice-preview-${index}`}>试听地址</Label>
<Input id={`subject-voice-preview-${index}`} onChange={(event) => updateSubjectVoice(index, { previewUrl: event.target.value })} value={voice.previewUrl} />
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。用户单次选择一个具体素材,引用以内联结构保存到视频节点,不会改写输入框或创建额外画布节点。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "全身正面"],
["face", "近景"],
["expression", "九宫格"],
["threeView", "设定图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>
<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">
{functions.map((item) => (
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
<CardHeader className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
<span>{item.label}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{functionKindLabels[item.kind]}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{item.route}
</span>
</CardTitle>
<CardDescription className="mt-2">{item.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
<Switch
checked={item.enabled}
id={`enabled-${item.key}`}
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
allowInherit
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
</div>
{item.cost !== undefined ? (
<div className="space-y-2">
<Label htmlFor={`cost-${item.key}`}>固定积分</Label>
<Input
id={`cost-${item.key}`}
min={0}
onChange={(event) =>
updateFunction(item.key, {
cost: Math.max(0, Math.round(Number(event.target.value) || 0)),
})
}
step={1}
type="number"
value={item.cost}
/>
</div>
) : null}
<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>
<CardContent>
<Label htmlFor={`prompt-${item.key}`}>管理员可编辑的预设提示词 / 后端规则</Label>
<Textarea
className="mt-2 min-h-28"
id={`prompt-${item.key}`}
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
value={item.prompt}
/>
{item.generationPrompt !== undefined ? (
<div className="mt-4 space-y-2">
<Label htmlFor={`generation-prompt-${item.key}`}>
生成时结构化数据注入模板
</Label>
<Textarea
className="min-h-20"
id={`generation-prompt-${item.key}`}
onChange={(event) =>
updateFunction(item.key, {
generationPrompt: event.target.value,
})
}
value={item.generationPrompt}
/>
<p className="text-xs text-muted-foreground">
支持 {"{{labels}}"}(带 @)和 {"{{plainLabels}}"}(纯标签);留空时不注入。
</p>
</div>
) : null}
</CardContent>
</Card>
))}
</div>
</section>
</main>
);
}