From 1bea8020c0afa3ea47ddd1c5ee257a3fd50f2638 Mon Sep 17 00:00:00 2001 From: pbanddev <202373785+PBandDev@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:11:33 -0600 Subject: [PATCH] Merge branch 'feature/group-tokens' --- CLAUDE.md | 29 +- README.md | 24 + src/layout/positioning.ts | 27 +- src/layout/selected-groups.ts | 215 ++- src/layout/title-tokens.ts | 278 ++++ tests/fixtures/token-testing.json | 1744 +++++++++++++++++++++ tests/integration/selected-groups.test.ts | 311 ++++ tests/integration/token-layout.test.ts | 135 ++ 8 files changed, 2752 insertions(+), 11 deletions(-) create mode 100644 src/layout/title-tokens.ts create mode 100644 tests/fixtures/token-testing.json create mode 100644 tests/integration/token-layout.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9a0b96a..e109601 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,7 @@ Use docs-mcp server for ComfyUI API reference. 8. `groups.ts` - Resize groups to fit members 9. `layout-utils.ts` - Shared functions for internal edge detection and member layer assignment 10. `selected-groups.ts` - Organize only selected groups independently +11. `title-tokens.ts` - Parse group title tokens and arrange nodes by mode **Configuration** in `types.ts`: - `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns @@ -91,6 +92,29 @@ Use docs-mcp server for ComfyUI API reference. - Duck-typing via `isLGraphGroup()` checks for pos/size/title/id - Handles Float64Array (ComfyUI runtime uses typed arrays for pos/size) +## Group Title Tokens + +`parseLayoutToken(title)` and `arrangeByMode()` in `src/layout/title-tokens.ts`: + +**Supported tokens** (case-insensitive): +- `[HORIZONTAL]` - Single horizontal row +- `[VERTICAL]` - Single vertical column +- `[1-9ROW]` - Distribute into N rows (round-robin) +- `[1-9COL]` - Distribute into N columns (round-robin) + +**Behavior**: +- Tokens override default layout for direct group members +- Ignores DAG structure when token present (treats all as disconnected) +- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]` +- Nested groups each respect their own tokens +- Groups without tokens use default behavior +- Nodes sorted by original position (left-to-right, top-to-bottom) to preserve user intent + +**Integration points**: +- `selected-groups.ts:layoutGroupContents()` - Token layout for "Organize Group" +- `positioning.ts:positionGroupContents()` - Token layout for "Organize Workflow" +- Both handle nested groups: process bottom-up first, then include as items in parent's token layout + ## Testing **Vitest-based regression testing** in `tests/`: @@ -105,11 +129,12 @@ Use docs-mcp server for ComfyUI API reference. - `assertIdempotent` - stable after multiple runs - `assertGroupMembershipPreserved` - nodes originally in groups remain in groups - `tests/integration/layout.test.ts` - Main regression tests -- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (17 cases) +- `tests/integration/selected-groups.test.ts` - Selected groups layout tests (34 cases) +- `tests/integration/token-layout.test.ts` - Token-based layout regression tests (8 cases) **Coverage**: 70% line threshold, excludes `src/index.ts` (ComfyUI runtime) and `src/layout/reroute-collapse.ts` (no fixtures) -All fixtures pass all invariants including idempotency. 50 total tests. +All fixtures pass all invariants including idempotency. 75 total tests. ## CI/CD diff --git a/README.md b/README.md index e87d618..a3f890b 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,36 @@ Automatically organizes nodes in your workflow using a compact hierarchical layo - **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin - **Reroute Optimization**: Reroute chains collapsed and restored along edges - **Group Support**: Nested groups with proper hierarchy +- **Group Layout Tokens**: Control layout with title tokens (see below) - **Subgraph Support**: Works inside subgraphs with I/O node handling ## Usage Right-click on the canvas and select **Organize Workflow**. +To organize specific groups, select them and choose **Organize Group** or **Organize N Groups**. + +## Group Layout Tokens + +Add tokens to group titles to control how nodes are arranged: + +| Token | Effect | +|-------|--------| +| `[HORIZONTAL]` | Single horizontal row | +| `[VERTICAL]` | Single vertical column | +| `[2ROW]`...`[9ROW]` | Distribute into N rows | +| `[2COL]`...`[9COL]` | Distribute into N columns | + +**Examples:** +- `"My Loaders [HORIZONTAL]"` - arranges all nodes in a single row +- `"Processing [3COL]"` - distributes nodes into 3 columns + +**Notes:** +- Tokens are case-insensitive (`[horizontal]` works) +- `[1ROW]` = `[HORIZONTAL]`, `[1COL]` = `[VERTICAL]` +- Nested groups each respect their own tokens +- Groups without tokens use default DAG-based layout + --- ## Installation diff --git a/src/layout/positioning.ts b/src/layout/positioning.ts index 83be901..1b5133a 100644 --- a/src/layout/positioning.ts +++ b/src/layout/positioning.ts @@ -22,6 +22,7 @@ import { packNodesIntoRows, getPackedWidth } from "./bin-pack"; import { restoreReroutePositions } from "./reroute-collapse"; import { getInternalEdges, assignMemberLayers } from "./layout-utils"; import { debugLog } from "../debug"; +import { parseLayoutToken, arrangeByMode } from "./title-tokens"; /** Title bar height for groups */ const GROUP_TITLE_HEIGHT = 50; @@ -374,14 +375,32 @@ function positionGroupContents( return startY; } - debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}`); + // Parse layout token from group title + const layoutMode = parseLayoutToken(group.group.title); - // Get internal edges between members - const edges = getInternalEdges(group.memberIds, allNodes); + debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}, mode=${layoutMode.type}`); let finalY = startY; - if (members.length > 0) { + // If layout mode is specified, use token-based layout (ignores internal edges) + if (layoutMode.type !== "default" && members.length > 0) { + const arranged = arrangeByMode(members, layoutMode, startX, startY, config); + + for (const pos of arranged) { + const node = allNodes.get(pos.id); + if (node?.pos) { + node.pos[0] = pos.x; + node.pos[1] = pos.y; + debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`); + + const nh = node.size?.[1] ?? 100; + finalY = Math.max(finalY, pos.y + nh); + } + } + } else if (members.length > 0) { + // Default behavior: check internal edges for layer-based or bin pack layout + const edges = getInternalEdges(group.memberIds, allNodes); + if (edges.length > 0) { // Has internal connections - use layer-based layout const layers = assignMemberLayers(group.memberIds, edges, allNodes); diff --git a/src/layout/selected-groups.ts b/src/layout/selected-groups.ts index efe268d..16677f9 100644 --- a/src/layout/selected-groups.ts +++ b/src/layout/selected-groups.ts @@ -9,10 +9,85 @@ import { DEFAULT_CONFIG } from "./types"; import { getInternalEdges, assignMemberLayers } from "./layout-utils"; import { packNodesIntoRows } from "./bin-pack"; import { debugLog } from "../debug"; +import { parseLayoutToken, arrangeByMode } from "./title-tokens"; /** Title bar height for groups */ const GROUP_TITLE_HEIGHT = 50; +/** + * Resize a group to fit its member nodes + */ +function resizeGroupToFitMembers( + group: LGraphGroup, + memberIds: Set, + allNodes: Map, + config: LayoutConfig +): void { + if (memberIds.size === 0) return; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const nodeId of memberIds) { + const node = allNodes.get(nodeId); + if (!node?.pos) continue; + const nw = node.size?.[0] ?? 200; + const nh = node.size?.[1] ?? 100; + minX = Math.min(minX, node.pos[0]); + minY = Math.min(minY, node.pos[1]); + maxX = Math.max(maxX, node.pos[0] + nw); + maxY = Math.max(maxY, node.pos[1] + nh); + } + + if (minX === Infinity) return; + + // Update group position and size with padding + group.pos[0] = minX - config.groupPadding; + group.pos[1] = minY - config.groupPadding - GROUP_TITLE_HEIGHT; + group.size[0] = maxX - minX + config.groupPadding * 2; + group.size[1] = maxY - minY + config.groupPadding * 2 + GROUP_TITLE_HEIGHT; +} + +/** + * Translate a group and its tracked members by a delta + */ +function translateGroupWithMembers( + group: LGraphGroup, + deltaX: number, + deltaY: number, + memberIds: Set, + allNodes: Map, + allGroups: LGraphGroup[] +): void { + // Move the group itself + if (Array.isArray(group.pos)) { + group.pos[0] += deltaX; + group.pos[1] += deltaY; + } + + // Translate tracked member nodes + for (const nodeId of memberIds) { + const node = allNodes.get(nodeId); + if (node?.pos) { + node.pos[0] += deltaX; + node.pos[1] += deltaY; + } + } + + // Find and translate nested groups inside this group + for (const nestedGroup of allGroups) { + if (nestedGroup === group) continue; + if (groupContainsGroup(group, nestedGroup)) { + if (Array.isArray(nestedGroup.pos)) { + nestedGroup.pos[0] += deltaX; + nestedGroup.pos[1] += deltaY; + } + } + } +} + /** * Check if outer group's bounds fully contain inner group's bounds */ @@ -185,8 +260,11 @@ function layoutGroupContents( return null; } + // Parse layout token from group title + const layoutMode = parseLayoutToken(group.title); + debugLog( - `layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}` + `layoutGroupContents "${group.title}": members=${members.length}, nestedGroups=${nestedGroups.length}, mode=${layoutMode.type}` ); // Get the group's current content area start position @@ -199,12 +277,139 @@ function layoutGroupContents( let maxX = -Infinity; let maxY = -Infinity; - // Get internal edges between members - const edges = getInternalEdges(memberIds, allNodes); - let contentEndY = startY; - if (members.length > 0) { + // If layout mode is specified, use token-based layout (ignores internal edges) + // This handles both direct member nodes AND nested groups as top-level items + if (layoutMode.type !== "default" && (members.length > 0 || nestedGroups.length > 0)) { + // First, recursively process nested groups (bottom-up) so their sizes are final + // Sort by containment depth (deepest first) + const sortedNested = [...nestedGroups].sort((a, b) => { + let aDepth = 0, + bDepth = 0; + for (const other of nestedGroups) { + if (other !== a && groupContainsGroup(other, a)) aDepth++; + if (other !== b && groupContainsGroup(other, b)) bDepth++; + } + return bDepth - aDepth; // Deepest first + }); + + // Track member IDs for each nested group (needed for translation later) + const nestedGroupMembers = new Map>(); + + for (const nested of sortedNested) { + if (processedGroups.has(nested)) continue; + + const nestedMemberIds = new Set(); + for (const [nodeId, node] of allNodes) { + if (nodeInsideGroup(node, nested)) { + // Check not in deeper nested group + let inDeeperGroup = false; + for (const other of sortedNested) { + if (other !== nested && groupContainsGroup(nested, other) && nodeInsideGroup(node, other)) { + inDeeperGroup = true; + break; + } + } + if (!inDeeperGroup) { + nestedMemberIds.add(nodeId); + } + } + } + nestedGroupMembers.set(nested.id, nestedMemberIds); + + const deeperNested = sortedNested.filter( + (g) => g !== nested && groupContainsGroup(nested, g) + ); + + layoutGroupContents( + nested, + nestedMemberIds, + deeperNested, + allNodes, + config, + processedGroups + ); + + // Resize nested group to fit its (possibly rearranged) contents + resizeGroupToFitMembers(nested, nestedMemberIds, allNodes, config); + } + + // Now create combined items list: direct member nodes + direct nested groups + // Direct nested groups are those not contained by other nested groups + const directNestedGroups = nestedGroups.filter((g) => { + for (const other of nestedGroups) { + if (other !== g && groupContainsGroup(other, g)) return false; + } + return true; + }); + + // Create fake LGraphNode-like objects for nested groups so we can use arrangeByMode + const groupItems: LGraphNode[] = directNestedGroups.map((g) => ({ + id: -g.id, // Negative ID to distinguish from real nodes + type: "__group__", + title: g.title, + pos: [g.pos[0], g.pos[1]] as [number, number], + size: [g.size[0], g.size[1]] as [number, number], + inputs: [], + outputs: [], + })); + + // Combine members and group items + const allItems = [...members, ...groupItems]; + + if (allItems.length > 0) { + const arranged = arrangeByMode(allItems, layoutMode, startX, startY, config); + + for (const pos of arranged) { + if (pos.id < 0) { + // This is a nested group (negative ID) + const groupId = -pos.id; + const nestedGroup = directNestedGroups.find((g) => g.id === groupId); + if (nestedGroup) { + const oldX = nestedGroup.pos[0]; + const oldY = nestedGroup.pos[1]; + const deltaX = pos.x - oldX; + const deltaY = pos.y - oldY; + + // Use tracked members for accurate translation after token layout + const trackedMembers = nestedGroupMembers.get(groupId) ?? new Set(); + translateGroupWithMembers(nestedGroup, deltaX, deltaY, trackedMembers, allNodes, nestedGroups); + debugLog(` Nested group ${groupId} (${nestedGroup.title}): token layout [${pos.x}, ${pos.y}]`); + + const gw = nestedGroup.size[0]; + const gh = nestedGroup.size[1]; + + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + gw); + maxY = Math.max(maxY, pos.y + gh); + contentEndY = Math.max(contentEndY, pos.y + gh); + } + } else { + // This is a regular node + const node = allNodes.get(pos.id); + if (node?.pos) { + node.pos[0] = pos.x; + node.pos[1] = pos.y; + debugLog(` Member ${node.id} (${node.type}): token layout [${pos.x}, ${pos.y}]`); + + const nw = node.size?.[0] ?? 200; + const nh = node.size?.[1] ?? 100; + + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + nw); + maxY = Math.max(maxY, pos.y + nh); + contentEndY = Math.max(contentEndY, pos.y + nh); + } + } + } + } + } else if (members.length > 0) { + // Default behavior: check internal edges for layer-based or vertical stack layout + const edges = getInternalEdges(memberIds, allNodes); + if (edges.length > 0) { // Has internal connections - use layer-based layout const layers = assignMemberLayers(memberIds, edges, allNodes); diff --git a/src/layout/title-tokens.ts b/src/layout/title-tokens.ts new file mode 100644 index 0000000..5405822 --- /dev/null +++ b/src/layout/title-tokens.ts @@ -0,0 +1,278 @@ +import type { LGraphNode, LayoutConfig } from "./types"; + +/** + * Layout mode parsed from group title tokens + */ +export type LayoutMode = + | { type: "default" } + | { type: "horizontal" } + | { type: "vertical" } + | { type: "rows"; count: number } + | { type: "columns"; count: number }; + +/** + * Parse layout token from group title (case-insensitive) + * Supports: [HORIZONTAL], [VERTICAL], [1-9ROW], [1-9COL] + */ +export function parseLayoutToken(title: string): LayoutMode { + const upper = title.toUpperCase(); + + // Check for [HORIZONTAL] + if (upper.includes("[HORIZONTAL]")) { + return { type: "horizontal" }; + } + + // Check for [VERTICAL] + if (upper.includes("[VERTICAL]")) { + return { type: "vertical" }; + } + + // Check for [xROW] pattern (1-9) + const rowMatch = upper.match(/\[([1-9])ROW\]/); + if (rowMatch) { + const count = parseInt(rowMatch[1], 10); + // 1ROW is same as horizontal + if (count === 1) { + return { type: "horizontal" }; + } + return { type: "rows", count }; + } + + // Check for [xCOL] pattern (1-9) + const colMatch = upper.match(/\[([1-9])COL\]/); + if (colMatch) { + const count = parseInt(colMatch[1], 10); + // 1COL is same as vertical + if (count === 1) { + return { type: "vertical" }; + } + return { type: "columns", count }; + } + + return { type: "default" }; +} + +/** + * Node info for layout calculations + */ +interface NodeInfo { + id: number; + width: number; + height: number; + originalX: number; + originalY: number; +} + +/** + * Result of arranging nodes by mode + */ +export interface ArrangedNode { + id: number; + x: number; + y: number; +} + +/** + * Sort nodes by original position (left-to-right, top-to-bottom) + */ +function sortByOriginalPosition(nodes: NodeInfo[]): NodeInfo[] { + return [...nodes].sort((a, b) => { + // Primary: sort by X (left to right) + if (a.originalX !== b.originalX) { + return a.originalX - b.originalX; + } + // Secondary: sort by Y (top to bottom) + return a.originalY - b.originalY; + }); +} + +/** + * Arrange nodes in a single horizontal row + */ +function arrangeHorizontal( + nodes: NodeInfo[], + startX: number, + startY: number, + gap: number +): ArrangedNode[] { + // Sort by original position (left to right) + const sorted = sortByOriginalPosition(nodes); + + const result: ArrangedNode[] = []; + let x = startX; + + for (const node of sorted) { + result.push({ id: node.id, x, y: startY }); + x += node.width + gap; + } + + return result; +} + +/** + * Arrange nodes in a single vertical column + */ +function arrangeVertical( + nodes: NodeInfo[], + startX: number, + startY: number, + gap: number +): ArrangedNode[] { + // Sort by original position (top to bottom, then left to right) + const sorted = [...nodes].sort((a, b) => { + if (a.originalY !== b.originalY) { + return a.originalY - b.originalY; + } + return a.originalX - b.originalX; + }); + + const result: ArrangedNode[] = []; + let y = startY; + + for (const node of sorted) { + result.push({ id: node.id, x: startX, y }); + y += node.height + gap; + } + + return result; +} + +/** + * Arrange nodes into N rows (round-robin distribution) + * Each row is horizontal, rows stacked vertically + */ +function arrangeRows( + nodes: NodeInfo[], + rowCount: number, + startX: number, + startY: number, + horizontalGap: number, + verticalGap: number +): ArrangedNode[] { + // Sort by original position first + const sorted = sortByOriginalPosition(nodes); + + // Distribute round-robin into rows + const rows: NodeInfo[][] = Array.from({ length: rowCount }, () => []); + for (let i = 0; i < sorted.length; i++) { + rows[i % rowCount].push(sorted[i]); + } + + const result: ArrangedNode[] = []; + let y = startY; + + for (const row of rows) { + if (row.length === 0) continue; + + let x = startX; + let rowMaxHeight = 0; + + for (const node of row) { + result.push({ id: node.id, x, y }); + x += node.width + horizontalGap; + rowMaxHeight = Math.max(rowMaxHeight, node.height); + } + + y += rowMaxHeight + verticalGap; + } + + return result; +} + +/** + * Arrange nodes into N columns (round-robin distribution) + * Each column is vertical, columns side-by-side + */ +function arrangeColumns( + nodes: NodeInfo[], + colCount: number, + startX: number, + startY: number, + horizontalGap: number, + verticalGap: number +): ArrangedNode[] { + // Sort by original position first + const sorted = sortByOriginalPosition(nodes); + + // Distribute round-robin into columns + const cols: NodeInfo[][] = Array.from({ length: colCount }, () => []); + for (let i = 0; i < sorted.length; i++) { + cols[i % colCount].push(sorted[i]); + } + + const result: ArrangedNode[] = []; + let x = startX; + + for (const col of cols) { + if (col.length === 0) continue; + + let y = startY; + let colMaxWidth = 0; + + for (const node of col) { + result.push({ id: node.id, x, y }); + y += node.height + verticalGap; + colMaxWidth = Math.max(colMaxWidth, node.width); + } + + x += colMaxWidth + horizontalGap; + } + + return result; +} + +/** + * Arrange nodes according to layout mode + */ +export function arrangeByMode( + members: LGraphNode[], + mode: LayoutMode, + startX: number, + startY: number, + config: LayoutConfig +): ArrangedNode[] { + // Build node info list with original positions + const nodes: NodeInfo[] = members.map((n) => ({ + id: n.id, + width: n.size?.[0] ?? 200, + height: n.size?.[1] ?? 100, + originalX: n.pos?.[0] ?? 0, + originalY: n.pos?.[1] ?? 0, + })); + + if (nodes.length === 0) { + return []; + } + + switch (mode.type) { + case "horizontal": + return arrangeHorizontal(nodes, startX, startY, config.horizontalGap); + + case "vertical": + return arrangeVertical(nodes, startX, startY, config.verticalGap); + + case "rows": + return arrangeRows( + nodes, + mode.count, + startX, + startY, + config.horizontalGap, + config.verticalGap + ); + + case "columns": + return arrangeColumns( + nodes, + mode.count, + startX, + startY, + config.horizontalGap, + config.verticalGap + ); + + default: + // Default mode - caller should use existing behavior + return []; + } +} diff --git a/tests/fixtures/token-testing.json b/tests/fixtures/token-testing.json new file mode 100644 index 0000000..2908f5e --- /dev/null +++ b/tests/fixtures/token-testing.json @@ -0,0 +1,1744 @@ +{ + "id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae", + "revision": 0, + "last_node_id": 58, + "last_link_id": 37, + "nodes": [ + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [ + 750, + 484 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 1 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 3, + 5 + ] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [ + 8 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "CheckpointLoaderSimple", + "models": [ + { + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true", + "directory": "checkpoints" + } + ], + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "v1-5-pruned-emaonly-fp16.safetensors" + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 1780.2780151367188, + 143 + ], + "size": [ + 315, + 474 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 6 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 2 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "KSampler", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 685468484323813, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 9, + "type": "SaveImage", + "pos": [ + 2505.2780151367188, + 100 + ], + "size": [ + 470, + 560 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 9 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "SaveImage", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "SD1.5" + ] + }, + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 1225, + 212.54043579101562 + ], + "size": [ + 425.27801513671875, + 180.6060791015625 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 6 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "text, watermark" + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 1225, + 433.1465148925781 + ], + "size": [ + 422.84503173828125, + 164.31304931640625 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 3 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 4 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "beautiful scenery nature glass bottle landscape, purple galaxy bottle," + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 15, + "type": "MarkdownNote", + "pos": [ + 100, + 100 + ], + "size": [ + 440, + 200 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note: Prompt", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "**Prompts usually have two types:**\n\n* **Positive Prompt:** Tells the model what you *want* to see.\n* **Negative Prompt:** Tells the model what you *don’t want* to see.\n\nThink of it as:
\nπŸ‘‰ Positive = β€œDo this”
\nπŸ‘‰ Negative = β€œDon’t do this”\n\n\nDifferent models may interpret prompts differently.
\nSome prefer short, simple phrases; others respond well to detailed descriptions or styles.\nExperiment to see how each model reacts.\n\nAbout SD1.5:
\nStable Diffusion 1.5 is one of the most popular base models.\nIt works best with short, clear prompts and simple concepts, and it has a natural, realistic visual style.\n" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 14, + "type": "MarkdownNote", + "pos": [ + 100, + 340 + ], + "size": [ + 470, + 130 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note: Output", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Image will auto-save to the `ComfyUI/output` folder. You can also right-click on the `Save Image` node and then use the menu to save the image locally." + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 13, + "type": "MarkdownNote", + "pos": [ + 100, + 510 + ], + "size": [ + 330, + 150 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note: Image size", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Different models are trained based on datasets of different image sizes. This workflow is using Stable Diffusion 1.5, which is trained based on 512x512 datasets. So, it doesn't perform well on large image sizes." + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 11, + "type": "MarkdownNote", + "pos": [ + 100, + 700 + ], + "size": [ + 400, + 300 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Note: Model link", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "[Tutorial](https://docs.comfy.org/tutorials/basic/text-to-image)\n\n\n## Model link\n\n**checkpoints**\n\n- [v1-5-pruned-emaonly-fp16.safetensors](https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true)\n\n\nModel Storage Location\n\n```\nπŸ“‚ ComfyUI/\nβ”œβ”€β”€ πŸ“‚ models/\nβ”‚ └── πŸ“‚ checkpoints/\nβ”‚ └── v1-5-pruned-emaonly-fp16.safetensors\n```\n\n\n## Report issue\nIf you have any problems running this workflow, please report template-related issues via this link: [report the template issue here](https://github.com/Comfy-Org/workflow_templates/issues)" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 750, + 228 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 10 + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 11 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "EmptyLatentImage", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 22, + "type": "PrimitiveInt", + "pos": [ + 110, + 1260 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 11 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PrimitiveInt", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + 512, + "randomize" + ] + }, + { + "id": 21, + "type": "PrimitiveInt", + "pos": [ + 110, + 1120 + ], + "size": [ + 270, + 82 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [ + 10 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PrimitiveInt", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + 512, + "randomize" + ] + }, + { + "id": 32, + "type": "PreviewImage", + "pos": [ + 2520, + 10 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 33, + "type": "PreviewImage", + "pos": [ + 2700, + 10 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 15 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 34, + "type": "PreviewImage", + "pos": [ + 2870, + 10 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 16 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 35, + "type": "PreviewImage", + "pos": [ + 3050, + 10 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 17 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 43, + "type": "PreviewImage", + "pos": [ + 3600, + 10 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 25 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 44, + "type": "PreviewImage", + "pos": [ + 3600, + 1050 + ], + "size": [ + 660, + 1000 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 26 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 2195.2780151367188, + 357 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 9, + 14, + 15, + 16, + 17, + 27 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "VAEDecode", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [] + }, + { + "id": 46, + "type": "SetNode", + "pos": [ + 2505.2780151367188, + 720 + ], + "size": [ + 210, + 60 + ], + "flags": {}, + "order": 32, + "mode": 0, + "inputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "link": 27 + } + ], + "outputs": [ + { + "name": "*", + "type": "*", + "links": null + } + ], + "title": "Set_image_raw", + "properties": { + "previousName": "image_raw", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "image_raw" + ], + "color": "#2a363b", + "bgcolor": "#3f5159" + }, + { + "id": 41, + "type": "GetNode", + "pos": [ + 3290, + 10 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 25 + ] + } + ], + "title": "Get_image_raw", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "image_raw" + ], + "color": "#2a363b", + "bgcolor": "#3f5159" + }, + { + "id": 42, + "type": "GetNode", + "pos": [ + 3290, + 130 + ], + "size": [ + 210, + 58 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 26 + ] + } + ], + "title": "Get_image_raw", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "image_raw" + ], + "color": "#2a363b", + "bgcolor": "#3f5159" + }, + { + "id": 47, + "type": "Note", + "pos": [ + 3290, + 250 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Isolated node again", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Text here" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 49, + "type": "CLIPTextEncode", + "pos": [ + 1460, + 1510 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 29 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 32 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + "1girl, solo, smile" + ] + }, + { + "id": 50, + "type": "CLIPTextEncode", + "pos": [ + 1460, + 1750 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 30 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 33 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "CLIPTextEncode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [ + "nsfw" + ] + }, + { + "id": 51, + "type": "KSampler", + "pos": [ + 1960, + 1490 + ], + "size": [ + 315, + 474 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 31 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 32 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 33 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 34 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 35 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "KSampler", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 685468484323813, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 52, + "type": "VAEDecode", + "pos": [ + 2380, + 1700 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 35 + }, + { + "name": "vae", + "type": "VAE", + "link": 36 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 37 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "VAEDecode", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [] + }, + { + "id": 53, + "type": "PreviewImage", + "pos": [ + 2690, + 1710 + ], + "size": [ + 140, + 26 + ], + "flags": {}, + "order": 33, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 37 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.8.2", + "Node name for S&R": "PreviewImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + } + }, + "widgets_values": [] + }, + { + "id": 54, + "type": "CheckpointLoaderSimple", + "pos": [ + 1050, + 1670 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 31 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [ + 29, + 30 + ] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [ + 36 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "CheckpointLoaderSimple", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "models": [ + { + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "v1-5-pruned-emaonly-fp16.safetensors" + ] + }, + { + "id": 55, + "type": "Note", + "pos": [ + 140, + 1490 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Isolated node", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Text here" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 56, + "type": "Note", + "pos": [ + 140, + 1640 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Isolated node", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Text here" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 57, + "type": "Note", + "pos": [ + 150, + 1780 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Isolated node", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Text here" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 58, + "type": "Note", + "pos": [ + 140, + 1960 + ], + "size": [ + 210, + 88 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Isolated node", + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "Text here" + ], + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 48, + "type": "EmptyLatentImage", + "pos": [ + 630, + 1670 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 34 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.65", + "Node name for S&R": "EmptyLatentImage", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 512, + 512, + 1 + ] + } + ], + "links": [ + [ + 1, + 4, + 0, + 3, + 0, + "MODEL" + ], + [ + 2, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 3, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 4, + 6, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 5, + 4, + 1, + 7, + 0, + "CLIP" + ], + [ + 6, + 7, + 0, + 3, + 2, + "CONDITIONING" + ], + [ + 7, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 8, + 4, + 2, + 8, + 1, + "VAE" + ], + [ + 9, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 10, + 21, + 0, + 5, + 0, + "INT" + ], + [ + 11, + 22, + 0, + 5, + 1, + "INT" + ], + [ + 14, + 8, + 0, + 32, + 0, + "IMAGE" + ], + [ + 15, + 8, + 0, + 33, + 0, + "IMAGE" + ], + [ + 16, + 8, + 0, + 34, + 0, + "IMAGE" + ], + [ + 17, + 8, + 0, + 35, + 0, + "IMAGE" + ], + [ + 25, + 41, + 0, + 43, + 0, + "IMAGE" + ], + [ + 26, + 42, + 0, + 44, + 0, + "IMAGE" + ], + [ + 27, + 8, + 0, + 46, + 0, + "IMAGE" + ], + [ + 29, + 54, + 1, + 49, + 0, + "CLIP" + ], + [ + 30, + 54, + 1, + 50, + 0, + "CLIP" + ], + [ + 31, + 54, + 0, + 51, + 0, + "MODEL" + ], + [ + 32, + 49, + 0, + 51, + 1, + "CONDITIONING" + ], + [ + 33, + 50, + 0, + 51, + 2, + "CONDITIONING" + ], + [ + 34, + 48, + 0, + 51, + 3, + "LATENT" + ], + [ + 35, + 51, + 0, + 52, + 0, + "LATENT" + ], + [ + 36, + 54, + 2, + 52, + 1, + "VAE" + ], + [ + 37, + 52, + 0, + 53, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "Step 1 - Load model", + "bounding": [ + 720, + 400, + 375, + 208 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 2, + "title": "Step 3 - Image size", + "bounding": [ + 720, + 150, + 375, + 216 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 3, + "title": "Step 2 - Prompt [1ROW]", + "bounding": [ + 1200, + 130, + 485.27801513671875, + 494.91912841796875 + ], + "color": "#3f789e", + "font_size": 24, + "flags": {} + }, + { + "id": 4, + "title": "Group [VERTICAL]", + "bounding": [ + 90, + 30, + 490, + 980.8 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 5, + "title": "[1COL]", + "bounding": [ + 100, + 1050, + 290, + 302.8 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 7, + "title": "[HORIZONTAL]", + "bounding": [ + 2510, + -60, + 691, + 106.8 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 8, + "title": "[1ROW]", + "bounding": [ + 3260, + -60, + 1030, + 2150 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 9, + "title": "Full Workflow Group [3COL]", + "bounding": [ + 110, + 1370, + 2750, + 740 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + }, + { + "id": 10, + "title": "Isolated Node Group [HORIZONTAL]", + "bounding": [ + 130, + 1420, + 240, + 458.8 + ], + "color": "#3f789e", + "font_size": 22, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.5085099000885906, + "offset": [ + 487.5772014681171, + -380.10593874229676 + ] + }, + "frontendVersion": "1.37.9", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true, + "workflowRendererVersion": "LG", + "ue_links": [] + }, + "version": 0.4 +} \ No newline at end of file diff --git a/tests/integration/selected-groups.test.ts b/tests/integration/selected-groups.test.ts index 8f7f39f..05baf73 100644 --- a/tests/integration/selected-groups.test.ts +++ b/tests/integration/selected-groups.test.ts @@ -1,6 +1,7 @@ import { describe, it, beforeEach, expect } from "vitest"; import { loadFixture, capturePositions } from "../helpers"; import { layoutSelectedGroups } from "../../src/layout/selected-groups"; +import { parseLayoutToken } from "../../src/layout/title-tokens"; import { assertNodesInsideGroups, assertFiniteCoordinates, @@ -406,4 +407,314 @@ describe("layoutSelectedGroups", () => { assertFiniteCoordinates(graph); }); }); + + describe("parseLayoutToken", () => { + it("parses [HORIZONTAL] token", () => { + expect(parseLayoutToken("My Group [HORIZONTAL]")).toEqual({ + type: "horizontal", + }); + }); + + it("parses [VERTICAL] token", () => { + expect(parseLayoutToken("[VERTICAL] Group")).toEqual({ type: "vertical" }); + }); + + it("parses [xROW] tokens", () => { + expect(parseLayoutToken("Group [2ROW]")).toEqual({ type: "rows", count: 2 }); + expect(parseLayoutToken("[3ROW] Test")).toEqual({ type: "rows", count: 3 }); + }); + + it("parses [xCOL] tokens", () => { + expect(parseLayoutToken("Group [2COL]")).toEqual({ + type: "columns", + count: 2, + }); + expect(parseLayoutToken("[4COL] Test")).toEqual({ + type: "columns", + count: 4, + }); + }); + + it("treats [1ROW] as horizontal", () => { + expect(parseLayoutToken("Group [1ROW]")).toEqual({ type: "horizontal" }); + }); + + it("treats [1COL] as vertical", () => { + expect(parseLayoutToken("Group [1COL]")).toEqual({ type: "vertical" }); + }); + + it("is case-insensitive", () => { + expect(parseLayoutToken("[horizontal]")).toEqual({ type: "horizontal" }); + expect(parseLayoutToken("[Vertical]")).toEqual({ type: "vertical" }); + expect(parseLayoutToken("[2row]")).toEqual({ type: "rows", count: 2 }); + expect(parseLayoutToken("[3Col]")).toEqual({ type: "columns", count: 3 }); + }); + + it("returns default for no token", () => { + expect(parseLayoutToken("Normal Group")).toEqual({ type: "default" }); + expect(parseLayoutToken("")).toEqual({ type: "default" }); + }); + + it("returns first matching token", () => { + // HORIZONTAL comes before xROW in parsing order + expect(parseLayoutToken("[HORIZONTAL] [2ROW]")).toEqual({ + type: "horizontal", + }); + }); + }); + + describe("groups with layout tokens", () => { + function createTestGraph(groupTitle: string): LGraph { + return { + _nodes: [ + { + id: 1, + type: "Note", + title: "Node 1", + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [], + }, + { + id: 2, + type: "Note", + title: "Node 2", + pos: [100, 250], + size: [200, 100], + inputs: [], + outputs: [], + }, + { + id: 3, + type: "Note", + title: "Node 3", + pos: [100, 400], + size: [200, 100], + inputs: [], + outputs: [], + }, + { + id: 4, + type: "Note", + title: "Node 4", + pos: [100, 550], + size: [200, 100], + inputs: [], + outputs: [], + }, + ], + _groups: [ + { + id: 1, + title: groupTitle, + pos: [50, 50], + size: [500, 700], + }, + ], + links: new Map(), + }; + } + + it("[HORIZONTAL] arranges nodes in single row", () => { + const graph = createTestGraph("Test [HORIZONTAL]"); + layoutSelectedGroups(graph, new Set([1])); + + // All nodes should have same Y (single row) + const ys = graph._nodes.map((n) => n.pos[1]); + expect(new Set(ys).size).toBe(1); + + // Nodes should be spread horizontally + const xs = graph._nodes.map((n) => n.pos[0]).sort((a, b) => a - b); + expect(xs[3]).toBeGreaterThan(xs[0]); + + assertFiniteCoordinates(graph); + }); + + it("[VERTICAL] arranges nodes in single column", () => { + const graph = createTestGraph("Test [VERTICAL]"); + layoutSelectedGroups(graph, new Set([1])); + + // All nodes should have same X (single column) + const xs = graph._nodes.map((n) => n.pos[0]); + expect(new Set(xs).size).toBe(1); + + // Nodes should be spread vertically + const ys = graph._nodes.map((n) => n.pos[1]).sort((a, b) => a - b); + expect(ys[3]).toBeGreaterThan(ys[0]); + + assertFiniteCoordinates(graph); + }); + + it("[2ROW] distributes nodes into 2 rows", () => { + const graph = createTestGraph("Test [2ROW]"); + layoutSelectedGroups(graph, new Set([1])); + + // Should have exactly 2 distinct Y values (2 rows) + const ys = new Set(graph._nodes.map((n) => n.pos[1])); + expect(ys.size).toBe(2); + + assertFiniteCoordinates(graph); + }); + + it("[3COL] distributes nodes into 3 columns", () => { + const graph = createTestGraph("Test [3COL]"); + layoutSelectedGroups(graph, new Set([1])); + + // Should have 3 distinct X values (4 nodes into 3 cols = 2,1,1 distribution) + const xs = new Set(graph._nodes.map((n) => n.pos[0])); + expect(xs.size).toBe(3); + + assertFiniteCoordinates(graph); + }); + + it("lowercase token works", () => { + const graph = createTestGraph("Test [horizontal]"); + layoutSelectedGroups(graph, new Set([1])); + + // All nodes should have same Y (single row) + const ys = graph._nodes.map((n) => n.pos[1]); + expect(new Set(ys).size).toBe(1); + + assertFiniteCoordinates(graph); + }); + + it("ignores DAG structure with token", () => { + // Create graph with connected nodes + const graph: LGraph = { + _nodes: [ + { + id: 1, + type: "NodeA", + title: "Node A", + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [{ name: "out", type: "FLOAT", links: [1] }], + }, + { + id: 2, + type: "NodeB", + title: "Node B", + pos: [100, 300], + size: [200, 100], + inputs: [{ name: "in", type: "FLOAT", link: 1 }], + outputs: [], + }, + ], + _groups: [ + { + id: 1, + title: "Test [HORIZONTAL]", + pos: [50, 50], + size: [500, 400], + }, + ], + links: new Map([ + [ + 1, + { + id: 1, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: "FLOAT", + }, + ], + ]), + }; + + layoutSelectedGroups(graph, new Set([1])); + + // With [HORIZONTAL], both nodes should have same Y regardless of connection + const node1 = graph._nodes.find((n) => n.id === 1)!; + const node2 = graph._nodes.find((n) => n.id === 2)!; + expect(node1.pos[1]).toBe(node2.pos[1]); + + assertFiniteCoordinates(graph); + }); + + it("nested groups respect their own tokens", () => { + const graph: LGraph = { + _nodes: [ + // Nodes in outer group (not in inner) + { + id: 1, + type: "Note", + title: "Outer 1", + pos: [100, 100], + size: [100, 100], + inputs: [], + outputs: [], + }, + { + id: 2, + type: "Note", + title: "Outer 2", + pos: [100, 250], + size: [100, 100], + inputs: [], + outputs: [], + }, + // Nodes in inner group + { + id: 3, + type: "Note", + title: "Inner 1", + pos: [350, 150], + size: [100, 100], + inputs: [], + outputs: [], + }, + { + id: 4, + type: "Note", + title: "Inner 2", + pos: [350, 300], + size: [100, 100], + inputs: [], + outputs: [], + }, + ], + _groups: [ + { + id: 1, + title: "Outer [HORIZONTAL]", + pos: [50, 50], + size: [600, 500], + }, + { + id: 2, + title: "Inner [VERTICAL]", + pos: [300, 100], + size: [200, 350], + }, + ], + links: new Map(), + }; + + layoutSelectedGroups(graph, new Set([1])); + + // Inner group nodes should have same X (vertical) + const inner1 = graph._nodes.find((n) => n.id === 3)!; + const inner2 = graph._nodes.find((n) => n.id === 4)!; + expect(inner1.pos[0]).toBe(inner2.pos[0]); + + assertFiniteCoordinates(graph); + }); + + it("resizes group to fit token-arranged contents", () => { + const graph = createTestGraph("Test [HORIZONTAL]"); + layoutSelectedGroups(graph, new Set([1])); + + const group = graph._groups.find((g) => g.id === 1)!; + + // Group should be wider than tall (horizontal layout) + // With 4 nodes of 200px width + gaps, width should be significant + expect(group.size[0]).toBeGreaterThan(600); + + assertFiniteCoordinates(graph); + }); + }); }); diff --git a/tests/integration/token-layout.test.ts b/tests/integration/token-layout.test.ts new file mode 100644 index 0000000..0a2ce25 --- /dev/null +++ b/tests/integration/token-layout.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { loadFixture } from "../helpers"; +import { layoutSelectedGroups } from "../../src/layout/selected-groups"; +import { layoutGraph } from "../../src/layout/index"; +import { assertFiniteCoordinates } from "../invariants"; +import type { LGraph } from "../../src/layout/types"; + +/** + * Regression tests for token-based layout issues + * Based on real workflow: token-testing.json + */ +describe("token layout regression", () => { + let graph: LGraph; + + beforeEach(() => { + graph = loadFixture("token-testing.json"); + }); + + describe("[1ROW] ordering", () => { + // Group 8 "[1ROW]" contains nodes 41, 42 (GetNode), 43, 44 (PreviewImage), 47 (Note) + // GetNodes are originally at X=3290, PreviewImages at X=3600 + + it("preserves left-to-right order (sources before targets)", () => { + layoutSelectedGroups(graph, new Set([8])); + + const node41 = graph._nodes.find((n) => n.id === 41)!; // GetNode + const node42 = graph._nodes.find((n) => n.id === 42)!; // GetNode + const node43 = graph._nodes.find((n) => n.id === 43)!; // PreviewImage + const node44 = graph._nodes.find((n) => n.id === 44)!; // PreviewImage + + // GetNodes (originally left at X=3290) should remain left of PreviewImages (X=3600) + expect(node41.pos[0]).toBeLessThan(node43.pos[0]); + expect(node42.pos[0]).toBeLessThan(node44.pos[0]); + + assertFiniteCoordinates(graph); + }); + + it("places all nodes at same Y in horizontal row", () => { + layoutSelectedGroups(graph, new Set([8])); + + const groupNodes = graph._nodes.filter((n) => + [41, 42, 43, 44, 47].includes(n.id) + ); + const ys = new Set(groupNodes.map((n) => n.pos[1])); + + // All nodes should have same Y (single row) + expect(ys.size).toBe(1); + }); + }); + + describe("[3COL] with nested groups", () => { + // Group 9 "Full Workflow Group [3COL]" contains nested group 10 "[HORIZONTAL]" + // Group 10 contains nodes 55, 56, 57 (isolated notes) + + it("includes nested groups in column arrangement", () => { + layoutSelectedGroups(graph, new Set([9])); + + const group10 = graph._groups.find((g) => g.id === 10)!; + + // Nested group should be positioned + expect(group10.pos[0]).toBeGreaterThan(0); + expect(group10.pos[1]).toBeGreaterThan(0); + + assertFiniteCoordinates(graph); + }); + + it("nested group respects its own token when selected directly", () => { + // Test group 10 "[HORIZONTAL]" directly + layoutSelectedGroups(graph, new Set([10])); + + const innerNodes = graph._nodes.filter((n) => + [55, 56, 57].includes(n.id) + ); + const ys = new Set(innerNodes.map((n) => n.pos[1])); + + // Inner nodes should all be at same Y (horizontal) + expect(ys.size).toBe(1); + }); + + it("does not overlap nested groups with other content", () => { + layoutSelectedGroups(graph, new Set([9])); + + const group10 = graph._groups.find((g) => g.id === 10)!; + + // Group 10 should have valid bounds + expect(group10.size[0]).toBeGreaterThan(0); + expect(group10.size[1]).toBeGreaterThan(0); + + assertFiniteCoordinates(graph); + }); + }); + + describe("Organize Workflow with tokens", () => { + it("respects group tokens in full workflow layout", () => { + layoutGraph(graph); + + // Group 8 "[1ROW]" - nodes should be at same Y + const group8Nodes = graph._nodes.filter((n) => + [41, 42, 43, 44, 47].includes(n.id) + ); + const g8Ys = new Set(group8Nodes.map((n) => n.pos[1])); + expect(g8Ys.size).toBe(1); + + assertFiniteCoordinates(graph); + }); + + it("preserves ordering in workflow layout", () => { + layoutGraph(graph); + + // In "[1ROW]" group, GetNodes should be left of PreviewImages + const node41 = graph._nodes.find((n) => n.id === 41)!; + const node43 = graph._nodes.find((n) => n.id === 43)!; + + expect(node41.pos[0]).toBeLessThan(node43.pos[0]); + }); + }); + + describe("[HORIZONTAL] token", () => { + // Group 7 "[HORIZONTAL]" contains nodes 32, 33, 34, 35 (PreviewImage nodes) + + it("arranges nodes horizontally", () => { + layoutSelectedGroups(graph, new Set([7])); + + const nodes = graph._nodes.filter((n) => + [32, 33, 34, 35].includes(n.id) + ); + const ys = new Set(nodes.map((n) => n.pos[1])); + + // All nodes should have same Y + expect(ys.size).toBe(1); + + assertFiniteCoordinates(graph); + }); + }); +});