feat(workflow): 工作流提供方 - 集成 RunningHub 与 ComfyUI Bridge

This commit is contained in:
yepwait
2026-08-26 15:02:24 +08:00
committed by ddCat
parent 6141bd688c
commit 86fc3fda2f
82 changed files with 11405 additions and 248 deletions

View File

@@ -88,6 +88,16 @@ export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
};
}
function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
return {
workflowProvider: undefined,
runningHubWorkflowId: undefined,
runningHubWorkflowKind: undefined,
comfyBridgeWorkflowId: undefined,
workflowParameters: undefined,
};
}
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
const prompt = node.metadata?.prompt || task.prompt;
@@ -125,7 +135,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
width: imageSize.width,
height: imageSize.height,
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
metadata: { ...node.metadata, ...imageMetadata(normalizedImage), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...imageMetadata(normalizedImage), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
};
}
@@ -154,7 +164,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
...node,
type: CanvasNodeType.Video,
...geometry,
metadata: { ...node.metadata, ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined },
};
}
@@ -163,7 +173,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
const audio = result.audio.storageKey
? { url: await resolveMediaUrl(result.audio.storageKey, result.audio.dataUrl), storageKey: result.audio.storageKey, durationMs: result.audio.durationMs, bytes: result.audio.bytes || 0, mimeType: result.audio.mimeType || "audio/mpeg" }
: await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), result.audio.format || "mp3");
return { ...node, type: CanvasNodeType.Audio, metadata: { ...node.metadata, ...audioMetadata(audio), prompt, ...completedTaskMetadata(task), errorDetails: undefined } };
return { ...node, type: CanvasNodeType.Audio, metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...audioMetadata(audio), prompt, ...completedTaskMetadata(task), errorDetails: undefined } };
}
if (!result.text) throw new Error("后端任务没有返回文本");

View File

