58
web/src/lib/canvas/canvas-connection-policy.ts
Normal file
58
web/src/lib/canvas/canvas-connection-policy.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
|
||||
|
||||
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate) {
|
||||
const target = nodes.find((node) => node.id === candidate.toNodeId);
|
||||
if (!target) return "找不到连线目标节点";
|
||||
const mode = nodeGenerationMode(target);
|
||||
if (!mode) return "";
|
||||
const input = connectionInputSummary(target.id, nodes, connections, candidate);
|
||||
const visualInputCount = input.imageCount + input.characterCount;
|
||||
|
||||
if (mode === "image") {
|
||||
if (input.videoCount > 0) return "图片生成节点不能连接参考视频";
|
||||
if (input.audioCount > 0) return "图片生成节点不能连接参考音频";
|
||||
return capacityError(config, mode, "image", visualInputCount, "参考图");
|
||||
}
|
||||
if (mode === "video") {
|
||||
return capacityError(config, mode, "image", visualInputCount, "参考图") || capacityError(config, mode, "video", input.videoCount, "参考视频") || capacityError(config, mode, "audio", input.audioCount, "参考音频");
|
||||
}
|
||||
if (mode === "text" && input.audioCount > 0) return "文本生成节点不能连接参考音频";
|
||||
if (mode === "audio" && input.characterCount > 1) return "角色配音一次只能连接一个角色卡";
|
||||
if (mode === "audio" && (input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0)) return "音频生成节点只接受文本或单个角色卡输入";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate?: ConnectionCandidate): ModelInputSummary {
|
||||
const sourceIds = new Set([...connections, ...(candidate ? [{ id: "candidate", ...candidate }] : [])].filter((connection) => connection.toNodeId === targetNodeId).map((connection) => connection.fromNodeId));
|
||||
const input: ModelInputSummary = { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 };
|
||||
sourceIds.forEach((sourceId) => {
|
||||
const source = nodes.find((node) => node.id === sourceId);
|
||||
if (!source || source.type === CanvasNodeType.Config || source.type === CanvasNodeType.Frame) return;
|
||||
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
|
||||
else if (source.type === CanvasNodeType.Image || source.type === CanvasNodeType.Drawing) input.imageCount += 1;
|
||||
else if (source.type === CanvasNodeType.Video) input.videoCount += 1;
|
||||
else if (source.type === CanvasNodeType.Audio) input.audioCount += 1;
|
||||
else input.textCount += 1;
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
function nodeGenerationMode(node: CanvasNodeData): CanvasGenerationMode | null {
|
||||
if (node.type === CanvasNodeType.Config) return node.metadata?.generationMode || "image";
|
||||
if (node.type === CanvasNodeType.Image) return "image";
|
||||
if (node.type === CanvasNodeType.Video) return "video";
|
||||
if (node.type === CanvasNodeType.Audio) return "audio";
|
||||
if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
function capacityError(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio", count: number, label: string) {
|
||||
const maximum = maxModelInputCapacity(config, capability, kind);
|
||||
if (maximum === null || count <= maximum) return "";
|
||||
const unit = kind === "image" ? "张" : "个";
|
||||
return maximum > 0 ? `已配置模型最多支持 ${maximum} ${unit}${label}` : `已配置模型均不支持${label}`;
|
||||
}
|
||||
@@ -238,6 +238,7 @@ export function getInputSummary(inputs: NodeGenerationInput[]) {
|
||||
imageCount: inputs.filter((input) => input.type === "image").length,
|
||||
videoCount: inputs.filter((input) => input.type === "video").length,
|
||||
audioCount: inputs.filter((input) => input.type === "audio").length,
|
||||
characterCount: inputs.filter((input) => input.type === "character").length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
@@ -202,13 +203,14 @@ function resolveVideoEditOperation(
|
||||
},
|
||||
): CanvasVideoEditOperation {
|
||||
const storedOperation = node?.metadata?.videoEditOperation;
|
||||
// 连接关系是生成时的真实输入,不能让分镜节点残留的文生视频模式丢弃后来连接的参考图。
|
||||
if (storedOperation === "text_to_video" && context?.referenceImages.length) return "image_to_video";
|
||||
if (storedOperation) return storedOperation;
|
||||
if (context?.referenceAudios.length && !context.referenceImages.length && !context.referenceVideos.length) return "audio_to_video";
|
||||
if (context?.referenceVideos.length) return "extend";
|
||||
if (context?.referenceImages.length) return "image_to_video";
|
||||
return "image_to_video";
|
||||
const input = {
|
||||
textCount: 0,
|
||||
imageCount: context?.referenceImages.length || 0,
|
||||
videoCount: context?.referenceVideos.length || 0,
|
||||
audioCount: context?.referenceAudios.length || 0,
|
||||
characterCount: 0,
|
||||
};
|
||||
return resolveVideoOperation(input, storedOperation) as CanvasVideoEditOperation;
|
||||
}
|
||||
|
||||
export function buildVideoGenerationMetadata(
|
||||
@@ -284,11 +286,12 @@ export function getGenerationCount(count: string) {
|
||||
}
|
||||
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
|
||||
const model = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
|
||||
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
|
||||
const model = resolveCompatibleModel(config, preferredModel, requirements) || preferredModel;
|
||||
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
|
||||
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground, count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count) }) : undefined;
|
||||
return {
|
||||
|
||||
149
web/src/lib/model-selection.ts
Normal file
149
web/src/lib/model-selection.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities";
|
||||
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
export type ModelInputSummary = {
|
||||
textCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
audioCount: number;
|
||||
characterCount: number;
|
||||
};
|
||||
|
||||
export type ModelRequirements = {
|
||||
capability?: ModelCapability;
|
||||
input?: ModelInputSummary;
|
||||
videoOperation?: string;
|
||||
videoSeconds?: string;
|
||||
};
|
||||
|
||||
export type DisplayModelGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export type ModelReferenceLimits = {
|
||||
maxImages: number;
|
||||
maxVideos: number;
|
||||
maxAudios: number;
|
||||
};
|
||||
|
||||
export function groupModelsByDisplayName(config: AiConfig, models: string[]): DisplayModelGroup[] {
|
||||
const groups = new Map<string, DisplayModelGroup>();
|
||||
models.forEach((model) => {
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const label = configuredModelDisplayName(config, model);
|
||||
const key = `${channel.id}\u0000${label.toLocaleLowerCase()}`;
|
||||
const current = groups.get(key);
|
||||
if (current) current.models.push(model);
|
||||
else groups.set(key, { key, label, models: [model] });
|
||||
});
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
export function configuredModelDisplayName(config: AiConfig, value: string) {
|
||||
const model = modelOptionName(value);
|
||||
const channel = resolveModelChannel(config, value);
|
||||
return channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim() || model;
|
||||
}
|
||||
|
||||
export function modelCompatibilityError(config: AiConfig, model: string, requirements?: ModelRequirements) {
|
||||
const capability = requirements?.capability;
|
||||
const input = requirements?.input;
|
||||
if (!capability || !input) return "";
|
||||
const visualInputCount = input.imageCount + input.characterCount;
|
||||
|
||||
if (capability === "image") {
|
||||
if (input.videoCount > 0) return "图片模型不支持参考视频";
|
||||
if (input.audioCount > 0) return "图片模型不支持参考音频";
|
||||
const maxImages = modelCapabilityConfigFor(config, model).image!.references.maxImages;
|
||||
if (visualInputCount > maxImages) return `最多支持 ${maxImages} 张参考图`;
|
||||
return "";
|
||||
}
|
||||
|
||||
if (capability === "video") {
|
||||
const profile = modelCapabilityConfigFor(config, model).video!;
|
||||
if (visualInputCount > profile.references.maxImages) return `最多支持 ${profile.references.maxImages} 张参考图`;
|
||||
if (input.videoCount > profile.references.maxVideos) return `最多支持 ${profile.references.maxVideos} 个参考视频`;
|
||||
if (input.audioCount > profile.references.maxAudios) return `最多支持 ${profile.references.maxAudios} 个参考音频`;
|
||||
if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长";
|
||||
const operation = resolveVideoOperation(input, requirements.videoOperation);
|
||||
if (operation !== "concat" && !profile.operations.includes(operation)) return `不支持${videoOperationLabel(operation)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
if (capability === "text") {
|
||||
return input.audioCount > 0 ? "文本模型不支持参考音频" : "";
|
||||
}
|
||||
|
||||
if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡";
|
||||
return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : "";
|
||||
}
|
||||
|
||||
export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) {
|
||||
const ordered = preferred && models.includes(preferred) ? [preferred, ...models.filter((model) => model !== preferred)] : models;
|
||||
return ordered.find((model) => !modelCompatibilityError(config, model, requirements)) || "";
|
||||
}
|
||||
|
||||
export function resolveCompatibleModel(config: AiConfig, selected: string, requirements?: ModelRequirements) {
|
||||
if (!requirements?.capability) return selected;
|
||||
const options = selectableModelsByCapability(config, requirements.capability);
|
||||
if (!options.length) return selected;
|
||||
const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected));
|
||||
if (!selectedGroup) return selected;
|
||||
return compatibleModelInGroup(config, selectedGroup.models, requirements, selected);
|
||||
}
|
||||
|
||||
export function maxModelInputCapacity(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio") {
|
||||
const options = selectableModelsByCapability(config, capability);
|
||||
if (!options.length) return null;
|
||||
return options.reduce((maximum, model) => {
|
||||
const profile = modelCapabilityConfigFor(config, model);
|
||||
const value =
|
||||
capability === "image" ? (kind === "image" ? profile.image!.references.maxImages : 0) : kind === "image" ? profile.video!.references.maxImages : kind === "video" ? profile.video!.references.maxVideos : profile.video!.references.maxAudios;
|
||||
return Math.max(maximum, value);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function modelGroupReferenceLimits(config: AiConfig, selected: string, capability: ModelCapability, requirements?: ModelRequirements): ModelReferenceLimits | undefined {
|
||||
if (capability !== "image" && capability !== "video") return undefined;
|
||||
const options = selectableModelsByCapability(config, capability);
|
||||
const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected));
|
||||
const groupModels = selectedGroup?.models || (selected ? [selected] : []);
|
||||
if (!groupModels.length) return undefined;
|
||||
const compatibleModels = requirements ? groupModels.filter((model) => !modelCompatibilityError(config, model, requirements)) : groupModels;
|
||||
const models = compatibleModels.length ? compatibleModels : groupModels;
|
||||
return models.reduce<ModelReferenceLimits>((limits, model) => {
|
||||
const profile = modelCapabilityConfigFor(config, model);
|
||||
if (capability === "image") {
|
||||
return { ...limits, maxImages: Math.max(limits.maxImages, profile.image!.references.maxImages) };
|
||||
}
|
||||
const references = profile.video!.references;
|
||||
return {
|
||||
maxImages: Math.max(limits.maxImages, references.maxImages),
|
||||
maxVideos: Math.max(limits.maxVideos, references.maxVideos),
|
||||
maxAudios: Math.max(limits.maxAudios, references.maxAudios),
|
||||
};
|
||||
}, { maxImages: 0, maxVideos: 0, maxAudios: 0 });
|
||||
}
|
||||
|
||||
export function inferVideoOperation(input: ModelInputSummary) {
|
||||
const visualInputCount = input.imageCount + input.characterCount;
|
||||
if (input.audioCount > 0 && visualInputCount === 0 && input.videoCount === 0) return "audio_to_video";
|
||||
if (input.videoCount > 0) return "extend";
|
||||
if (visualInputCount > 0) return "image_to_video";
|
||||
return "text_to_video";
|
||||
}
|
||||
|
||||
export function resolveVideoOperation(input: ModelInputSummary, storedOperation?: string) {
|
||||
if (storedOperation && !["text_to_video", "image_to_video", "audio_to_video", "extend"].includes(storedOperation)) return storedOperation;
|
||||
return inferVideoOperation(input);
|
||||
}
|
||||
|
||||
function videoOperationLabel(operation: string) {
|
||||
if (operation === "text_to_video") return "文生视频";
|
||||
if (operation === "image_to_video") return "图生视频";
|
||||
if (operation === "audio_to_video") return "音频生视频";
|
||||
if (operation === "extend") return "视频续写";
|
||||
return "当前生成模式";
|
||||
}
|
||||
Reference in New Issue
Block a user