import { z } from 'zod' import { fromZodError } from 'zod-validation-error' import type { ClipboardItems, ExportedSubgraph, RendererType } from '@/lib/litegraph/src/types/serialisation' import { isWidgetValue } from '@/lib/litegraph/src/types/widgets' import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets' import { parseLinkId, toLinkId } from '@/types/linkId' import type { LinkPresentation } from '@/types/linkPresentation' import type { NodeProperty } from '@/types/nodeState' import { toRerouteId } from '@/types/rerouteId' const zRendererType = z.enum([ 'LG', 'Vue', 'Vue-corrected' ]) satisfies z.ZodType // GroupNode is hacking node id to be a string, so we need to allow that. // innerNode.id = `${this.node.id}:${i}` // Remove it after GroupNode is redesigned. export const zNodeId = z.union([z.number().int(), z.string()]) const zNodeInputName = z.string() /** * UUID identifier for a saved workflow. * * Workflow files persist their identity as `id: z.guid()` on the * graph schema. This alias names that primitive at use sites (services, * stores, share types) without changing structural typing. */ export type WorkflowId = string const zSlotIndex = z.union([ z.number().int(), z .string() .transform((val) => parseInt(val)) .refine((val) => !isNaN(val), { message: 'Invalid number' }) ]) // TODO: Investigate usage of array and number as data type usage in custom nodes. // Known usage: // - https://github.com/rgthree/rgthree-comfy Context Big node is using array as type. const zDataType = z.union([z.string(), z.array(z.string()), z.number()]) const zVector2 = z.union([ z .object({ 0: z.number(), 1: z.number() }) .passthrough() .transform((v) => [v[0], v[1]] as [number, number]), z.tuple([z.number(), z.number()]) ]) // Definition of an AI model file used in the workflow. export const zModelFile = z.object({ name: z.string(), url: z.string().url(), hash: z.string().optional(), hash_type: z.string().optional(), directory: z.string() }) /** * A node's declared model entry. Only `name` is required - the shape a * workflow must declare to pass validation is `zModelFile`, and a workflow * that failed it still loads. */ export const zDeclaredModelFile = z.object({ name: z.string(), url: z.string().optional(), hash: z.string().optional(), hash_type: z.string().optional(), directory: z.string().optional() }) const zGraphState = z .object({ lastGroupId: z.number(), lastNodeId: z.number(), lastLinkId: z.number(), lastRerouteId: z.number() }) .passthrough() const zComfyLink = z.tuple([ z.number(), // Link id zNodeId, // Node id of source node zSlotIndex, // Output slot# of source node zNodeId, // Node id of destination node zSlotIndex, // Input slot# of destination node zDataType // Data type ]) /** Extension to 0.4 schema (links as arrays): parent reroute ID */ const zComfyLinkExtension = z .object({ id: z.number(), parentId: z.number() }) .passthrough() const zLinkPresentationFields = { hidden: z.boolean().optional(), label: z.string().optional() } satisfies { [K in keyof LinkPresentation]-?: z.ZodType } const zComfyLinkPresentation = z.object(zLinkPresentationFields).passthrough() const zComfyLinkObject = z .object({ id: z.number(), origin_id: zNodeId, origin_slot: zSlotIndex, target_id: zNodeId, target_slot: zSlotIndex, type: zDataType, parentId: z.number().optional(), ...zLinkPresentationFields }) .passthrough() const zReroute = z .object({ id: z.number(), parentId: z.number().optional(), pos: zVector2, linkIds: z.array(z.number()).nullish(), floating: z .object({ slotType: z.enum(['input', 'output']) }) .optional() }) .passthrough() const zNodeOutput = z .object({ name: z.string(), type: zDataType, links: z.array(z.number()).nullable().optional(), slot_index: zSlotIndex.optional() }) .passthrough() const zNodeInput = z .object({ name: zNodeInputName, type: zDataType, link: z.number().nullable().optional(), slot_index: zSlotIndex.optional() }) .passthrough() const zFlags = z .object({ collapsed: z.boolean().optional(), pinned: z.boolean().optional(), allow_interaction: z.boolean().optional(), horizontal: z.boolean().optional(), skip_repeated_outputs: z.boolean().optional() }) .passthrough() const repoLikeIdPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/ const githubUsernamePattern = /^(?!-)(?!.*--)[a-zA-Z0-9-]+(? !/^[_\-.]|[_\-.]$/.test(id), { message: "ID must not start or end with '_', '-', or '.'" }) const zCnrId = zRepoLikeId const zGithubRepoName = zRepoLikeId // GitHub username/organization schema const zGithubUsername = z .string() .min(1) .max(39) .regex(githubUsernamePattern, 'Invalid GitHub username/org') // Auxiliary ID identifies node packs not installed via the Comfy Node Registry const zAuxId = z .string() .regex(/^[^/]+\/[^/]+$/, "Invalid format. Must be 'github-user/repo-name'") .transform((id) => id.split('/')) .refine( ([username, repo]) => zGithubUsername.safeParse(username).success && zGithubRepoName.safeParse(repo).success, "Invalid aux_id: Must be valid 'github-username/github-repo-name'" ) .transform(([username, repo]) => `${username}/${repo}`) const zGitHash = z.string().superRefine((val: string, ctx) => { if (!gitHashPattern.test(val)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Node pack version has invalid Git commit hash: "${val}"` }) } }) const zSemVer = z.string().superRefine((val: string, ctx) => { if (!semverPattern.test(val)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Node pack version has invalid semantic version: "${val}"` }) } }) const zVersion = z.union([ z .string() .transform((ver) => ver.replace(/^v/, '')) // Strip leading 'v' .pipe(z.union([zSemVer, zGitHash])), z.literal('unknown') ]) const zNodeProperty = z.union([ z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), z.unknown()), z.array(z.unknown()) ]) satisfies z.ZodType const zProperties = z .object({ ['Node name for S&R']: z.string().optional(), cnr_id: zCnrId.optional(), aux_id: zAuxId.optional(), ver: zVersion.optional(), models: z.array(zModelFile).optional() }) .catchall(zNodeProperty.optional()) const zWidgetValue = z.custom(isWidgetValue) const zWidgetValues: z.ZodType> = z.union([z.array(zWidgetValue), z.record(z.string(), zWidgetValue)]) const MAX_CLIPBOARD_WIDGET_VALUES = 10_000 function hasSupportedClipboardWidgetValuesLength( values: z.output | undefined ): boolean { if (!values || Array.isArray(values)) return true return ( typeof values.length !== 'number' || values.length <= MAX_CLIPBOARD_WIDGET_VALUES ) } function isArrayLikeLength(value: TWidgetValue): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } function normalizeClipboardWidgetValues( values: z.output | undefined ): TWidgetValue[] | undefined { if (!values || Array.isArray(values)) return values const length = values.length if (!isArrayLikeLength(length)) return [] const normalized: TWidgetValue[] = [] normalized.length = length for (const [key, value] of Object.entries(values)) { const index = Number(key) if (Number.isInteger(index) && index >= 0 && index < length) normalized[index] = value } return normalized } function normalizeClipboardNodeWidgets< T extends { widgets_values?: z.output } >(node: T) { const values = node.widgets_values if (values && !Array.isArray(values) && typeof values.length !== 'number') { return { ...node, widgets_values: undefined, widgets_values_named: { ...values } } } return { ...node, widgets_values: normalizeClipboardWidgetValues(values) } } export const zComfyNode = z .object({ id: zNodeId, type: z.string(), pos: zVector2, size: zVector2, flags: zFlags, order: z.number(), mode: z.number(), inputs: z.array(zNodeInput).optional(), outputs: z.array(zNodeOutput).optional(), properties: zProperties, widgets_values: zWidgetValues.optional(), color: z.string().optional(), bgcolor: z.string().optional() }) .passthrough() const zSubgraphIO = zNodeInput.extend({ /** Slot ID (internal; never changes once instantiated). */ id: z.guid(), /** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */ type: z.string(), /** Links connected to this slot, or `undefined` if not connected. An output slot should only ever have one link. */ linkIds: z.array(z.number()).optional() }) const zSubgraphInstance = z .object({ id: zNodeId, type: z.guid(), pos: zVector2, size: zVector2, flags: zFlags, order: z.number(), mode: z.number(), inputs: z.array(zSubgraphIO).optional(), outputs: z.array(zSubgraphIO).optional(), widgets_values: zWidgetValues.optional(), color: z.string().optional(), bgcolor: z.string().optional() }) .passthrough() const zGroup = z .object({ id: z.number().optional(), title: z.string(), bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]), color: z.string().optional(), font_size: z.number().optional(), locked: z.boolean().optional() }) .passthrough() const zDS = z .object({ scale: z.number(), offset: zVector2 }) .passthrough() const zConfig = z .object({ links_ontop: z.boolean().optional(), align_to_grid: z.boolean().optional() }) .passthrough() const zExtra = z .object({ ds: zDS.optional(), frontendVersion: z.string().optional(), linkExtensions: z.array(zComfyLinkExtension).optional(), linkPresentation: z .record( z.string().refine((value) => parseLinkId(value) !== undefined, { message: 'Expected a canonical integer link ID' }), zComfyLinkPresentation ) .optional(), reroutes: z.array(zReroute).optional(), workflowRendererVersion: zRendererType.optional(), BlueprintDescription: z.string().optional(), BlueprintSearchAliases: z.array(z.string()).optional(), linearMode: z.boolean().optional(), linearData: z .object({ inputs: z .array( z.union([ z.tuple([ zNodeId, z.string(), z.object({ height: z.number().optional() }).passthrough() ]), z.tuple([zNodeId, z.string()]) ]) ) .optional(), outputs: z.array(zNodeId).optional() }) .optional() }) .passthrough() const zGraphDefinitions = z.object({ subgraphs: z.lazy(() => z.array(zSubgraphDefinition)) }) const zBaseExportableGraph = z.object({ /** Unique graph ID. Automatically generated if not provided. */ id: z.guid().optional(), revision: z.number().optional(), config: zConfig.optional().nullable(), /** Details of the appearance and location of subgraphs shown in this graph. Similar to */ subgraphs: z.array(zSubgraphInstance).optional() }) /** Schema version 0.4 */ export const zComfyWorkflow = zBaseExportableGraph .extend({ id: z.guid().optional(), revision: z.number().optional(), last_node_id: zNodeId, last_link_id: z.number(), nodes: z.array(zComfyNode), links: z.array(zComfyLink), floatingLinks: z.array(zComfyLinkObject).optional(), groups: z.array(zGroup).optional(), config: zConfig.optional().nullable(), extra: zExtra.optional().nullable(), version: z.number(), models: z.array(zModelFile).optional(), definitions: zGraphDefinitions.optional() }) .passthrough() /** Required for recursive definition of subgraphs. */ interface ComfyWorkflow1BaseType { id?: string revision?: number version: 1 models?: z.infer[] state: z.infer } /** Required for recursive definition of subgraphs w/ZodEffects. */ interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType { groups?: z.input[] nodes: z.input[] links?: z.input[] floatingLinks?: z.input[] reroutes?: z.input[] definitions?: { subgraphs: SubgraphDefinitionBase[] } } /** Required for recursive definition of subgraphs w/ZodEffects. */ interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType { groups?: z.output[] nodes: z.output[] links?: z.output[] floatingLinks?: z.output[] reroutes?: z.output[] definitions?: { subgraphs: SubgraphDefinitionBase[] } } /** Schema version 1 */ export const zComfyWorkflow1 = zBaseExportableGraph .extend({ id: z.guid().optional(), revision: z.number().optional(), version: z.literal(1), config: zConfig.optional().nullable(), state: zGraphState, groups: z.array(zGroup).optional(), nodes: z.array(zComfyNode), links: z.array(zComfyLinkObject).optional(), floatingLinks: z.array(zComfyLinkObject).optional(), reroutes: z.array(zReroute).optional(), extra: zExtra.optional().nullable(), models: z.array(zModelFile).optional(), definitions: z .object({ subgraphs: z.lazy( (): z.ZodArray< z.ZodType< SubgraphDefinitionBase, SubgraphDefinitionBase > > => z.array(zSubgraphDefinition) ) }) .optional() }) .passthrough() const zExportedSubgraphIONode = z.object({ id: zNodeId, bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]), pinned: z.boolean().optional() }) const zExposedWidget = z.object({ id: z.string(), name: z.string() }) interface SubgraphDefinitionBase< T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput > { /** Unique graph ID. Automatically generated if not provided. */ id: string revision: number name: string /** Optional description shown as tooltip when hovering over the subgraph node. */ description?: string category?: string essentials_category?: string /** Custom metadata for the subgraph (description, searchAliases, etc.) */ extra?: T extends ComfyWorkflow1BaseInput ? z.input | null : z.output | null inputNode: T extends ComfyWorkflow1BaseInput ? z.input : z.output outputNode: T extends ComfyWorkflow1BaseInput ? z.input : z.output /** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */ inputs?: T extends ComfyWorkflow1BaseInput ? z.input[] : z.output[] /** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */ outputs?: T extends ComfyWorkflow1BaseInput ? z.input[] : z.output[] /** A list of node widgets displayed in the parent graph, on the subgraph object. */ widgets?: T extends ComfyWorkflow1BaseInput ? z.input[] : z.output[] definitions?: { subgraphs: SubgraphDefinitionBase[] } } /** A subgraph definition `worfklow.definitions.subgraphs` */ const zSubgraphDefinition = zComfyWorkflow1 .extend({ /** Unique graph ID. Automatically generated if not provided. */ id: z.guid(), revision: z.number(), name: z.string(), /** Optional description shown as tooltip when hovering over the subgraph node. */ description: z.string().optional(), category: z.string().optional(), essentials_category: z.string().optional(), inputNode: zExportedSubgraphIONode, outputNode: zExportedSubgraphIONode, /** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */ inputs: z.array(zSubgraphIO).optional(), /** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */ outputs: z.array(zSubgraphIO).optional(), /** A list of node widgets displayed in the parent graph, on the subgraph object. */ widgets: z.array(zExposedWidget).optional(), definitions: z .object({ subgraphs: z.lazy( (): z.ZodArray< z.ZodType< SubgraphDefinitionBase, SubgraphDefinitionBase > > => zSubgraphDefinition.array() ) }) .optional() }) .passthrough() const zClipboardNode = zComfyNode .refine( (node) => hasSupportedClipboardWidgetValuesLength(node.widgets_values), { path: ['widgets_values'], message: 'Clipboard widget values length is too large' } ) .transform(normalizeClipboardNodeWidgets) const zClipboardSubgraphInstance = zSubgraphInstance .refine( (node) => hasSupportedClipboardWidgetValuesLength(node.widgets_values), { path: ['widgets_values'], message: 'Clipboard widget values length is too large' } ) .transform(normalizeClipboardNodeWidgets) const zClipboardGroup = zGroup.transform((group) => ({ ...group, id: group.id ?? -1 })) const zClipboardReroute = zReroute.transform((reroute) => ({ ...reroute, linkIds: reroute.linkIds ?? [] })) interface ClipboardSubgraphInput extends Omit< z.input, 'groups' | 'nodes' | 'reroutes' | 'subgraphs' | 'definitions' > { groups?: z.input[] nodes: z.input[] reroutes?: z.input[] subgraphs?: z.input[] definitions?: { subgraphs: ClipboardSubgraphInput[] } } const zClipboardSubgraphDefinition: z.ZodType< ExportedSubgraph, ClipboardSubgraphInput > = zSubgraphDefinition .extend({ groups: z.array(zClipboardGroup).optional(), nodes: z.array(zClipboardNode), reroutes: z.array(zClipboardReroute).optional(), subgraphs: z.array(zClipboardSubgraphInstance).optional(), definitions: z .object({ subgraphs: z.lazy(() => z.array(zClipboardSubgraphDefinition)) }) .optional() }) .transform(({ config, extra, ...subgraph }) => { const normalizedExtra = extra ? { ...extra, reroutes: extra.reroutes?.map((reroute) => ({ ...reroute, linkIds: reroute.linkIds ?? [] })), linkExtensions: extra.linkExtensions?.map((link) => ({ ...link, id: toLinkId(link.id), parentId: toRerouteId(link.parentId) })) } : undefined return { ...subgraph, config: config ?? undefined, extra: normalizedExtra } }) const zClipboardItemsSchema = z .object({ nodes: z.array(zClipboardNode).optional(), groups: z.array(zClipboardGroup).optional(), reroutes: z.array(zClipboardReroute).optional(), links: z.array(zComfyLinkObject).optional(), subgraphs: z.array(zClipboardSubgraphDefinition).optional() }) .refine( (items) => items.nodes !== undefined || items.groups !== undefined || items.reroutes !== undefined || items.links !== undefined || items.subgraphs !== undefined ) export const zClipboardItems: z.ZodType< ClipboardItems, z.input > = zClipboardItemsSchema export type ModelFile = z.infer export type DeclaredModelFile = z.infer export type ComfyLinkObject = z.infer export type ComfyNode = z.infer export type Reroute = z.infer export type WorkflowJSON04 = z.infer export type ComfyWorkflowJSON = z.infer< typeof zComfyWorkflow | typeof zComfyWorkflow1 > /** * The minimum a node must carry for `LGraph.configure` to instantiate it: * it calls `LiteGraph.createNode(type)` and assigns `id`. */ const zLegacyLoadableNode = z .object({ id: zNodeId, type: z.string() }) .passthrough() export const zLegacyLoadableWorkflow = z .object({ version: z.number(), nodes: z.array(zLegacyLoadableNode), links: z.array(z.unknown()).optional(), last_node_id: zNodeId.optional(), last_link_id: z.number().optional() }) .passthrough() export type LegacyLoadableWorkflow = z.infer const zWorkflowVersion = z.object({ version: z.number() }) export async function validateComfyWorkflow( data: unknown, onError: (error: string) => void = console.warn ): Promise { const versionResult = zWorkflowVersion.safeParse(data) let result: z.ZodSafeParseResult if (!versionResult.success) { // Invalid workflow const error = fromZodError(versionResult.error) onError(`Workflow does not contain a valid version. Zod error:\n${error}`) return null } else if (versionResult.data.version === 1) { // Schema version 1 result = await zComfyWorkflow1.safeParseAsync(data) } else { // Unknown or old version: 0.4 result = await zComfyWorkflow.safeParseAsync(data) } if (result.success) return result.data const error = fromZodError(result.error) onError(`Invalid workflow against zod schema:\n${error}`) return null } /** * API format workflow for direct API usage. */ const zNodeInputValue = z.union([ // For widget values (can be any type) z.any(), // For node links [nodeId, slotIndex] z.tuple([zNodeId, zSlotIndex]) ]) export const zNodePackMetadata = zProperties .pick({ cnr_id: true, aux_id: true, ver: true }) .strip() const zNodeData = z.object({ inputs: z.record(zNodeInputName, zNodeInputValue), class_type: z.string(), _meta: zNodePackMetadata.extend({ title: z.string() }).optional() }) export const zComfyApiWorkflow = z.record(zNodeId, zNodeData) export type ComfyApiWorkflow = z.infer