@@ -1,16 +1,17 @@
import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowOutputSizeValue, workflowVideoFieldsFromJson } from "@/lib/model-capabilities";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
import type { ReferenceImage } from "@/types/image";
@@ -75,6 +76,8 @@ export async function runBackendCanvasGenerationTask(
}
export function canvasImageReferenceLimitError(config: AiConfig, referenceImages: ReferenceImage[]) {
// 工作流的参考素材上限由字段映射槽位决定,不能再套用当前普通图片模型的能力表。
if ((config.taskWorkflowProvider || "model") !== "model") return "";
const maxImages = modelCapabilityConfigFor(config, config.model).image?.references.maxImages;
if (maxImages === undefined || referenceImages.length <= maxImages) return "";
return `当前图片模型最多支持 ${maxImages} 张参考图,当前已连接 ${referenceImages.length} 张。请移除多余连线后重试`;
@@ -85,7 +88,9 @@ export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImage
if (error) throw new Error(error);
}
export { backendProviderConfig, logicalModelIDForConfig };
export { backendProviderConfig };
// 兼容故事板执行器的旧导入路径。
export { logicalModelIDForConfig };
const generationOperationLocks = new Map<string, Promise<unknown>>();
@@ -214,6 +219,7 @@ export function audioExtension(mimeType?: string) {
export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
...generationWorkflowMetadata(config),
generationType: type,
model: config.model,
size: config.size,
@@ -225,18 +231,19 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
}
export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null;
return {
id: node.id,
name: `reference-${node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
dataUrl: node.metadata.content || "",
storageKey: node.metadata.storageKey,
};
}
export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
return {
...generationWorkflowMetadata(config),
model: config.model,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
@@ -312,12 +319,17 @@ export function buildVideoGenerationMetadata(
referenceVideos: ReferenceVideo[];
referenceAudios: ReferenceAudio[];
},
config?: AiConfig,
): CanvasNodeMetadata {
const metadata = node?.metadata;
const referenceImageIds = new Set((context?.referenceImages || []).map((image) => image.id));
const startFrame = requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds);
const endFrame = requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds);
// 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。
// 这样旧节点切换到 RunningHub/ComfyUI 后,不会因为残留的首尾帧 ID 阻断生成。
const workflowVideo = node?.type === CanvasNodeType.Config && ((config?.taskWorkflowProvider === "runninghub" || config?.taskWorkflowProvider === "comfyui") || isCanvasWorkflowProvider(metadata));
const startFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds);
const endFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds);
return {
...(config ? generationWorkflowMetadata(config) : {}),
videoEditOperation: resolveVideoEditOperation(node, context),
videoCameraMoveId: metadata?.videoCameraMoveId,
videoCameraMovePrompt: metadata?.videoCameraMovePrompt,
@@ -343,8 +355,8 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
nodes.map(async (node) => {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type === CanvasNodeType.Image && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
}),
@@ -377,19 +389,52 @@ export function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
export function generationWorkflowMetadata(config: AiConfig): Pick<CanvasNodeMetadata, "workflowProvider" | "runningHubWorkflowId" | "runningHubWorkflowKind" | "comfyBridgeWorkflowId"> {
const provider = config.taskWorkflowProvider || "model";
return {
workflowProvider: provider,
runningHubWorkflowId: provider === "runninghub" ? config.runningHub.workflowId : undefined,
runningHubWorkflowKind: provider === "runninghub" ? config.runningHub.selectedKind : undefined,
comfyBridgeWorkflowId: provider === "comfyui" ? config.comfyBridge.workflowId : undefined,
};
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
// 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config ? resolveCanvasWorkflowProvider(node.metadata) : "model";
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 preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
const workflowParameters = node?.metadata?.workflowParameters || {};
const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim();
const selectedRunningHubWorkflow = workflowProvider === "runninghub"
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
: undefined;
const comfyBridgeWorkflowId = node?.metadata?.comfyBridgeWorkflowId?.trim() || config.comfyBridge.workflowId.trim();
const selectedComfyBridgeWorkflow = workflowProvider === "comfyui"
? config.comfyBridge.workflows.find((item) => item.workflowId.trim() === comfyBridgeWorkflowId)
: undefined;
const selectedWorkflowFields = workflowProvider === "runninghub"
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
: workflowProvider === "comfyui"
? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson)
: [];
const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
const workflowParameterValue = (source: string) => {
const value = workflowParameters[`source:${source}`];
return value === undefined || value === null ? undefined : String(value);
};
const requestedConfig: AiConfig = {
...config,
quality: node?.metadata?.quality ?? config.quality ?? defaultConfig.quality,
size: node?.metadata?.size ?? config.size ?? defaultConfig.size,
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
size: workflowProvider === "model"
? node?.metadata?.size ?? config.size ?? defaultConfig.size
: workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds ?? config.videoSeconds ?? defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node?.metadata?.vquality ?? config.vquality ?? defaultConfig.vquality),
videoSeconds: workflowProvider === "model" ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds),
vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality),
videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload,
@@ -402,42 +447,53 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const imageSize = mode === "image" ? requestedConfig.size : undefined;
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
: { capability: mode, options: liveOptions };
const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel;
const generationDefaults = resolveModelGenerationDefaults(
config,
model,
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
mode === "image"
? {
size: node?.metadata?.size,
quality: node?.metadata?.quality,
transparentBackground: node?.metadata?.transparentBackground,
const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
: { capability: mode, options: liveOptions };
const model = workflowProvider === "model"
? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel
: preferredModel;
const generationDefaults: Partial<ModelGenerationDefaults> = workflowProvider === "model"
? resolveModelGenerationDefaults(
config,
model,
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
mode === "image"
? {
size: node?.metadata?.size,
quality: node?.metadata?.quality,
transparentBackground: node?.metadata?.transparentBackground,
count: requestedConfig.count,
}
: {
size: node?.metadata?.size,
videoSeconds: node?.metadata?.seconds,
vquality: node?.metadata?.vquality,
videoGenerateAudio: node?.metadata?.generateAudio,
videoWatermark: node?.metadata?.watermark,
},
{
size: requestedConfig.size,
quality: requestedConfig.quality,
transparentBackground: requestedConfig.transparentBackground,
count: requestedConfig.count,
}
: {
size: node?.metadata?.size,
videoSeconds: node?.metadata?.seconds,
vquality: node?.metadata?.vquality,
videoGenerateAudio: node?.metadata?.generateAudio,
videoWatermark: node?.metadata?.watermark,
videoSeconds: requestedConfig.videoSeconds,
vquality: requestedConfig.vquality,
videoGenerateAudio: requestedConfig.videoGenerateAudio,
videoWatermark: requestedConfig.videoWatermark,
},
{
size: requestedConfig.size,
quality: requestedConfig.quality,
transparentBackground: requestedConfig.transparentBackground,
count: requestedConfig.count,
videoSeconds: requestedConfig.videoSeconds,
vquality: requestedConfig.vquality,
videoGenerateAudio: requestedConfig.videoGenerateAudio,
videoWatermark: requestedConfig.videoWatermark,
},
);
)
: {};
const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows };
const comfyBridge = { ...config.comfyBridge, enabled: workflowProvider === "comfyui" && config.comfyBridge.enabled, workflowId: comfyBridgeWorkflowId, capability: selectedComfyBridgeWorkflow?.capability || modeCapability, workflows: workflowProvider === "comfyui" ? config.comfyBridge.workflows.map((item) => item.workflowId.trim() === comfyBridgeWorkflowId ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.comfyBridge.workflows };
return {
...requestedConfig,
taskWorkflowProvider: workflowProvider,
runningHub,
comfyBridge,
model,
quality: generationDefaults.quality || requestedConfig.quality,
size: generationDefaults.size || requestedConfig.size,
@@ -446,6 +502,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
vquality: generationDefaults.vquality || requestedConfig.vquality,
videoGenerateAudio: generationDefaults.videoGenerateAudio || requestedConfig.videoGenerateAudio,
videoWatermark: generationDefaults.videoWatermark || requestedConfig.videoWatermark,
videoArkPrivateAssetUpload: requestedConfig.videoArkPrivateAssetUpload,
count: generationDefaults.count || requestedConfig.count,
};
}
@@ -457,6 +514,28 @@ export function resolveCanvasGenerationModel(config: AiConfig, model: string | u
return configuredModelMatchesCapability(config, normalized, mode) ? normalized : "";
}
function applyWorkflowParameterValues(fields: WorkflowFieldMapping[] | undefined, values: Record<string, unknown>) {
if (!fields?.length || !Object.keys(values).length) return fields;
return fields.map((field) => {
const key = workflowFieldKey(field);
const randomKey = workflowFieldRandomKey(field);
const hasValue = workflowFieldHasStoredValue(field, values);
const hasRandom = Object.prototype.hasOwnProperty.call(values, randomKey);
if (!hasValue && !hasRandom) return field;
const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values));
// 画布动态参数按具体字段覆盖,不能继续走 aspectRatio/quality 等全局来源,
// 否则同一工作流中的两个同类参数会被折叠成同一个值。
return {
...field,
...(hasValue ? { fieldValue: value, value } : {}),
source: "",
sourceAutomatic: false,
sourceFromUpstream: false,
randomEnabled: hasRandom ? values[randomKey] === true : hasValue ? false : field.randomEnabled,
};
});
}
export function supportsVideoReferenceAudio(config: AiConfig) {
const interfaceType = resolveModelRequestConfig(config, config.model).interfaceType;
return interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || isSeedanceVideoConfig(config);

View File

@@ -0,0 +1,19 @@
import type { CanvasNodeMetadata } from "@/types/canvas";
export type CanvasWorkflowProvider = "model" | "runninghub" | "comfyui";
/**
* 统一解析画布节点的工作流渠道;旧节点可能没有 workflowProvider,
* 但已经保存了具体工作流 ID,此时不能退回普通模型的首尾帧逻辑。
*/
export function resolveCanvasWorkflowProvider(metadata?: CanvasNodeMetadata): CanvasWorkflowProvider {
if (metadata?.workflowProvider) return metadata.workflowProvider;
if (metadata?.runningHubWorkflowId?.trim()) return "runninghub";
if (metadata?.comfyBridgeWorkflowId?.trim()) return "comfyui";
return "model";
}
export function isCanvasWorkflowProvider(metadata?: CanvasNodeMetadata) {
const provider = resolveCanvasWorkflowProvider(metadata);
return provider === "runninghub" || provider === "comfyui";
}

View File

@@ -1,4 +1,4 @@
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video, Workflow } from "lucide-react";
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
@@ -39,6 +39,8 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
extensionCommand(CanvasNodeType.Compare, 50),
extensionCommand(CanvasNodeType.Chart, 60),
extensionCommand(CanvasNodeType.ColorGrade, 70),
// 云端和本地工作流共用独立配置节点,不进入基础模型节点的渠道选择。
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },

View File

@@ -38,6 +38,7 @@ export type ToolbarHandlers = {
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
onAddWorkflow: () => void;
/**
* 扩展节点(Markdown / SVG / HTML / 全景 / 对比 / 图表 / 调色)统一走这一个入口。
* 不给每种扩展节点单开一个 onAddXxx —— 那会让 ToolbarHandlers 随节点数线性膨胀,
@@ -140,6 +141,7 @@ export type AddNodeMenuContext = {
| "onAddFrame"
| "onAddFolder"
| "onAddDrawing"
| "onAddWorkflow"
| "onAddExtensionNode"
| "onChooseStyle"
| "onOpenDirector"
@@ -181,7 +183,7 @@ export type AddNodeMenuCommand = {
icon: ReactNode;
badge?: string;
// extension:展示与加工类扩展节点。单独一区,避免挤散 node 区调好的四列网格。
section: "node" | "extension" | "project" | "resource";
section: "node" | "extension" | "workflow" | "project" | "resource";
defaultOrder: number;
applicable?: (ctx: AddNodeMenuContext) => boolean;
run: (ctx: AddNodeMenuContext) => void;

View File

@@ -0,0 +1,100 @@
import { normalizeRunningHubCapability, type AiConfig, type RunningHubCapability, type RunningHubWorkflowKind, type WorkflowFieldMapping } from "@/stores/use-config-store";
export type GenerationWorkflowMode = "text" | "image" | "video" | "audio";
export type GenerationWorkflowExecution = {
provider: "runninghub" | "comfyui-bridge";
kind: RunningHubWorkflowKind;
capability: RunningHubCapability;
interfaceType: string;
name: string;
taskModel: string;
providerModel: string;
workflowId: string;
webappId: string;
workflowJson: Record<string, unknown>;
workflowFields: WorkflowFieldMapping[];
bridgeId: string;
};
// 画布样式计划、任务记录和后端协议必须共用同一个工作流选择结果,避免再次回退到普通模型。
export function resolveGenerationWorkflowExecution(config: AiConfig, mode: GenerationWorkflowMode): GenerationWorkflowExecution | null {
const workflowProvider = mode === "text" ? "model" : config.taskWorkflowProvider || "model";
if (workflowProvider === "model") return null;
if (workflowProvider === "runninghub") {
const runningHub = config.runningHub;
const workflowId = runningHub.workflowId.trim();
const kind = runningHub.selectedKind === "app" ? "app" : "workflow";
// RunningHub 工作流的参数拉取、提交和轮询固定使用积分 API Key;企业级 Key 只用于素材上传。
const apiKey = runningHub.apiKey;
if (!runningHub.enabled || !runningHub.baseUrl.trim() || !apiKey.trim() || !workflowId) {
throw new Error("RunningHub 工作流配置不完整,请填写积分 API Key 并选择已保存条目");
}
const workflow = runningHub.workflows.find((item) => item.workflowId.trim() === workflowId && (item.kind === "app" ? "app" : "workflow") === kind);
if (!workflow) throw new Error("当前 RunningHub 工作流条目不存在,请重新选择已保存条目");
const capability = normalizeRunningHubCapability(workflow.capability, normalizeRunningHubCapability(runningHub.capability));
assertWorkflowCapability("RunningHub", capability, mode);
const webappId = kind === "app" ? (workflow.webappId || workflow.workflowId).trim() : "";
const name = workflow.title?.trim() || webappId || workflowId;
return {
provider: "runninghub",
kind,
capability,
interfaceType: `runninghub-workflow-${capability}`,
name,
taskModel: workflowTaskModel("RunningHub · ", name),
providerModel: workflowId,
workflowId: kind === "workflow" ? workflowId : "",
webappId,
workflowJson: workflow.workflowJson || {},
workflowFields: workflow.fields || [],
bridgeId: "",
};
}
const comfyBridge = config.comfyBridge;
const workflowId = comfyBridge.workflowId.trim();
if (!comfyBridge.enabled || !comfyBridge.bridgeId.trim() || !workflowId) {
throw new Error("ComfyUI Bridge 配置不完整,请检查 Bridge 在线状态和已保存工作流");
}
const workflow = comfyBridge.workflows.find((item) => item.workflowId.trim() === workflowId);
if (!workflow) throw new Error("当前 ComfyUI 工作流条目不存在,请重新选择已保存条目");
const capability = normalizeRunningHubCapability(workflow.capability, normalizeRunningHubCapability(comfyBridge.capability));
assertWorkflowCapability("ComfyUI", capability, mode);
const name = workflow.title?.trim() || workflowId;
return {
provider: "comfyui-bridge",
kind: "workflow",
capability,
interfaceType: `comfyui-bridge-${capability}`,
name,
taskModel: workflowTaskModel("ComfyUI · ", name),
providerModel: workflowId,
workflowId,
webappId: "",
workflowJson: workflow.workflowJson || {},
workflowFields: workflow.fields || [],
bridgeId: comfyBridge.bridgeId.trim(),
};
}
function assertWorkflowCapability(providerName: string, capability: RunningHubCapability, mode: GenerationWorkflowMode) {
if (capability === mode) return;
const capabilityName = workflowCapabilityName(capability);
const modeName = workflowCapabilityName(mode);
throw new Error(`${providerName} 已保存条目的用途为${capabilityName},当前节点是${modeName}生成,请切换匹配的工作流`);
}
function workflowCapabilityName(value: GenerationWorkflowMode) {
if (value === "video") return "视频";
if (value === "audio") return "音频";
if (value === "text") return "文本";
return "图片";
}
function workflowTaskModel(prefix: string, name: string) {
return `${prefix}${Array.from(name)
.slice(0, Math.max(0, 120 - prefix.length))
.join("")}`;
}

View File

@@ -360,6 +360,509 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
return { ...fallback, ...capabilityConfig, text, image, video };
}
// 工作流字段是供应商参数的唯一事实来源;不能用普通视频模型的固定清晰度列表覆盖它。
export type WorkflowVideoFieldLike = {
nodeId?: string;
classType?: string;
fieldName?: string;
label?: string;
role?: string;
safeToOverride?: boolean;
optionsSource?: string;
enabled?: boolean;
source?: string;
sourceFromUpstream?: boolean;
randomEnabled?: boolean;
fieldType?: string;
options?: unknown[];
fieldValue?: unknown;
value?: unknown;
default?: unknown;
defaultValue?: unknown;
min?: unknown;
max?: unknown;
step?: unknown;
};
export type WorkflowFieldNumberBounds = { min?: number; max?: number; step?: number };
export function workflowFieldKey(field: WorkflowVideoFieldLike) {
return `field:${String(field.nodeId || "").trim()}:${String(field.fieldName || "").trim()}`;
}
export function workflowFieldRandomKey(field: WorkflowVideoFieldLike) {
return `random:${String(field.nodeId || "").trim()}:${String(field.fieldName || "").trim()}`;
}
export function workflowFieldSource(field: WorkflowVideoFieldLike) {
return String(field.source || "").trim().replace(/[\s_-]/g, "").toLowerCase();
}
export function workflowParameterFields(fields: readonly WorkflowVideoFieldLike[]) {
return fields.filter((field) => {
const source = workflowFieldSource(field);
const fieldType = String(field.fieldType || "").trim().toUpperCase();
if (!field.fieldName || !field.nodeId || field.enabled === false || !workflowFieldSafeToOverride(field)) return false;
if (["prompt", "text", "positiveprompt", "positive", "referenceimage", "image", "referencevideo", "video", "referenceaudio", "audio", "mask"].includes(source)) return false;
if (["IMAGE", "VIDEO", "AUDIO"].includes(fieldType)) return false;
return true;
});
}
export function workflowFieldSafeToOverride(field: WorkflowVideoFieldLike) {
if (field.safeToOverride === false) return false;
const classType = String(field.classType || "").trim().toLowerCase();
const fieldName = String(field.fieldName || "").trim().toLowerCase();
if (classType === "int" && fieldName === "value") return false;
return classType !== "imageresize+" || !["width", "height", "multiple_of"].includes(fieldName);
}
export function workflowFieldRole(field: WorkflowVideoFieldLike) {
if (["prompt", "media", "business", "internal"].includes(String(field.role || ""))) return String(field.role);
const source = workflowFieldSource(field);
const fieldType = String(field.fieldType || "").trim().toUpperCase();
if (["prompt", "text", "positiveprompt", "positive"].includes(source)) return "prompt";
if (["referenceimage", "image", "referencevideo", "video", "referenceaudio", "audio", "mask"].includes(source) || ["IMAGE", "VIDEO", "AUDIO"].includes(fieldType)) return "media";
if (!workflowFieldSafeToOverride(field)) return "internal";
const key = normalizeWorkflowVideoFieldKey(String(field.fieldName || ""));
if (["aspectratio", "ratio", "duration", "durationseconds", "seconds", "videoseconds", "quality", "resolution", "seed", "noiseseed", "steps", "step", "sigmapoints", "cfg", "cfgscale", "guidance", "guidancescale", "sampler", "samplername", "scheduler", "fps", "count", "batch", "batchsize", "generateaudio", "watermark", "negativeprompt", "systemprompt"].includes(key)) return "business";
return field.classType ? "internal" : "business";
}
/** 读取工作流字段声明的连续数值范围;兼容 RunningHub 将范围包在 options/range 中的返回格式。 */
export function workflowFieldNumberBounds(field: WorkflowVideoFieldLike | undefined): WorkflowFieldNumberBounds {
if (!field) return {};
const sources: Record<string, unknown>[] = [field as unknown as Record<string, unknown>];
const options = Array.isArray(field.options) ? field.options : [];
options.forEach((option) => {
if (!option || typeof option !== "object" || Array.isArray(option)) return;
const item = option as Record<string, unknown>;
const nested = item.range;
if (nested && typeof nested === "object" && !Array.isArray(nested)) sources.push(nested as Record<string, unknown>);
if (["min", "max", "step", "minValue", "maxValue", "stepValue"].some((key) => item[key] !== undefined)) sources.push(item);
});
const read = (keys: string[]) => {
for (const source of sources) {
for (const key of keys) {
const raw = source[key];
if (raw === undefined || raw === null || String(raw).trim() === "") continue;
const value = Number(raw);
if (Number.isFinite(value)) return value;
}
}
return undefined;
};
return { min: read(["min", "minValue", "min_value"]), max: read(["max", "maxValue", "max_value"]), step: read(["step", "stepValue", "step_value"]) };
}
/** 只有真正的枚举项才作为下拉选项;范围对象交给 InputNumber。 */
export function workflowFieldChoiceValues(field: WorkflowVideoFieldLike | undefined) {
const bounds = workflowFieldNumberBounds(field);
if (bounds.min !== undefined && bounds.max !== undefined && bounds.step !== undefined) return [];
const protocolOptions = workflowFieldChoiceValuesFromProtocol(field);
if (protocolOptions.length) return protocolOptions;
const options = Array.isArray(field?.options) ? field.options : [];
return options.filter((option) => !workflowFieldOptionIsRange(option));
}
/** ComfyUI 的枚举按完整字符串校验;旧画布中的比例简写只能映射到唯一同前缀原值。 */
export function workflowFieldSubmissionValue(field: WorkflowVideoFieldLike, value: unknown) {
const choices = workflowFieldSubmissionChoices(field);
const submitted = workflowFieldOptionValue(value);
if (!choices.length || choices.some((choice) => workflowFieldOptionValue(choice) === submitted)) return value;
const ratio = workflowRatioPrefix(submitted);
if (!ratio) return value;
const matches = choices.filter((choice) => workflowRatioPrefix(workflowFieldOptionValue(choice)) === ratio);
return matches.length === 1 ? workflowFieldOptionValue(matches[0]) : value;
}
const resolutionSelectorAspectRatioOptions = [
"1:1 (Square)",
"2:3 (Portrait Photo)",
"3:2 (Photo)",
"3:4 (Portrait Standard)",
"4:3 (Standard)",
"9:16 (Portrait Widescreen)",
"16:9 (Widescreen)",
"21:9 (Ultrawide)",
];
function workflowFieldSubmissionChoices(field: WorkflowVideoFieldLike) {
const classType = normalizeWorkflowVideoFieldKey(String(field.classType || ""));
const fieldName = normalizeWorkflowVideoFieldKey(String(field.fieldName || ""));
// RunningHub 的 API JSON 只有当前值,没有 ComfyUI object_info。该节点的真实枚举
// 来自节点类型合同,不能使用通用比例模板冒充上游 options。
if (classType === "resolutionselector" && fieldName === "aspectratio") return resolutionSelectorAspectRatioOptions;
return workflowFieldChoiceValues(field);
}
function workflowRatioPrefix(value: string) {
const match = value.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)(?:\s|\(|$)/);
return match ? `${match[1]}:${match[2]}` : "";
}
const workflowKnownOptions: Record<string, string[]> = {
aspectratio: ["1:1", "16:9", "9:16", "4:3", "3:4", "4:5", "5:4", "3:2", "2:3", "21:9", "9:21"],
ratio: ["1:1", "16:9", "9:16", "4:3", "3:4", "4:5", "5:4", "3:2", "2:3", "21:9", "9:21"],
resolution: ["512", "768", "1024", "1280", "1536", "2048", "1k", "2k", "4k"],
size: ["512", "768", "1024", "1280", "1536", "2048"],
sampler: ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", "lms", "dpmpp_2m", "dpmpp_sde", "ddim", "uni_pc"],
samplername: ["euler", "euler_ancestral", "heun", "dpm_2", "dpm_2_ancestral", "lms", "dpmpp_2m", "dpmpp_sde", "ddim", "uni_pc"],
scheduler: ["normal", "karras", "exponential", "sgm_uniform", "simple", "ddim_uniform", "beta"],
};
export function workflowFieldPresetOptions(field: WorkflowVideoFieldLike | undefined) {
if (!field) return [];
const fieldType = String(field.fieldType || "").trim().toUpperCase();
if (["NUMBER", "FLOAT", "INTEGER", "INT", "SLIDER", "BOOLEAN", "BOOL", "IMAGE", "VIDEO", "AUDIO"].includes(fieldType)) return [];
const classType = normalizeWorkflowVideoFieldKey(String(field.classType || ""));
const key = normalizeWorkflowVideoFieldKey(String(field.fieldName || ""));
if (classType === "resolutionselector" && key === "aspectratio") return resolutionSelectorAspectRatioOptions;
return workflowKnownOptions[key] || [];
}
export function workflowFieldConfigurationError(field: WorkflowVideoFieldLike) {
const bounds = workflowFieldNumberBounds(field);
if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) return "最小值不能大于最大值";
if (bounds.step !== undefined && bounds.step <= 0) return "步长必须大于 0";
return "";
}
export function workflowFieldValueError(field: WorkflowVideoFieldLike, value: unknown) {
const configurationError = workflowFieldConfigurationError(field);
if (configurationError) return configurationError;
const fieldType = String(field.fieldType || "").trim().toUpperCase();
const bounds = workflowFieldNumberBounds(field);
const numeric = ["NUMBER", "FLOAT", "INTEGER", "INT", "SLIDER"].includes(fieldType) || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined;
if (numeric) {
if (value === undefined || value === null || String(value).trim() === "" || !Number.isFinite(Number(value))) return "请输入有效数字";
const parsed = Number(value);
if (bounds.min !== undefined && parsed < bounds.min || bounds.max !== undefined && parsed > bounds.max) return "数值超出允许范围";
if (bounds.step !== undefined) {
const start = bounds.min ?? 0;
const steps = (parsed - start) / bounds.step;
if (Math.abs(steps - Math.round(steps)) > 1e-7) return "数值不符合步长";
}
}
const options = workflowFieldChoiceValues(field).map(workflowFieldOptionValue);
if (options.length && !options.includes(workflowFieldOptionValue(value))) return "当前值不在允许选项中";
return "";
}
export function workflowImageCapabilityConfig(fields: readonly WorkflowVideoFieldLike[], fallback = defaultModelCapabilityConfig().image!): ImageCapabilityConfig {
const ratioField = fields.find((field) => workflowVideoFieldMatches(field, "aspectratio"));
const sizeField = fields.find((field) => workflowVideoFieldMatches(field, "size"));
const qualityField = fields.find((field) => workflowVideoFieldMatches(field, "quality"));
const ratioOptions = workflowFieldChoiceValues(ratioField).map(workflowFieldOptionValue).filter(Boolean);
const ratioDefault = workflowFieldDefaultValue(ratioField);
const sizeOptions = workflowFieldChoiceValues(sizeField).map(workflowFieldOptionValue).filter(Boolean);
const sizeDefault = workflowFieldDefaultValue(sizeField);
const qualityOptions = workflowFieldChoiceValues(qualityField).map(workflowFieldOptionValue).filter(Boolean);
return {
...fallback,
size: ratioField
? { parameter: "aspect_ratio", values: ratioOptions.length ? ratioOptions : ratioDefault ? [ratioDefault] : [], default: ratioDefault || ratioOptions[0] || "auto", allowCustom: false }
: sizeField
? { parameter: "size", values: sizeOptions.length ? sizeOptions : sizeDefault ? [sizeDefault] : [], default: sizeDefault || sizeOptions[0] || "auto", allowCustom: false }
: { ...fallback.size, values: [], default: "auto", allowCustom: false },
quality: qualityField
? { supported: qualityOptions.length > 0, values: qualityOptions, default: workflowFieldDefaultValue(qualityField) || qualityOptions[0] || "auto" }
: { supported: false, values: [], default: "auto" },
transparentBackground: { supported: false, default: false },
maxOutputs: 1,
};
}
export function workflowVideoCapabilityConfig(fields: readonly WorkflowVideoFieldLike[], fallback = defaultModelCapabilityConfig().video!): VideoCapabilityConfig {
const profile: VideoCapabilityConfig = {
...fallback,
references: { ...fallback.references },
duration: { ...fallback.duration, values: fallback.duration.values ? [...fallback.duration.values] : undefined },
ratios: [...fallback.ratios],
resolutions: [...fallback.resolutions],
generateAudio: { ...fallback.generateAudio },
watermark: { ...fallback.watermark },
};
// 工作流没有统一的分辨率/时长协议。清空普通模型的候选值,
// 后面只根据工作流字段自身声明的 options 或数值范围恢复控件。
profile.resolutions = [];
profile.defaultResolution = "";
profile.ratios = [];
profile.defaultRatio = "";
profile.duration = { selection: "enum", values: [], default: fallback.duration.default };
profile.generateAudio = { supported: false, default: false };
profile.watermark = { supported: false, default: false };
const resolutionField = fields.find((field) => workflowVideoFieldMatches(field, "vquality"));
const durationField = fields.find((field) => workflowVideoFieldMatches(field, "videoseconds"));
const ratioField = fields.find((field) => workflowVideoFieldMatches(field, "aspectratio"));
if (resolutionField) {
const options = workflowFieldChoiceValues(resolutionField).map(workflowFieldOptionValue).filter(Boolean);
const bounds = workflowFieldNumberBounds(resolutionField);
const generated = options.length ? options : bounds.min !== undefined && bounds.max !== undefined ? [] : workflowNumericFieldValues(resolutionField);
const defaultValue = workflowFieldDefaultValue(resolutionField);
if (bounds.min === undefined || bounds.max === undefined || bounds.max < bounds.min) {
if (generated.length) profile.resolutions = generated;
}
else if (defaultValue !== "") profile.resolutions = [defaultValue];
if (profile.resolutions.length) profile.defaultResolution = matchWorkflowValue(defaultValue, profile.resolutions) || profile.resolutions[0];
}
if (durationField) {
const options = workflowFieldChoiceValues(durationField).map(workflowFieldOptionValue).map(workflowDurationNumber).filter((value): value is number => value !== undefined);
const bounds = workflowFieldNumberBounds(durationField);
const generated = options.length ? options : workflowNumericFieldValues(durationField).map(Number).filter(Number.isFinite);
const defaultValue = Number(workflowFieldDefaultValue(durationField));
if (generated.length) {
profile.duration = { selection: "enum", values: [...new Set(generated)], default: Number.isFinite(defaultValue) ? defaultValue : generated[0] };
} else {
const min = bounds.min;
const max = bounds.max;
const step = bounds.step;
if (min !== undefined && max !== undefined && max >= min) {
profile.duration = { selection: "range", min, max, ...(step !== undefined && step > 0 ? { step } : {}), default: Number.isFinite(defaultValue) ? defaultValue : min };
} else if (Number.isFinite(defaultValue)) {
// 只有默认值时不能臆造通用时长选项,只保留工作流当前值。
profile.duration = { selection: "enum", values: [defaultValue], default: defaultValue };
}
}
}
if (ratioField) {
const options = workflowFieldOptionValues(ratioField.options);
const defaultValue = workflowFieldDefaultValue(ratioField);
// 工作流声明了比例字段时,不能继续沿用普通模型的比例列表。
// 没有 options 时只保留该字段当前默认值(例如 `auto`)。
profile.ratios = options.length ? options : defaultValue ? [defaultValue] : [];
if (profile.ratios.length) profile.defaultRatio = matchWorkflowValue(defaultValue, profile.ratios) || profile.ratios[0];
}
const generateAudioField = fields.find((field) => workflowBooleanFieldMatches(field, "generateaudio"));
const watermarkField = fields.find((field) => workflowBooleanFieldMatches(field, "watermark"));
if (generateAudioField) profile.generateAudio = { supported: true, default: workflowBooleanDefault(generateAudioField) };
if (watermarkField) profile.watermark = { supported: true, default: workflowBooleanDefault(watermarkField) };
return profile;
}
/** 读取工作流当前选择的比例/尺寸;字段没有语义名时仅识别纯尺寸枚举。 */
export function workflowOutputSizeValue(fields: readonly WorkflowVideoFieldLike[], values: Readonly<Record<string, unknown>>) {
const field = fields.find((item) => workflowVideoFieldMatches(item, "aspectratio"))
|| fields.find((item) => workflowVideoFieldMatches(item, "size"))
|| fields.find((item) => {
const options = workflowFieldChoiceValues(item).map(workflowFieldOptionValue).filter(Boolean);
return options.length > 1 && options.every(workflowOutputSizeLike);
});
const value = workflowFieldCurrentValue(field, values);
const submitted = !field || value === undefined ? "" : workflowFieldOptionValue(workflowFieldSubmissionValue(field, value));
return submitted || workflowVideoDefaultSize(fields, values);
}
/** 从工作流同一节点的 width/height 字段读取默认输出尺寸。 */
export function workflowVideoDefaultSize(fields: readonly WorkflowVideoFieldLike[], values: Readonly<Record<string, unknown>> = {}) {
const candidates = fields.filter((field) => workflowDimensionFieldMatches(field, "width") || workflowDimensionFieldMatches(field, "height"));
const groups = Array.from(new Set(candidates.map((field) => String(field.nodeId || "").trim()).filter(Boolean)));
const orderedGroups = groups.sort((left, right) => {
const leftResize = candidates.some((field) => String(field.nodeId || "").trim() === left && String(field.label || "").toLowerCase().includes("imageresize"));
const rightResize = candidates.some((field) => String(field.nodeId || "").trim() === right && String(field.label || "").toLowerCase().includes("imageresize"));
return Number(leftResize) - Number(rightResize);
});
for (const nodeId of orderedGroups) {
const nodeFields = candidates.filter((field) => String(field.nodeId || "").trim() === nodeId);
const width = nodeFields.find((field) => workflowDimensionFieldMatches(field, "width"));
const height = nodeFields.find((field) => workflowDimensionFieldMatches(field, "height"));
const widthValue = workflowNumber(workflowFieldCurrentValue(width, values));
const heightValue = workflowNumber(workflowFieldCurrentValue(height, values));
if (widthValue !== undefined && widthValue > 0 && heightValue !== undefined && heightValue > 0) return `${Math.round(widthValue)}x${Math.round(heightValue)}`;
}
return "";
}
export function workflowVideoFieldsFromJson(value: Record<string, unknown> | undefined) {
if (!value || typeof value !== "object") return [] as WorkflowVideoFieldLike[];
const fields: WorkflowVideoFieldLike[] = [];
Object.entries(value).forEach(([nodeId, rawNode]) => {
if (!rawNode || typeof rawNode !== "object" || Array.isArray(rawNode)) return;
const node = rawNode as Record<string, unknown>;
const classType = String(node.class_type || node.type || "").trim();
const inputs = node.inputs;
if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) return;
Object.entries(inputs as Record<string, unknown>).forEach(([fieldName, fieldValue]) => {
if (Array.isArray(fieldValue)) return;
const fieldType = typeof fieldValue === "number" ? "NUMBER" : typeof fieldValue === "boolean" ? "BOOLEAN" : "TEXT";
const candidate = { nodeId, classType, fieldName, fieldValue, fieldType };
const safeToOverride = workflowFieldSafeToOverride(candidate);
const role = workflowFieldRole(candidate);
fields.push({ ...candidate, safeToOverride, role, enabled: safeToOverride && role !== "internal" });
});
});
return fields;
}
function workflowVideoFieldMatches(field: WorkflowVideoFieldLike, source: string) {
const keys = [field.source, field.fieldName, field.label].map((value) => normalizeWorkflowVideoFieldKey(String(value || ""))).filter(Boolean);
if (source === "vquality") {
// quality 是工作流自己的质量参数(可能是 0.1-3 这类连续值),
// 只有显式 videoquality/videoresolution/vquality 才属于视频分辨率。
if (workflowFieldSource(field) === "quality" || keys.some((key) => ["quality", "imagequality"].includes(key))) return false;
return keys.some((key) => ["vquality", "videoresolution", "videoquality", "resolution"].includes(key) || key.includes("清晰度") || key.includes("分辨率"));
}
if (source === "videoseconds") {
return keys.some((key) => ["videoseconds", "duration", "seconds", "durationseconds", "videoduration", "videodurationseconds", "videolength", "clipduration"].includes(key) || key.includes("时长") || key.includes("秒数"));
}
if (source === "size") {
return keys.some((key) => ["size", "imagesize", "imageresolution", "resolution"].includes(key));
}
if (source === "quality") {
return workflowFieldSource(field) === "quality" || keys.some((key) => ["quality", "imagequality"].includes(key) || key.includes("清晰度"));
}
return keys.some((key) => ["aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio"].includes(key));
}
function workflowDimensionFieldMatches(field: WorkflowVideoFieldLike, dimension: "width" | "height") {
const keys = [field.source, field.fieldName, field.label].map((value) => normalizeWorkflowVideoFieldKey(String(value || ""))).filter(Boolean);
return keys.some((key) => key === dimension || key.endsWith(dimension) || key.includes(`${dimension}pixels`));
}
function workflowBooleanFieldMatches(field: WorkflowVideoFieldLike, name: "generateaudio" | "watermark") {
const keys = [field.source, field.fieldName, field.label].map((value) => normalizeWorkflowVideoFieldKey(String(value || ""))).filter(Boolean);
return keys.some((key) => key === name || key.endsWith(name));
}
function workflowBooleanDefault(field: WorkflowVideoFieldLike) {
const value = workflowFieldDefaultValue(field).toLowerCase();
return value === "true" || value === "1" || value === "yes";
}
function normalizeWorkflowVideoFieldKey(value: string) {
return String(value).toLowerCase().replace(/[\s_-]/g, "");
}
function workflowFieldOptionValues(options: unknown[] | undefined) {
if (!Array.isArray(options)) return [] as string[];
return options.filter((option) => !workflowFieldOptionIsRange(option)).map(workflowFieldOptionValue).filter(Boolean);
}
function workflowFieldChoiceValuesFromProtocol(field: WorkflowVideoFieldLike | undefined) {
const explicit = workflowFieldOptionValues(field?.options);
if (explicit.length) return explicit;
const classType = normalizeWorkflowVideoFieldKey(String(field?.classType || ""));
const fieldName = normalizeWorkflowVideoFieldKey(String(field?.fieldName || ""));
// RunningHub 对该节点有固定枚举,但 getJsonApiFormat 可能只返回默认值;
// 只有节点类型和字段名同时匹配时才补齐,避免把所有工作流比例强制成同一套选项。
if (classType === "resolutionselector" && fieldName === "aspectratio") return resolutionSelectorAspectRatioOptions;
return [] as string[];
}
function workflowFieldOptionIsRange(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const item = value as Record<string, unknown>;
const nested = item.range;
return [item, nested && typeof nested === "object" && !Array.isArray(nested) ? nested as Record<string, unknown> : undefined]
.some((candidate) => candidate && ["min", "max", "step", "minValue", "maxValue", "stepValue"].some((key) => candidate[key] !== undefined));
}
function workflowFieldOptionValue(value: unknown): string {
if (value && typeof value === "object" && !Array.isArray(value)) {
const item = value as Record<string, unknown>;
for (const key of ["value", "id", "key", "name", "label"]) {
if (item[key] !== undefined && item[key] !== null && String(item[key]).trim()) return String(item[key]).trim();
}
}
return value === undefined || value === null ? "" : String(value).trim();
}
function workflowOutputSizeLike(value: string) {
return /^\d+(?:\.\d+)?(?:x|:)\d+(?:\.\d+)?(?:-|$)/i.test(value.trim());
}
function workflowFieldDefaultValue(field: WorkflowVideoFieldLike | undefined) {
if (!field) return "";
return workflowFieldOptionValue(field.fieldValue ?? field.defaultValue ?? field.value ?? field.default);
}
/**
* 读取画布当前的工作流字段值。新版本按 nodeId + fieldName 保存;旧画布曾按
* source 语义键保存,比例字段必须兼容读取,否则会静默回退到工作流默认的 1:1。
*/
export function workflowFieldCurrentValue(field: WorkflowVideoFieldLike | undefined, values: Readonly<Record<string, unknown>>) {
if (!field) return undefined;
for (const key of [workflowFieldKey(field), ...workflowFieldLegacyKeys(field)]) {
if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
}
// 旧画布和新工作流 schema 可能分别使用 aspectRatio / aspect_ratio,
// 字段语义相同但存储键不同。读取时按节点号和规范化字段名兼容,
// 避免找不到用户已选值后回退到工作流或全局默认比例。
const nodeKey = normalizeWorkflowParameterPart(field.nodeId);
const fieldKeys = [field.fieldName, field.source, field.label]
.map((value) => normalizeWorkflowParameterPart(value))
.filter(Boolean);
if (nodeKey && fieldKeys.length) {
for (const [key, value] of Object.entries(values)) {
const match = key.match(/^field:([^:]+):(.+)$/i);
if (!match) continue;
if (normalizeWorkflowParameterPart(match[1]) === nodeKey && fieldKeys.includes(normalizeWorkflowParameterPart(match[2]))) return value;
}
}
return field.fieldValue ?? field.defaultValue ?? field.value ?? field.default;
}
export function workflowFieldHasStoredValue(field: WorkflowVideoFieldLike | undefined, values: Readonly<Record<string, unknown>>) {
if (!field) return false;
if ([workflowFieldKey(field), ...workflowFieldLegacyKeys(field)].some((key) => Object.prototype.hasOwnProperty.call(values, key))) return true;
const nodeKey = normalizeWorkflowParameterPart(field.nodeId);
const fieldKeys = [field.fieldName, field.source, field.label]
.map((value) => normalizeWorkflowParameterPart(value))
.filter(Boolean);
if (!nodeKey || !fieldKeys.length) return false;
return Object.keys(values).some((key) => {
const match = key.match(/^field:([^:]+):(.+)$/i);
return Boolean(match && normalizeWorkflowParameterPart(match[1]) === nodeKey && fieldKeys.includes(normalizeWorkflowParameterPart(match[2])));
});
}
function normalizeWorkflowParameterPart(value: unknown) {
return String(value ?? "").trim().toLowerCase().replace(/[\s_-]/g, "");
}
function workflowFieldLegacyKeys(field: WorkflowVideoFieldLike) {
const keys = [field.source, field.fieldName, field.label]
.map((value) => normalizeWorkflowVideoFieldKey(String(value || "")))
.filter(Boolean);
if (keys.some((key) => ["aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio"].includes(key))) {
return ["source:aspectRatio", "source:aspect_ratio", "source:ratio"];
}
if (keys.some((key) => ["size", "imagesize", "imageresolution"].includes(key))) return ["source:size"];
if (keys.some((key) => ["quality", "imagequality"].includes(key))) return ["source:quality"];
if (keys.some((key) => ["vquality", "videoresolution", "videoquality"].includes(key))) return ["source:vquality", "source:videoResolution"];
if (keys.some((key) => ["videoseconds", "duration", "durationseconds", "seconds"].includes(key))) return ["source:videoSeconds", "source:duration", "source:duration_seconds"];
if (keys.some((key) => ["count", "batch", "batchsize"].includes(key))) return ["source:count", "source:batch"];
return [] as string[];
}
function workflowNumericFieldValues(field: WorkflowVideoFieldLike) {
const bounds = workflowFieldNumberBounds(field);
const min = bounds.min;
const max = bounds.max;
const step = bounds.step;
if (min === undefined || max === undefined || max < min || step === undefined || step <= 0) return [] as string[];
const count = Math.floor((max - min) / step) + 1;
if (count <= 0 || count > 32) return [] as string[];
return Array.from({ length: count }, (_, index) => String(Number((min + index * step).toFixed(6))));
}
function workflowNumber(value: unknown) {
if (value === undefined || value === null || String(value).trim() === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function workflowDurationNumber(value: string) {
const parsed = Number(String(value).trim().replace(/(?:s|秒)$/i, ""));
return Number.isFinite(parsed) ? parsed : undefined;
}
function matchWorkflowValue(value: string, options: string[]) {
if (!value) return "";
return options.find((option) => option.toLowerCase() === value.toLowerCase()) || options.find((option) => option.replace(/p$/i, "").toLowerCase() === value.replace(/p$/i, "").toLowerCase()) || "";
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
const size = normalizeImageSizeSetting(profile, value.size);
const quality = profile.quality.supported ? (value.quality && profile.quality.values.includes(value.quality) ? value.quality : profile.quality.default || "auto") : profile.quality.default || "auto";

View File

@@ -1,2 +1,3 @@
import "./eagle";
import "./prompt-optimizer";
import "./workflows";

View File

@@ -0,0 +1,54 @@
import { registerPlugin } from "../plugin-registry";
import { PLUGIN_API_VERSION, type PluginManifest, type RegisteredPlugin } from "../plugin-types";
export const RUNNINGHUB_PLUGIN_ID = "runninghub-workflow-provider";
export const COMFYUI_PLUGIN_ID = "comfyui-workflow-provider";
function workflowContributions(providerId: string, label: string) {
return (["image", "video", "audio"] as const).map((capability) => ({
id: `${providerId}-${capability}`,
label: `${label} · ${{ image: "图片", video: "视频", audio: "音频" }[capability]}`,
providerId,
capability,
parameters: [],
}));
}
const runningHubManifest: PluginManifest = {
id: RUNNINGHUB_PLUGIN_ID,
name: "RunningHub 工作流",
version: "1.0.0",
publishedAt: "2026-08-25",
updatedAt: "2026-08-25",
apiVersion: PLUGIN_API_VERSION,
description: "在画布中拉取并执行 RunningHub Workflow 与 App,按工作流字段生成图片、视频和音频。",
documentation:
"# RunningHub 工作流\n\n该插件把 RunningHub 的 Workflow / App 接入画布工作流节点。API Key、工作流参数和字段映射仍由宿主安全保存并提交,插件本身不接触密钥。\n\n在插件设置中可以打开完整的 RunningHub 配置页,拉取工作流、编辑字段映射并测试请求。",
author: "影策",
surfaces: ["node", "settings"],
permissions: ["generation.run", "external.open"],
trusted: true,
runtime: { backend: "trusted-backend", web: "trusted-backend" },
contributes: { workflows: workflowContributions(RUNNINGHUB_PLUGIN_ID, "RunningHub") },
};
const comfyUIManifest: PluginManifest = {
id: COMFYUI_PLUGIN_ID,
name: "ComfyUI Bridge 工作流",
version: "1.0.0",
publishedAt: "2026-08-25",
updatedAt: "2026-08-25",
apiVersion: PLUGIN_API_VERSION,
description: "通过本机或云端 Bridge 发现、映射并执行 ComfyUI API 工作流。",
documentation:
"# ComfyUI Bridge 工作流\n\n该插件把 ComfyUI API JSON 工作流接入画布工作流节点。Bridge 在能访问 ComfyUI 的机器上运行,工作流字段映射和执行请求由宿主处理。\n\n在插件设置中可以打开完整的 ComfyUI Bridge 配置页,注册设备、发现工作流并测试请求。",
author: "影策",
surfaces: ["node", "settings"],
permissions: ["generation.run", "external.open"],
trusted: true,
runtime: { backend: "trusted-backend", web: "trusted-backend" },
contributes: { workflows: workflowContributions(COMFYUI_PLUGIN_ID, "ComfyUI Bridge") },
};
registerPlugin({ manifest: runningHubManifest } satisfies RegisteredPlugin);
registerPlugin({ manifest: comfyUIManifest } satisfies RegisteredPlugin);