4505 lines
179 KiB
TypeScript
4505 lines
179 KiB
TypeScript
"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>
|
||
);
|
||
}
|