Wire video canvas model selectors
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-19 14:44:27 +08:00
parent af74e2197b
commit 9422c5de73
4 changed files with 478 additions and 64 deletions

View File

@ -13,8 +13,19 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 {
type VideoCanvasFunctionConfig,
@ -30,6 +41,26 @@ type SettingsResponse = {
};
};
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: "图片生成",
@ -40,13 +71,125 @@ const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string>
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>
);
}
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;
@ -135,7 +278,7 @@ export function VideoCanvasSettingsClient() {
<div>
<CardTitle className="text-xl">全局模型与开关</CardTitle>
<CardDescription>
默认模型可填写现有后端的模型标识;保持 auto 时由各接口按当前可用模型选择。
默认模型从后台已启用模型中选择;保持自动时由各接口按当前可用模型选择。
</CardDescription>
</div>
<div className="flex items-center gap-3">
@ -150,25 +293,28 @@ export function VideoCanvasSettingsClient() {
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
<Input
id="defaultImageModel"
onChange={(event) => updateRoot({ defaultImageModel: event.target.value })}
<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>
<Input
id="defaultVideoModel"
onChange={(event) => updateRoot({ defaultVideoModel: event.target.value })}
<ModelSelect
kind="video"
models={modelOptions}
onChange={(value) => updateRoot({ defaultVideoModel: value || "auto" })}
value={config.defaultVideoModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
<Input
id="defaultMultimodalModel"
onChange={(event) => updateRoot({ defaultMultimodalModel: event.target.value })}
<ModelSelect
kind="multimodal"
models={modelOptions}
onChange={(value) => updateRoot({ defaultMultimodalModel: value || "auto" })}
value={config.defaultMultimodalModel}
/>
</div>
@ -212,10 +358,11 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<Input
id={`model-${item.key}`}
onChange={(event) => updateFunction(item.key, { model: event.target.value })}
placeholder="留空使用全局默认模型"
<ModelSelect
allowInherit
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
</div>

View File

@ -19,6 +19,8 @@ const requestSchema = z.object({
imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(),
nodeTitle: z.string().trim().optional(),
preferredModelRef: z.string().trim().optional(),
model: z.string().trim().optional(),
});
function sanitizeUrls(value: unknown, limit = 6) {
@ -263,6 +265,7 @@ export async function POST(request: Request) {
const prompt = normalizeText(parsed.data.prompt);
const imageUrls = sanitizeUrls(parsed.data.imageUrls, 4);
const videoUrls = sanitizeUrls(parsed.data.videoUrls, 4);
const preferredModelRef = normalizeText(parsed.data.preferredModelRef || parsed.data.model);
if (!prompt && imageUrls.length === 0 && videoUrls.length === 0) {
return NextResponse.json(
{ success: false, error: "请至少提供脚本、图片或视频上下文" },
@ -278,14 +281,25 @@ export async function POST(request: Request) {
(item) => !item.model.isSubModel && item.model.isEnabled
);
const preferredModel =
preferredModelRef
? compatibleModels.find(
(item) =>
item.model.id === preferredModelRef ||
item.model.modelId === preferredModelRef,
)
: null;
let selectedModel =
imageUrls.length > 0 &&
agentModel &&
supportsVision(agentModel.model)
? agentModel
: imageUrls.length > 0
? compatibleModels.find((item) => supportsVision(item.model))
: agentModel || compatibleModels[0];
preferredModel && (imageUrls.length === 0 || supportsVision(preferredModel.model))
? preferredModel
: imageUrls.length > 0 &&
agentModel &&
supportsVision(agentModel.model)
? agentModel
: imageUrls.length > 0
? compatibleModels.find((item) => supportsVision(item.model))
: agentModel || compatibleModels[0];
if (!selectedModel) {
selectedModel = compatibleModels[0];

View File

@ -43,6 +43,10 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { cn, fetcher } from "@/lib/utils";
import {
canvasService,
@ -201,6 +205,22 @@ type ComposerSettings = {
resolution: string;
};
type CanvasModelOption = {
id: string;
modelId?: string;
displayName: string;
providerName?: string;
providerType?: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
isMultimodal?: boolean;
isDefault?: boolean;
isSubModel?: boolean;
capabilities?: unknown;
};
type CanvasModelKind = "image" | "video" | "multimodal" | "any";
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script";
type CanvasCreateKind = AddModuleKind;
type TargetModuleActionLabel =
@ -1178,9 +1198,30 @@ function getDefaultFunctionPrompt(key: string) {
return defaultVideoCanvasFunctionConfig.functions[key]?.prompt || "";
}
function normalizeModelRef(value: unknown) {
const model = String(value || "").trim();
return model && model !== "auto" ? model : "";
}
function getFunctionModel(
config: VideoCanvasFunctionConfig,
definition: VideoCanvasFunctionDefinition,
overrideModel?: string,
) {
const override = normalizeModelRef(overrideModel);
if (override) return override;
const fallback =
definition.kind === "video"
? config.defaultVideoModel
: definition.kind === "multimodal"
? config.defaultMultimodalModel
: config.defaultImageModel;
return normalizeModelRef(definition.model || fallback) || undefined;
}
function getFunctionModelValue(
config: VideoCanvasFunctionConfig,
definition: VideoCanvasFunctionDefinition,
) {
const fallback =
definition.kind === "video"
@ -1188,8 +1229,57 @@ function getFunctionModel(
: definition.kind === "multimodal"
? config.defaultMultimodalModel
: config.defaultImageModel;
const model = (definition.model || fallback || "").trim();
return model && model !== "auto" ? model : undefined;
return String(definition.model || fallback || "auto").trim() || "auto";
}
function getCanvasModelRef(model: CanvasModelOption) {
return String(model.modelId || model.id || "").trim();
}
function isCanvasImageModel(model: CanvasModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isCanvasVideoModel(model: CanvasModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterCanvasModelsByKind(models: CanvasModelOption[], kind: CanvasModelKind) {
const usable = models.filter((model) => getCanvasModelRef(model));
let filtered = usable;
if (kind === "image") {
filtered = usable.filter(isCanvasImageModel);
} else if (kind === "video") {
filtered = usable.filter(isCanvasVideoModel);
} else if (kind === "multimodal") {
filtered = usable.filter(
(model) => model.isMultimodal || model.supportsVision || (!isCanvasImageModel(model) && !isCanvasVideoModel(model)),
);
}
return filtered.length ? filtered : usable;
}
function getCanvasModelDisplayName(
modelRef: string | undefined,
models: CanvasModelOption[],
fallback = "自动模型",
) {
const normalized = normalizeModelRef(modelRef);
if (!normalized) return fallback;
const match = models.find(
(model) => model.id === normalized || model.modelId === normalized || getCanvasModelRef(model) === normalized,
);
return match?.displayName || normalized;
}
function composeFunctionPrompt(
@ -1782,10 +1872,18 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
"/api/video-canvas/config",
fetcher
);
const { data: canvasModelsData } = useSWR<{ models?: CanvasModelOption[] }>(
"/api/models",
fetcher
);
const videoCanvasFunctionConfig = useMemo(
() => normalizeVideoCanvasFunctionConfig(videoCanvasFunctionSettings?.config),
[videoCanvasFunctionSettings?.config]
);
const canvasModelOptions = useMemo(
() => (canvasModelsData?.models || []).filter((model) => getCanvasModelRef(model)),
[canvasModelsData?.models]
);
const [nodes, setNodes] = useState<CloneNode[]>([]);
const [edges, setEdges] = useState<CloneEdge[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@ -2879,7 +2977,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const response = await imageGenerationService.generateImage({
aspectRatio: payload.settings.ratio,
imageCount: count,
model: getFunctionModel(videoCanvasFunctionConfig, definition),
model: getFunctionModel(videoCanvasFunctionConfig, definition, payload.settings.model),
prompt: finalPrompt,
referenceImage: sourceUrl,
});
@ -2947,7 +3045,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
);
const runPresetComposerSubmit = useCallback(
async (nodeId: string, mode: Exclude<ActiveStarterScene, null>, draftPrompt: string) => {
async (
nodeId: string,
mode: Exclude<ActiveStarterScene, null>,
draftPrompt: string,
settings?: ComposerSettings,
) => {
const node = nodes.find((item) => item.id === nodeId);
if (!node) return;
const incomingNodes = edges
@ -2977,7 +3080,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
imageUrls,
mode: "agent-plan",
nodeTitle: node.title,
prompt: finalPrompt,
prompt,
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, settings?.model),
videoUrls,
});
if (!response.success) throw new Error(response.error || "故事脚本生成失败");
@ -3013,7 +3117,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt = composeFunctionPrompt(definition, draftPrompt);
const response = await videoGenerationService.generateVideo({
images: imageUrls.slice(0, 4),
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, settings?.model),
prompt,
ratio: "16:9",
resolution: "720p",
@ -3120,6 +3224,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
.filter(Boolean);
const action = String(node.draftValues?.action || "");
const params = (node.draftValues?.params || {}) as Record<string, unknown>;
const paramsSettings =
params.settings && typeof params.settings === "object"
? (params.settings as Record<string, unknown>)
: {};
const nodeModelRef = normalizeModelRef(params.model || paramsSettings.model);
const rawPrompt = String(params.prompt || node.draftValues?.prompt || "");
try {
@ -3146,7 +3255,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const response = await imageGenerationService.generateImage({
aspectRatio: action === "IMAGE_GENERATE_NINE_GRID" ? "16:9" : undefined,
imageCount: 1,
model: getFunctionModel(videoCanvasFunctionConfig, definition),
model: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
prompt,
referenceImage: sourceUrl || undefined,
});
@ -3188,7 +3297,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const prompt = composeFunctionPrompt(definition, rawPrompt || node.title);
const response = await videoGenerationService.generateVideo({
images: imageUrls.slice(0, 4),
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
prompt,
ratio: "16:9",
resolution: "720p",
@ -3232,6 +3341,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
mode: "agent-plan",
nodeTitle: node.title,
prompt,
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
videoUrls,
});
if (!response.success) throw new Error(response.error || "多模态分析失败");
@ -3644,14 +3754,17 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
<PresetWorkflowControls
anchor={selectedPresetAnchor}
mode={selectedPresetMode ?? "character-three-view"}
functionConfig={videoCanvasFunctionConfig}
modelOptions={canvasModelOptions}
nodes={nodes}
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
onPresetSubmit={(prompt) =>
onPresetSubmit={(prompt, settings) =>
runPresetComposerSubmit(
selectedPresetControlNode.id,
selectedPresetMode ?? "character-three-view",
prompt
prompt,
settings
)
}
selectedNode={selectedPresetControlNode}
@ -3661,8 +3774,13 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
<TextTargetModuleComposer
anchor={selectedTargetAnchor}
defaultModel={getFunctionModelValue(
videoCanvasFunctionConfig,
getConfiguredFunction(videoCanvasFunctionConfig, "story-script"),
)}
modelOptions={canvasModelOptions}
node={selectedNode}
onSubmit={(prompt) => {
onSubmit={(prompt, model) => {
setNodes((current) =>
current.map((node) =>
node.id === selectedNode.id
@ -3675,6 +3793,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
manualTextComposer: true,
params: {
...((node.draftValues?.params as Record<string, unknown> | undefined) || {}),
model,
prompt,
},
},
@ -4385,7 +4504,9 @@ function MagnetGlyph() {
function PresetWorkflowControls({
anchor,
functionConfig,
mode,
modelOptions,
nodes,
onComposerSubmit,
onImageToolAction,
@ -4393,19 +4514,25 @@ function PresetWorkflowControls({
selectedNode,
}: {
anchor: PresetControlAnchor;
functionConfig: VideoCanvasFunctionConfig;
mode: Exclude<ActiveStarterScene, null>;
modelOptions: CanvasModelOption[];
nodes: CloneNode[];
onComposerSubmit: (payload: ComposerSubmitPayload) => void;
onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
onPresetSubmit: (prompt: string) => void;
onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void;
selectedNode: CloneNode;
}) {
if (mode === "character-three-view") {
const definition = getConfiguredFunction(functionConfig, "character-three-view");
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
<PromptComposer
anchor={anchor}
defaultModel={getFunctionModelValue(functionConfig, definition)}
modelKind="image"
modelOptions={modelOptions}
nodes={nodes}
onSubmit={onComposerSubmit}
referenceThumbnailUrl={getNodeMediaUrl(selectedNode) || CHARACTER_IMAGE_URL}
@ -4415,6 +4542,7 @@ function PresetWorkflowControls({
}
if (mode === "story-script") {
const definition = getConfiguredFunction(functionConfig, "story-script");
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
@ -4429,6 +4557,9 @@ function PresetWorkflowControls({
<PresetComposerPanel
anchor={anchor}
credit="8"
defaultModel={getFunctionModelValue(functionConfig, definition)}
modelKind="multimodal"
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
prompt={STORY_SCRIPT_PROMPT}
submitMessage="故事脚本生成接口待接入。"
@ -4439,6 +4570,7 @@ function PresetWorkflowControls({
}
if (mode === "first-frame") {
const definition = getConfiguredFunction(functionConfig, "first-frame-video");
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
@ -4453,6 +4585,9 @@ function PresetWorkflowControls({
<PresetComposerPanel
anchor={anchor}
credit="28"
defaultModel={getFunctionModelValue(functionConfig, definition)}
modelKind="video"
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
prompt="以当前图为首帧生成视频。"
submitMessage="首帧图生视频接口待接入。"
@ -4463,31 +4598,51 @@ function PresetWorkflowControls({
);
}
const definition = getConfiguredFunction(functionConfig, "audio-to-video");
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<AudioToVideoComposerPanel anchor={anchor} onSubmit={onPresetSubmit} />
<AudioToVideoComposerPanel
anchor={anchor}
defaultModel={getFunctionModelValue(functionConfig, definition)}
modelOptions={modelOptions}
onSubmit={onPresetSubmit}
/>
</div>
);
}
function TextTargetModuleComposer({
anchor,
defaultModel = "auto",
modelOptions = [],
node,
onSubmit,
}: {
anchor: PresetControlAnchor;
defaultModel?: string;
modelOptions?: CanvasModelOption[];
node: CloneNode;
onSubmit: (prompt: string) => void;
onSubmit: (prompt: string, model: string) => void;
}) {
const contentValues = node.draftValues?.content;
const initialPrompt =
Array.isArray(contentValues) && typeof contentValues[0] === "string" ? contentValues[0] : "";
const initialModel = normalizeModelRef(
((node.draftValues?.params as Record<string, unknown> | undefined)?.model as string | undefined) ||
defaultModel,
) || "auto";
const [menu, setMenu] = useState<ComposerMenu>(null);
const [selectedModel, setSelectedModel] = useState(initialModel);
const [prompt, setPrompt] = useState(initialPrompt);
useEffect(() => {
setPrompt(initialPrompt);
}, [initialPrompt, node.id]);
useEffect(() => {
setSelectedModel(initialModel);
}, [initialModel, node.id]);
return (
<div
className="pointer-events-auto absolute z-30 overflow-visible"
@ -4504,6 +4659,17 @@ function TextTargetModuleComposer({
className="relative h-[286px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]"
style={{ transform: `scale(${OFFICIAL_COMPOSER_SCALE})`, transformOrigin: "top left" }}
>
{menu === "model" ? (
<ComposerPopover
menu="model"
modelKind="multimodal"
modelOptions={modelOptions}
onSelect={(option) => {
setSelectedModel(option);
setMenu(null);
}}
/>
) : null}
<textarea
className="h-[158px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/38"
onChange={(event) => setPrompt(event.target.value)}
@ -4513,10 +4679,11 @@ function TextTargetModuleComposer({
/>
<div className="absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78">
<ComposerControl
label="GVLM 3.1"
active={menu === "model"}
label={getCanvasModelDisplayName(selectedModel, modelOptions)}
icon="⌘"
dropdown
onClick={() => toast.info("文本模型选择后端待接入。")}
onClick={() => setMenu((current) => current === "model" ? null : "model")}
/>
<div className="min-w-0 flex-1" />
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
@ -4524,7 +4691,7 @@ function TextTargetModuleComposer({
</div>
<button
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] leading-none text-black transition hover:bg-white"
onClick={() => onSubmit(prompt)}
onClick={() => onSubmit(prompt, selectedModel)}
type="button"
>
↑
@ -4609,12 +4776,29 @@ function CharacterPresetToolbar({
function AudioToVideoComposerPanel({
anchor,
defaultModel = "auto",
modelOptions = [],
onSubmit,
}: {
anchor?: PresetControlAnchor;
onSubmit?: (prompt: string) => void;
defaultModel?: string;
modelOptions?: CanvasModelOption[];
onSubmit?: (prompt: string, settings?: ComposerSettings) => void;
}) {
const [prompt, setPrompt] = useState(AUDIO_TO_VIDEO_PROMPT);
const [menu, setMenu] = useState<ComposerMenu>(null);
const [settings, setSettings] = useState<ComposerSettings>({
camera: "摄像机",
count: "1个",
model: normalizeModelRef(defaultModel) || "auto",
panorama: "全景",
ratio: "16:9",
resolution: "720P",
});
useEffect(() => {
setSettings((current) => ({ ...current, model: normalizeModelRef(defaultModel) || "auto" }));
}, [defaultModel]);
return (
<div
@ -4638,6 +4822,17 @@ function AudioToVideoComposerPanel({
}
>
<div className="relative h-[260px] w-[1216px] rounded-[16px] border border-white/8 bg-[#252525]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
{menu === "model" ? (
<ComposerPopover
menu="model"
modelKind="video"
modelOptions={modelOptions}
onSelect={(option) => {
setSettings((current) => ({ ...current, model: option }));
setMenu(null);
}}
/>
) : null}
<button className="absolute right-6 top-5 text-[32px] text-white/38 hover:text-white/70" onClick={() => toast.info("展开音频生视频编辑器待接入。")} type="button">↗</button>
<div className="flex items-center gap-3">
{["文生视频", "全能参考", "图生视频", "首尾帧", "图片参考"].map((tab, index) => (
@ -4686,7 +4881,7 @@ function AudioToVideoComposerPanel({
value={prompt}
/>
<div className="absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82">
<ComposerControl label="Seedance 2.0" icon="▥" dropdown onClick={() => toast.info("视频模型选择后端待接入。")} />
<ComposerControl active={menu === "model"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon="▥" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<span className="rounded-[5px] bg-[#f6c85f] px-1.5 py-0.5 text-[13px] font-bold text-[#2b2314]">VIP</span>
<ComposerControl label="16:9" icon="▭" onClick={() => toast.info("比例选择后端待接入。")} />
<ComposerControl label="720P" onClick={() => toast.info("清晰度选择后端待接入。")} />
@ -4701,7 +4896,7 @@ function AudioToVideoComposerPanel({
className="absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white text-[34px] leading-none text-black hover:bg-white/90"
onClick={() => {
if (onSubmit) {
onSubmit(prompt);
onSubmit(prompt, settings);
return;
}
toast.info("音频生视频生成接口待接入。");
@ -5184,6 +5379,9 @@ function AudioResourcePanel() {
function PresetComposerPanel({
anchor,
credit,
defaultModel = "auto",
modelKind = "image",
modelOptions = [],
onSubmit,
prompt,
submitMessage,
@ -5192,18 +5390,34 @@ function PresetComposerPanel({
}: {
anchor?: PresetControlAnchor;
credit: string;
onSubmit?: (prompt: string) => void;
defaultModel?: string;
modelKind?: CanvasModelKind;
modelOptions?: CanvasModelOption[];
onSubmit?: (prompt: string, settings?: ComposerSettings) => void;
prompt: string;
submitMessage: string;
thumbnailUrl?: string;
title: string;
}) {
const [menu, setMenu] = useState<ComposerMenu>(null);
const [draftPrompt, setDraftPrompt] = useState(prompt);
const [settings, setSettings] = useState<ComposerSettings>({
camera: "摄像机",
count: "1张",
model: normalizeModelRef(defaultModel) || "auto",
panorama: "全景",
ratio: "16:9",
resolution: "2K",
});
useEffect(() => {
setDraftPrompt(prompt);
}, [prompt]);
useEffect(() => {
setSettings((current) => ({ ...current, model: normalizeModelRef(defaultModel) || "auto" }));
}, [defaultModel]);
return (
<div
className="pointer-events-auto absolute overflow-visible"
@ -5229,6 +5443,17 @@ function PresetComposerPanel({
className="relative h-[238px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]"
style={{ transform: `scale(${OFFICIAL_COMPOSER_SCALE})`, transformOrigin: "top left" }}
>
{menu === "model" ? (
<ComposerPopover
menu="model"
modelKind={modelKind}
modelOptions={modelOptions}
onSelect={(option) => {
setSettings((current) => ({ ...current, model: option }));
setMenu(null);
}}
/>
) : null}
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
<div className="mb-5 flex items-center gap-3">
<span className="rounded-[10px] border border-white/10 bg-[#292929] px-3 py-2 text-[14px] text-white/62">{title}</span>
@ -5245,7 +5470,7 @@ function PresetComposerPanel({
value={draftPrompt}
/>
<div className="absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[17px] text-white/78">
<ComposerControl label="Lib Nano Pro" icon="◉" dropdown onClick={() => toast.info("模型选择接口待接入。")} />
<ComposerControl active={menu === "model"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<ComposerControl label="16:9" icon="▭" dropdown onClick={() => toast.info("比例选择接口待接入。")} />
<ComposerControl label="2K" dropdown onClick={() => toast.info("清晰度选择接口待接入。")} />
<ComposerControl label="摄像机" icon="▣" onClick={() => toast.info("镜头配置接口待接入。")} />
@ -5256,7 +5481,7 @@ function PresetComposerPanel({
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
onClick={() => {
if (onSubmit) {
onSubmit(draftPrompt);
onSubmit(draftPrompt, settings);
return;
}
toast.info(submitMessage);
@ -5739,11 +5964,17 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
function PromptComposer({
anchor,
defaultModel = "auto",
modelKind = "image",
modelOptions = [],
nodes = [],
onSubmit,
referenceThumbnailUrl = CHARACTER_IMAGE_URL,
}: {
anchor?: PresetControlAnchor;
defaultModel?: string;
modelKind?: CanvasModelKind;
modelOptions?: CanvasModelOption[];
nodes?: CloneNode[];
onSubmit?: (payload: ComposerSubmitPayload) => void;
referenceThumbnailUrl?: string;
@ -5755,11 +5986,15 @@ function PromptComposer({
const [settings, setSettings] = useState<ComposerSettings>({
camera: "摄像机",
count: "1张",
model: "Lib Nano Pro",
model: normalizeModelRef(defaultModel) || "auto",
panorama: "全景",
ratio: "16:9",
resolution: "2K",
});
useEffect(() => {
setSettings((current) => ({ ...current, model: normalizeModelRef(defaultModel) || "auto" }));
}, [defaultModel]);
const referenceNodes = useMemo(
() => nodes.filter((node) => node.kind !== "script" && node.inputType !== "audio"),
[nodes]
@ -5883,7 +6118,12 @@ function PromptComposer({
) : menu === "focus" ? (
<ComposerFocusPopover onApply={(value) => applyComposerOption("focus", value)} />
) : menu ? (
<ComposerPopover menu={menu} onSelect={(option) => applyComposerOption(menu, option)} />
<ComposerPopover
menu={menu}
modelKind={modelKind}
modelOptions={modelOptions}
onSelect={(option) => applyComposerOption(menu, option)}
/>
) : null}
<textarea
@ -5898,7 +6138,7 @@ function PromptComposer({
/>
<div className="absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[18px] text-white/78">
<ComposerControl active={menu === "model"} label={settings.model} icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<ComposerControl active={menu === "model"} label={getCanvasModelDisplayName(settings.model, modelOptions)} icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
<ComposerControl active={menu === "ratio"} label={settings.ratio} icon="▭" dropdown onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")} />
<ComposerControl active={menu === "resolution"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")} />
<ComposerControl active={menu === "camera"} label={settings.camera} icon="▣" onClick={() => setMenu((current) => current === "camera" ? null : "camera")} />
@ -6033,34 +6273,46 @@ function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void
function ComposerPopover({
menu,
modelKind = "image",
modelOptions = [],
onSelect,
}: {
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
modelKind?: CanvasModelKind;
modelOptions?: CanvasModelOption[];
onSelect: (option: string) => void;
}) {
const options: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, string[]> = {
camera: ["摄像机", "手持镜头", "固定镜头"],
count: ["1张", "2张", "4张"],
mark: ["添加人物标记", "添加构图标记", "添加动作标记"],
model: ["Lib Nano Pro", "Lib Seedream Pro", "Lib Universal"],
panorama: ["全景", "半身", "近景"],
ratio: ["16:9", "9:16", "1:1", "4:3"],
resolution: ["1K", "2K", "4K"],
settings: ["提示词增强", "参考图权重", "高级参数"],
style: ["写实", "电影感", "国风", "柔和自然光"],
translate: ["中文", "English", "自动翻译"],
const staticOptions: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, Array<{ label: string; value: string }>> = {
camera: ["摄像机", "手持镜头", "固定镜头"].map((value) => ({ label: value, value })),
count: ["1张", "2张", "4张"].map((value) => ({ label: value, value })),
mark: ["添加人物标记", "添加构图标记", "添加动作标记"].map((value) => ({ label: value, value })),
model: [],
panorama: ["全景", "半身", "近景"].map((value) => ({ label: value, value })),
ratio: ["16:9", "9:16", "1:1", "4:3"].map((value) => ({ label: value, value })),
resolution: ["1K", "2K", "4K"].map((value) => ({ label: value, value })),
settings: ["提示词增强", "参考图权重", "高级参数"].map((value) => ({ label: value, value })),
style: ["写实", "电影感", "国风", "柔和自然光"].map((value) => ({ label: value, value })),
translate: ["中文", "English", "自动翻译"].map((value) => ({ label: value, value })),
};
const modelItems = [
{ label: "自动模型", value: "auto" },
...filterCanvasModelsByKind(modelOptions, modelKind).map((model) => ({
label: model.displayName || model.modelId || model.id,
value: getCanvasModelRef(model),
})),
];
const options = menu === "model" ? modelItems : staticOptions[menu];
return (
<div className="absolute left-5 top-[102px] z-50 w-[260px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
{options[menu].map((option) => (
<div className="absolute left-5 top-[102px] z-50 max-h-[280px] w-[300px] overflow-y-auto rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
{options.map((option) => (
<button
className="flex h-9 w-full items-center justify-between rounded-[9px] px-3 text-left text-[13px] text-white/72 hover:bg-white/8 hover:text-white"
key={option}
onClick={() => onSelect(option)}
key={option.value}
onClick={() => onSelect(option.value)}
type="button"
>
{option}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
<span className="text-white/28">›</span>
</button>
))}
@ -6094,7 +6346,7 @@ function ComposerControl({
type="button"
>
{icon ? <span>{icon}</span> : null}
{label ? <span>{label}</span> : null}
{label ? <span className="max-w-[170px] truncate">{label}</span> : null}
{dropdown ? <span className="text-white/42">⌄</span> : null}
</button>
);

View File

@ -720,6 +720,7 @@ export const workflowAnalysisService = {
imageUrls?: string[];
videoUrls?: string[];
nodeTitle?: string;
preferredModelRef?: string;
}): Promise<
ApiResponse<{
summary?: string;