diff --git a/package.json b/package.json index a258134..268994f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "devDependencies": { "@floating-ui/core": "^1.2.6", "@floating-ui/dom": "^1.2.8", + "@types/cytoscape": "^3.19.9", + "@types/dompurify": "^3.0.2", "@zerodevx/svelte-toast": "^0.9.3", "eslint": "^8.37.0", "eslint-config-prettier": "^8.8.0", @@ -74,13 +76,15 @@ "@litegraph-ts/tsconfig": "workspace:*", "@sveltejs/vite-plugin-svelte": "^2.1.1", "@tsconfig/svelte": "^4.0.1", - "@types/dompurify": "^3.0.2", "canvas-to-svg": "^1.0.3", "cm6-theme-basic-dark": "^0.2.0", "cm6-theme-basic-light": "^0.2.0", "codemirror": "^6.0.1", "csv": "^6.3.0", "csv-parse": "^5.3.10", + "cytoscape": "^3.25.0", + "cytoscape-dagre": "^2.5.0", + "deep-equal": "^2.2.1", "dompurify": "^3.0.3", "events": "^3.3.0", "framework7": "^8.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a1120..fc56c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,9 +97,6 @@ importers: '@tsconfig/svelte': specifier: ^4.0.1 version: 4.0.1 - '@types/dompurify': - specifier: ^3.0.2 - version: 3.0.2 canvas-to-svg: specifier: ^1.0.3 version: 1.0.3 @@ -118,6 +115,15 @@ importers: csv-parse: specifier: ^5.3.10 version: 5.3.10 + cytoscape: + specifier: ^3.25.0 + version: 3.25.0 + cytoscape-dagre: + specifier: ^2.5.0 + version: 2.5.0(cytoscape@3.25.0) + deep-equal: + specifier: ^2.2.1 + version: 2.2.1 dompurify: specifier: ^3.0.3 version: 3.0.3 @@ -188,6 +194,12 @@ importers: '@floating-ui/dom': specifier: ^1.2.8 version: 1.2.8 + '@types/cytoscape': + specifier: ^3.19.9 + version: 3.19.9 + '@types/dompurify': + specifier: ^3.0.2 + version: 3.0.2 '@zerodevx/svelte-toast': specifier: ^0.9.3 version: 0.9.3(svelte@3.59.1) @@ -1363,7 +1375,6 @@ packages: '@codemirror/language': ^6.0.0 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 - '@lezer/common': ^1.0.0 dependencies: '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.0 @@ -2368,6 +2379,10 @@ packages: resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} dev: true + /@types/cytoscape@3.19.9: + resolution: {integrity: sha512-oqCx0ZGiBO0UESbjgq052vjDAy2X53lZpMrWqiweMpvVwKw/2IiYDdzPFK6+f4tMfdv9YKEM9raO5bAZc3UYBg==} + dev: true + /@types/d3-dsv@3.0.0: resolution: {integrity: sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==} @@ -2395,7 +2410,7 @@ packages: resolution: {integrity: sha512-YBL4ziFebbbfQfH5mlC+QTJsvh0oJUrWbmxKMyEdL7emlHJqGR2Qb34TEFKj+VCayBvjKy3xczMFNhugThUsfQ==} dependencies: '@types/trusted-types': 2.0.3 - dev: false + dev: true /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} @@ -2482,7 +2497,7 @@ packages: /@types/trusted-types@2.0.3: resolution: {integrity: sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==} - dev: false + dev: true /@types/uuid@9.0.1: resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==} @@ -2702,6 +2717,13 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true + /array-buffer-byte-length@1.0.0: + resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} + dependencies: + call-bind: 1.0.2 + is-array-buffer: 3.0.2 + dev: false + /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -2752,6 +2774,11 @@ packages: postcss-value-parser: 4.2.0 dev: true + /available-typed-arrays@1.0.5: + resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} + engines: {node: '>= 0.4'} + dev: false + /aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} dev: false @@ -3118,8 +3145,6 @@ packages: '@codemirror/search': 6.4.0 '@codemirror/state': 6.2.0 '@codemirror/view': 6.11.0 - transitivePeerDependencies: - - '@lezer/common' dev: false /codemirror@6.0.1(@lezer/common@1.0.2): @@ -3324,6 +3349,23 @@ packages: stream-transform: 3.2.6 dev: false + /cytoscape-dagre@2.5.0(cytoscape@3.25.0): + resolution: {integrity: sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==} + peerDependencies: + cytoscape: ^3.2.22 + dependencies: + cytoscape: 3.25.0 + dagre: 0.8.5 + dev: false + + /cytoscape@3.25.0: + resolution: {integrity: sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==} + engines: {node: '>=0.10'} + dependencies: + heap: 0.2.7 + lodash: 4.17.21 + dev: false + /d3-array@3.2.2: resolution: {integrity: sha512-yEEyEAbDrF8C6Ob2myOBLjwBLck1Z89jMGFee0oPsn95GqjerpaOA4ch+vc2l0FNFFwMD5N7OCSEN5eAlsUbgQ==} engines: {node: '>=12'} @@ -3474,6 +3516,13 @@ packages: engines: {node: '>=12'} dev: false + /dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + dependencies: + graphlib: 2.1.8 + lodash: 4.17.21 + dev: false + /dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} @@ -3539,6 +3588,29 @@ packages: dependencies: type-detect: 4.0.8 + /deep-equal@2.2.1: + resolution: {integrity: sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==} + dependencies: + array-buffer-byte-length: 1.0.0 + call-bind: 1.0.2 + es-get-iterator: 1.1.3 + get-intrinsic: 1.2.0 + is-arguments: 1.1.1 + is-array-buffer: 3.0.2 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + isarray: 2.0.5 + object-is: 1.1.5 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.5.0 + side-channel: 1.0.4 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.9 + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -3552,6 +3624,14 @@ packages: engines: {node: '>=8'} dev: true + /define-properties@1.2.0: + resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} + engines: {node: '>= 0.4'} + dependencies: + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + dev: false + /delaunator@5.0.0: resolution: {integrity: sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==} dependencies: @@ -3653,6 +3733,20 @@ packages: is-arrayish: 0.2.1 dev: true + /es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.0 + has-symbols: 1.0.3 + is-arguments: 1.1.1 + is-map: 2.0.2 + is-set: 2.0.2 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + dev: false + /es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -4258,6 +4352,12 @@ packages: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true + /for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.7 + dev: false + /forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} dev: false @@ -4348,6 +4448,10 @@ packages: /function-bind@1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4458,6 +4562,12 @@ packages: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} dev: true + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.0 + dev: false + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4465,6 +4575,12 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true + /graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + dependencies: + lodash: 4.17.21 + dev: false + /happy-dom@9.18.3: resolution: {integrity: sha512-b7iMGYeIXvUryNultA0AHEVU0FPpb2djJ/xSVlMDfP7HG4z7FomdqkCEpWtSv1zDL+t1gRUoBbpqFCoUBvjYtg==} dependencies: @@ -4490,6 +4606,10 @@ packages: har-schema: 2.0.0 dev: false + /has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + dev: false + /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -4500,17 +4620,34 @@ packages: engines: {node: '>=8'} dev: true + /has-property-descriptors@1.0.0: + resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + dependencies: + get-intrinsic: 1.2.0 + dev: false + /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} dev: false + /has-tostringtag@1.0.0: + resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: false + /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 + /heap@0.2.7: + resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} + dev: false + /htm@3.1.1: resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==} dev: false @@ -4639,26 +4776,77 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + /internal-slot@1.0.5: + resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.0 + has: 1.0.3 + side-channel: 1.0.4 + dev: false + /internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} dev: false + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + + /is-array-buffer@3.0.2: + resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.0 + is-typed-array: 1.1.10 + dev: false + /is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} dev: true + /is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 + /is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: false + /is-core-module@2.12.0: resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} dependencies: has: 1.0.3 + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: false + /is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -4685,6 +4873,17 @@ packages: dependencies: is-extglob: 2.1.1 + /is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + dev: false + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: false + /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4698,15 +4897,69 @@ packages: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} dev: true + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + + /is-set@2.0.2: + resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + dev: false + + /is-shared-array-buffer@1.0.2: + resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} + dependencies: + call-bind: 1.0.2 + dev: false + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} dev: true + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: false + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: false + + /is-typed-array@1.1.10: + resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + dev: false + /is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} dev: false + /is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + dev: false + + /is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.0 + dev: false + /is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -4718,6 +4971,10 @@ packages: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: false + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: false + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true @@ -5716,6 +5973,29 @@ packages: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: false + /object-is@1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + dev: false + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + dev: false + + /object.assign@4.1.4: + resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + has-symbols: 1.0.3 + object-keys: 1.1.1 + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -6135,6 +6415,15 @@ packages: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} dev: false + /regexp.prototype.flags@1.5.0: + resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + functions-have-names: 1.2.3 + dev: false + /request@2.88.2: resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} engines: {node: '>= 6'} @@ -6507,6 +6796,13 @@ packages: /std-env@3.3.3: resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} + /stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + dependencies: + internal-slot: 1.0.5 + dev: false + /stream-transform@3.2.6: resolution: {integrity: sha512-/pyOvaCQFqYTmrFhmMbnAEVo3SsTx1H39eUVPOtYeAgbEUc+rDo7GoP8LbHJgU83mKtzJe/7Nq/ipaAnUOHgJQ==} dev: false @@ -8361,6 +8657,37 @@ packages: webidl-conversions: 3.0.1 dev: false + /which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + dev: false + + /which-collection@1.0.1: + resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + dependencies: + is-map: 2.0.2 + is-set: 2.0.2 + is-weakmap: 2.0.1 + is-weakset: 2.0.2 + dev: false + + /which-typed-array@1.1.9: + resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + is-typed-array: 1.1.10 + dev: false + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} diff --git a/src/lib/ComfyBoxStdPrompt.ts b/src/lib/ComfyBoxStdPrompt.ts index 61833a0..0f93f12 100644 --- a/src/lib/ComfyBoxStdPrompt.ts +++ b/src/lib/ComfyBoxStdPrompt.ts @@ -224,13 +224,13 @@ const Metadata = z.object({ extra_data: ExtraData }) -const ComfyBoxStdPrompt = z.object({ +const StdPrompt = z.object({ version: z.number(), metadata: Metadata, parameters: Parameters }) -export default ComfyBoxStdPrompt +export default StdPrompt /* * A standardized Stable Diffusion parameter format that should be used with an @@ -260,4 +260,4 @@ export default ComfyBoxStdPrompt * "see" width 1024 and height 1024, even though the only parameter exposed from * the frontend was the scale of 2.) */ -export type ComfyBoxStdPrompt = z.infer +export type ComfyBoxStdPrompt = z.infer diff --git a/src/lib/ComfyBoxStdPromptSerializer.ts b/src/lib/ComfyBoxStdPromptSerializer.ts index 86f9714..7e33d21 100644 --- a/src/lib/ComfyBoxStdPromptSerializer.ts +++ b/src/lib/ComfyBoxStdPromptSerializer.ts @@ -1,31 +1,88 @@ import type { ComfyBoxStdGroupLoRA, ComfyBoxStdPrompt } from "$lib/ComfyBoxStdPrompt"; -import type { SerializedPrompt, SerializedPromptInputs } from "./components/ComfyApp"; +import StdPrompt from "$lib/ComfyBoxStdPrompt"; +import type { SafeParseReturnType, ZodError } from "zod"; +import type { ComfyNodeID } from "./api"; +import type { SerializedAppState, SerializedPrompt, SerializedPromptInputs, SerializedPromptInputsAll } from "./components/ComfyApp"; +import { ComfyComboNode, type ComfyWidgetNode } from "./nodes/widgets"; +import { basename, isSerializedPromptInputLink } from "./utils"; -export type ComfyPromptConverter = (stdPrompt: ComfyBoxStdPrompt, inputs: SerializedPromptInputs, nodeID: ComfyNodeID) => void; +export type ComfyPromptConverter = { + encoder: ComfyPromptEncoder, + decoder: ComfyPromptDecoder +} + +// +export type ComfyDecodeArgument = { + groupName: string, + keyName: string, + value: any, + widgetNode: ComfyWidgetNode +}; + +export type ComfyPromptEncoder = (stdPrompt: ComfyBoxStdPrompt, inputs: SerializedPromptInputs, nodeID: ComfyNodeID) => void; +export type ComfyPromptDecoder = (args: ComfyDecodeArgument[]) => void; -function LoraLoader(stdPrompt: ComfyBoxStdPrompt, inputs: SerializedPromptInputs) { - const params = stdPrompt.parameters +const LoraLoader: ComfyPromptConverter = { + encoder: (stdPrompt: ComfyBoxStdPrompt, inputs: SerializedPromptInputs) => { + const params = stdPrompt.parameters + const loras: ComfyBoxStdGroupLoRA[] = params.lora - const lora: ComfyBoxStdGroupLoRA = { - model_name: inputs["lora_name"], - strength_unet: inputs["strength_model"], - strength_tenc: inputs["strength_clip"] + for (const lora of loras) { + lora.model_hashes = { + addnet_shorthash: null // TODO find hashes for model! + } + } + }, + decoder: (args: ComfyDecodeArgument[]) => { + // Find corresponding model names in the ComfyUI models folder from the model base filename + for (const arg of args) { + if (arg.groupName === "lora" && arg.keyName === "model_name" && arg.widgetNode.is(ComfyComboNode)) { + const modelBasename = basename(arg.value); + const found = arg.widgetNode.properties.values.find(k => k.indexOf(modelBasename) !== -1) + if (found) + arg.value = found; + } + } } +} + +// input name -> group/key in standard prompt +type ComfyStdPromptMapping = Record - if (params.lora) - params.lora.push(lora) - else - params.lora = [lora] +type ComfyStdPromptSpec = { + paramMapping: ComfyStdPromptMapping, + extraParams?: Record, + converter?: ComfyPromptConverter, } -const ALL_CONVERTERS: Record = { - LoraLoader +const ALL_SPECS: Record = { + "KSampler": { + paramMapping: { + cfg: "k_sampler.cfg_scale", + seed: "k_sampler.seed", + steps: "k_sampler.steps", + sampler_name: "k_sampler.sampler_name", + scheduler: "k_sampler.scheduler", + denoise: "k_sampler.denoise", + }, + }, + "LoraLoader": { + paramMapping: { + lora_name: "lora.model_name", + strength_model: "lora.strength_unet", + strength_clip: "lora.strength_tenc", + }, + extraParams: { + "lora.module_name": "LoRA", + }, + converter: LoraLoader, + } } const COMMIT_HASH: string = __GIT_COMMIT_HASH__; export default class ComfyBoxStdPromptSerializer { - serialize(prompt: SerializedPrompt): ComfyBoxStdPrompt { + serialize(prompt: SerializedPromptInputsAll, workflow?: SerializedAppState): [SafeParseReturnType, any] { const stdPrompt: ComfyBoxStdPrompt = { version: 1, metadata: { @@ -33,23 +90,57 @@ export default class ComfyBoxStdPromptSerializer { commit_hash: COMMIT_HASH, extra_data: { comfybox: { + workflows: [] // TODO!!! } } }, parameters: {} } - for (const [nodeID, inputs] of Object.entries(prompt.output)) { + for (const [nodeID, inputs] of Object.entries(prompt)) { const classType = inputs.class_type - const converter = ALL_CONVERTERS[classType] - if (converter) { - converter(stdPrompt, inputs.inputs, nodeID) + const spec = ALL_SPECS[classType] + if (spec) { + console.warn("SPEC", spec, inputs) + let targets = {} + for (const [comfyKey, stdPromptKey] of Object.entries(spec.paramMapping)) { + const inputValue = inputs.inputs[comfyKey]; + if (inputValue != null && !isSerializedPromptInputLink(inputValue)) { + console.warn("GET", comfyKey, inputValue) + const trail = stdPromptKey.split("."); + let target = null; + + console.warn(trail, trail.length - 2); + for (let index = 0; index < trail.length - 1; index++) { + const name = trail[index]; + if (index === 0) { + targets[name] ||= {} + target = targets[name] + } + else { + target = target[name] + } + console.warn(index, name, target) + } + + let name = trail[trail.length - 1] + target[name] = inputValue + console.warn(stdPrompt.parameters) + } + } + + // TODO converter.encode + + for (const [groupName, group] of Object.entries(targets)) { + stdPrompt.parameters[groupName] ||= [] + stdPrompt.parameters[groupName].push(group) + } } else { - console.warn("No StdPrompt type converter for comfy class!", classType) + console.warn("No StdPrompt type spec for comfy class!", classType) } } - return stdPrompt + return [StdPrompt.safeParse(stdPrompt), stdPrompt]; } } diff --git a/src/lib/components/ComfyApp.ts b/src/lib/components/ComfyApp.ts index 97abc08..323660f 100644 --- a/src/lib/components/ComfyApp.ts +++ b/src/lib/components/ComfyApp.ts @@ -40,6 +40,7 @@ import { deserializeTemplateFromSVG, type SerializedComfyBoxTemplate } from "$li import templateState from "$lib/stores/templateState"; import { formatValidationError, type ComfyAPIPromptErrorResponse, formatExecutionError, type ComfyExecutionError } from "$lib/apiErrors"; import systemState from "$lib/stores/systemState"; +import type { JourneyNode } from "$lib/stores/journeyStates"; export const COMFYBOX_SERIAL_VERSION = 1; @@ -610,6 +611,8 @@ export default class ComfyApp { if (node?.onExecuted) { node.onExecuted(output); } + workflow.journey.onExecuted(promptID, nodeID, output, queueEntry); + workflow.journey.set(get(workflow.journey)) } } }); @@ -1028,6 +1031,18 @@ export default class ComfyApp { notify("Prompt queued.", { type: "info", showOn: "web" }); } + let journeyNode: JourneyNode | null; + + if (get(uiState).saveHistory) { + const activeNode = targetWorkflow.journey.getActiveNode(); + journeyNode = targetWorkflow.journey.pushPatchOntoActive(targetWorkflow, activeNode); + + // if no patch was applied, use currently selected node for prompt image + // output purposes + if (journeyNode == null) + journeyNode = activeNode; + } + this.processingQueue = true; let workflow: ComfyBoxWorkflow; @@ -1064,9 +1079,6 @@ export default class ComfyApp { // console.debug(graphToGraphVis(workflow.graph)) // console.debug(promptToGraphVis(p)) - const stdPrompt = this.stdPromptSerializer.serialize(p); - // console.warn("STD", stdPrompt); - const extraData: ComfyBoxPromptExtraData = { extra_pnginfo: { comfyBoxWorkflow: wf, @@ -1100,6 +1112,9 @@ export default class ComfyApp { else { queueState.afterQueued(workflow.id, response.promptID, response.number, p.output, extraData) workflowState.afterQueued(workflow.id, response.promptID) + if (journeyNode != null) { + targetWorkflow.journey.afterQueued(journeyNode, response.promptID); + } } } catch (err) { errorMes = err?.toString(); diff --git a/src/lib/components/ComfyJourneyView.svelte b/src/lib/components/ComfyJourneyView.svelte new file mode 100644 index 0000000..7486293 --- /dev/null +++ b/src/lib/components/ComfyJourneyView.svelte @@ -0,0 +1,228 @@ + + + + + +
+
+ + +
+ {#key $journey.version} + + {/key} +
+ +
+
+ {#each MODES as [theMode, icon]} + + + {/each} +
+
+ + diff --git a/src/lib/components/ComfyPaneView.svelte b/src/lib/components/ComfyPaneView.svelte index 36b6a0b..46f3ff6 100644 --- a/src/lib/components/ComfyPaneView.svelte +++ b/src/lib/components/ComfyPaneView.svelte @@ -1,5 +1,5 @@ @@ -46,8 +48,10 @@ {:else if mode === "queue"} + {:else if mode === "journey"} + {:else} -
(Blank)
+
(Blank: {mode})
{/if} {#if showSwitcher} diff --git a/src/lib/components/ComfyQueue.svelte b/src/lib/components/ComfyQueue.svelte index 31719e2..a34af04 100644 --- a/src/lib/components/ComfyQueue.svelte +++ b/src/lib/components/ComfyQueue.svelte @@ -32,6 +32,7 @@ import ComfyQueueGridDisplay from "./ComfyQueueGridDisplay.svelte"; import { WORKFLOWS_VIEW } from "./ComfyBoxWorkflowsView.svelte"; import uiQueueState from "$lib/stores/uiQueueState"; + import type { SerializedAppState, SerializedPromptInputsAll } from "./ComfyApp"; export let app: ComfyApp; @@ -124,19 +125,22 @@ let showModal = false; let expandAll = false; - let selectedPrompt = null; + let selectedPrompt: SerializedPromptInputsAll | null = null; + let selectedWorkflow: SerializedAppState | null = null; let selectedImages = []; function showPrompt(entry: QueueUIEntry) { if (entry.error != null) { showModal = false; expandAll = false; selectedPrompt = null; + selectedWorkflow = null; selectedImages = []; showError(entry.entry.promptID); } else { - selectedPrompt = entry.entry.prompt; + selectedPrompt = entry.entry.prompt, + selectedWorkflow = entry.entry.extraData.extra_pnginfo.comfyBoxWorkflow selectedImages = entry.images; showModal = true; expandAll = false @@ -145,6 +149,7 @@ function closeModal() { selectedPrompt = null + selectedWorkflow = null; selectedImages = [] showModal = false; expandAll = false; @@ -165,7 +170,7 @@ {#if selectedPrompt} - { closeModal(); closeDialog(); }} {app} prompt={selectedPrompt} images={selectedImages} {expandAll} /> + { closeModal(); closeDialog(); }} {app} prompt={selectedPrompt} workflow={selectedWorkflow} images={selectedImages} {expandAll} /> {/if}
diff --git a/src/lib/components/ComfySettingsView.svelte b/src/lib/components/ComfySettingsView.svelte index 15d3dcd..f2643a1 100644 --- a/src/lib/components/ComfySettingsView.svelte +++ b/src/lib/components/ComfySettingsView.svelte @@ -173,7 +173,7 @@ } .comfy-settings-entries { - padding: 3rem 3rem; + padding: 2rem 0.75rem; height: 100%; } diff --git a/src/lib/components/JourneyRenderer.svelte b/src/lib/components/JourneyRenderer.svelte new file mode 100644 index 0000000..bb1ec4f --- /dev/null +++ b/src/lib/components/JourneyRenderer.svelte @@ -0,0 +1,259 @@ + + + + +{#if workflow && journey} + +{/if} diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 63f82a5..41688f1 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -37,8 +37,11 @@ on:close={close} on:cancel={doClose} on:click|self={close} + on:contextmenu|preventDefault|stopPropagation > -
+
diff --git a/src/lib/components/PromptDisplay.svelte b/src/lib/components/PromptDisplay.svelte index b96e2c7..004b4bd 100644 --- a/src/lib/components/PromptDisplay.svelte +++ b/src/lib/components/PromptDisplay.svelte @@ -1,6 +1,6 @@
- - -
- - {#each Object.entries(prompt) as [nodeID, inputs], i} - {@const classType = inputs.class_type} - {@const filtered = Object.entries(inputs.inputs).filter((i) => !isInputLink(i[1]))} - {#if filtered.length > 0} -
- - - {#each filtered as [inputName, input]} - - -
- {#if isInputLink(input)} - Link {input[0]} -> {input[1]} - {:else if typeof input === "object"} - - - - - {:else if isMultiline(input)} - {@const lines = Math.max(countNewLines(input), input.length / splitLength)} - - {:else} - - {/if} -
-
- {/each} -
-
-
- {/if} - {/each} -
-
-
-
- {#if comfyBoxImages.length > 0} - - + + + + + {#if comfyBoxImages.length > 0} + Output type: {litegraphType} {#if receiveTargets.length > 0} @@ -191,9 +199,94 @@
No receive output targets found across all workflows.
{/if}
-
-
- {/if} + + {/if} + + {#if stdPromptError} + +
Parsing Error
+
+
+ +
+
+
+ +
Original Data
+
+
+ +
+
+
+ {:else if stdPrompt} + +
+
+ +
+
+
+ {:else} + + (No standard prompt) + + {/if} +
+ + +
+ + {#each Object.entries(prompt) as [nodeID, inputs], i} + {@const classType = inputs.class_type} + {@const filtered = Object.entries(inputs.inputs).filter((i) => !isInputLink(i[1]))} + {#if filtered.length > 0} +
+ + + {#each filtered as [inputName, input]} + + +
+ {#if isInputLink(input)} + Link {input[0]} -> {input[1]} + {:else if typeof input === "object"} + + + + + {:else if isMultiline(input)} + {@const lines = Math.max(countNewLines(input), input.length / splitLength)} + + {:else} + + {/if} +
+
+ {/each} +
+
+
+ {/if} + {/each} +
+
+
+
+
{#if images.length > 0}
@@ -221,21 +314,34 @@ display: flex; flex-wrap: nowrap; - overflow-y: auto; flex-direction: column; - @media (min-width: 1600px) { + @media (min-width: 1200px) { flex-direction: row; } } + .scroll-container { + position: relative; + /* overflow-y: auto; */ + flex: 1 1 0%; + } + + .json { + @include json-view; + } + .prompt-and-sends { width: 50%; - .scroll-container { - position: relative; - /* overflow-y: auto; */ - flex: 1 1 0%; + overflow-y: auto; + + :global(>.tabs) { + height: 100%; + + :global(>.tabitem) { + overflow-y: auto; + } } .copy-button { diff --git a/src/lib/components/WidgetContainer.svelte b/src/lib/components/WidgetContainer.svelte index da6001d..b9443ee 100644 --- a/src/lib/components/WidgetContainer.svelte +++ b/src/lib/components/WidgetContainer.svelte @@ -70,39 +70,34 @@ {#if container} - {#key $attrsChanged} - - {/key} + {:else if widget && widget.node} {@const edit = $uiState.uiUnlocked && $uiState.uiEditMode === "widgets"} {@const hidden = isHidden(widget)} {@const hovered = $uiState.uiUnlocked && $selectionState.currentHovered.has(widget.id)} {@const selected = $uiState.uiUnlocked && $selectionState.currentSelection.includes(widget.id)} - {#key $attrsChanged} - {#key $propsChanged} -
- -
- {#if hidden && edit} -
- {/if} - {#if showHandles || hovered} -
- {/if} - {/key} - {/key} +
+ +
+ {#if hidden && edit} +
+ {/if} + {#if showHandles || hovered} +
+ {/if} {/if} diff --git a/src/lib/components/graph/GraphEdge.svelte b/src/lib/components/graph/GraphEdge.svelte new file mode 100644 index 0000000..dbb374b --- /dev/null +++ b/src/lib/components/graph/GraphEdge.svelte @@ -0,0 +1,15 @@ + diff --git a/src/lib/components/graph/GraphNode.svelte b/src/lib/components/graph/GraphNode.svelte new file mode 100644 index 0000000..23c302b --- /dev/null +++ b/src/lib/components/graph/GraphNode.svelte @@ -0,0 +1,15 @@ + diff --git a/src/lib/components/graph/GraphStyles.ts b/src/lib/components/graph/GraphStyles.ts new file mode 100644 index 0000000..d724c1d --- /dev/null +++ b/src/lib/components/graph/GraphStyles.ts @@ -0,0 +1,107 @@ +import type { Stylesheet } from "cytoscape"; + +const styles: Stylesheet[] = [ + { + selector: "core", + style: { + "selection-box-color": "#ddd", + "selection-box-opacity": 0.65, + "selection-box-border-color": "#aaa", + "selection-box-border-width": 1, + "active-bg-color": "#4b5563", + "active-bg-opacity": 0.35, + "active-bg-size": 30, + "outside-texture-bg-color": "#000", + "outside-texture-bg-opacity": 0.125, + } + }, + { + selector: ".historyNode", + style: { + "width": "100", + "height": "100", + "shape": "round-rectangle", + "font-family": "Arial", + "font-size": "18", + "font-weight": "normal", + "content": `data(label)`, + "text-valign": "center", + "text-wrap": "wrap", + "text-max-width": "140", + "background-color": "#60a5fa", + "border-color": "#2563eb", + "border-width": "3", + "color": "#1d3660" + } + }, + { + selector: "node.historyNode[bgImage]", + style: { + "label": "", + "background-image": "data(bgImage)", + "background-image-containment": "over", + "background-fit": "cover", + "color": "transparent" + } + }, + { + selector: ".historyNode:selected", + style: { + "background-color": "#f97316", + "color": "white", + "border-color": "#ea580c", + "line-color": "#0e76ba", + "target-arrow-color": "#0e76ba", + } + }, + { + selector: ".patchNode", + style: { + "width": "label", + "height": "label", + "shape": "round-rectangle", + "padding": "20", + "font-family": "Arial", + "font-size": "11", + "font-weight": "normal", + "content": `data(label)`, + "text-valign": "center", + "text-wrap": "wrap", + "text-max-width": "140", + "line-height": "1.5", + "background-color": "#374151", + "border-color": "#1f2937", + "border-width": "1", + "color": "white", + } + }, + { + selector: "edge", + style: { + "curve-style": "bezier", + "color": "darkred", + "text-background-color": "#ffffff", + "text-background-opacity": 1, + "text-background-padding": "3", + "width": 3, + "target-arrow-shape": "triangle", + "line-color": "#1d4ed8", + "target-arrow-color": "#1d4ed8", + "font-weight": "bold" + } + }, + { + selector: "edge[label]", + style: { + "content": `data(label)`, + } + }, + { + selector: "edge.label", + style: { + "line-color": "orange", + "target-arrow-color": "orange" + } + } +] +export default styles; diff --git a/src/lib/components/modal/EditTemplateModal.svelte b/src/lib/components/modal/EditTemplateModal.svelte index 9979f22..319551d 100644 --- a/src/lib/components/modal/EditTemplateModal.svelte +++ b/src/lib/components/modal/EditTemplateModal.svelte @@ -13,7 +13,7 @@ import Textbox from "@gradio/form/src/Textbox.svelte"; import type { ModalData } from "$lib/stores/modalState"; import { writable, type Writable } from "svelte/store"; - import { negmod } from "$lib/utils"; + import { negmod } from "$lib/utils"; const DOMPurify = createDOMPurify(window); export let templateAndSvg: SerializedComfyBoxTemplate; diff --git a/src/lib/components/modal/RestoreParamsTable.svelte b/src/lib/components/modal/RestoreParamsTable.svelte new file mode 100644 index 0000000..6b4bb55 --- /dev/null +++ b/src/lib/components/modal/RestoreParamsTable.svelte @@ -0,0 +1,140 @@ + + +
+ {#if workflow == null} +
No workflow is active.
+ {:else if Object.keys(uiRestoreParams).length === 0} +
+

No parameters to restore found in this workflow.

+

(Either prompt is unchanged from active workflow, or the workflow the parameters were saved from was different)

+
+ {:else} + + Parameters + + + + {#each uiRestoreParams as { node, widget, sources }} + +
➤ {widget.attrs.title || node.title}
+ {#each sources as source} + {@const value = String(source.finalValue)} +
+
+ + {capitalize(source.type)} +
+ {#if isMultiline(value, 20)} + {@const lines = Math.max(countNewLines(value), value.length / 20)} + + {:else} + + {/if} +
+
+
+
+ {/each} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/nodes/ComfyGraphNode.ts b/src/lib/nodes/ComfyGraphNode.ts index e03204a..e3698e8 100644 --- a/src/lib/nodes/ComfyGraphNode.ts +++ b/src/lib/nodes/ComfyGraphNode.ts @@ -118,7 +118,7 @@ export default class ComfyGraphNode extends LGraphNode { } get dragItem(): WidgetLayout | null { - return layoutStates.getDragItemByNode(this); + return layoutStates.getDragItemByNode(this) as WidgetLayout; } get workflow(): ComfyBoxWorkflow | null { diff --git a/src/lib/nodes/widgets/ComfyButtonNode.ts b/src/lib/nodes/widgets/ComfyButtonNode.ts index ed82a65..3e97d40 100644 --- a/src/lib/nodes/widgets/ComfyButtonNode.ts +++ b/src/lib/nodes/widgets/ComfyButtonNode.ts @@ -11,6 +11,7 @@ export default class ComfyButtonNode extends ComfyWidgetNode { override properties: ComfyButtonProperties = { tags: [], defaultValue: false, + excludeFromJourney: true, param: "bang" } diff --git a/src/lib/nodes/widgets/ComfyCheckboxNode.ts b/src/lib/nodes/widgets/ComfyCheckboxNode.ts index 91aebf6..0dcf366 100644 --- a/src/lib/nodes/widgets/ComfyCheckboxNode.ts +++ b/src/lib/nodes/widgets/ComfyCheckboxNode.ts @@ -11,6 +11,7 @@ export default class ComfyCheckboxNode extends ComfyWidgetNode { override properties: ComfyCheckboxProperties = { tags: [], defaultValue: false, + excludeFromJourney: false, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyComboNode.ts b/src/lib/nodes/widgets/ComfyComboNode.ts index 85ba08b..3c2e951 100644 --- a/src/lib/nodes/widgets/ComfyComboNode.ts +++ b/src/lib/nodes/widgets/ComfyComboNode.ts @@ -19,7 +19,8 @@ export default class ComfyComboNode extends ComfyWidgetNode { tags: [], defaultValue: "A", values: ["A", "B", "C", "D"], - convertValueToLabelCode: "" + convertValueToLabelCode: "", + excludeFromJourney: false, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyGalleryNode.ts b/src/lib/nodes/widgets/ComfyGalleryNode.ts index 1267d18..e9d2229 100644 --- a/src/lib/nodes/widgets/ComfyGalleryNode.ts +++ b/src/lib/nodes/widgets/ComfyGalleryNode.ts @@ -18,7 +18,8 @@ export default class ComfyGalleryNode extends ComfyWidgetNode 0 && (selectedIndex != null || this.properties.autoSelectOnUpdate)) { diff --git a/src/lib/nodes/widgets/ComfyImageUploadNode.ts b/src/lib/nodes/widgets/ComfyImageUploadNode.ts index 1782b18..37cbfac 100644 --- a/src/lib/nodes/widgets/ComfyImageUploadNode.ts +++ b/src/lib/nodes/widgets/ComfyImageUploadNode.ts @@ -6,7 +6,6 @@ import ImageUploadWidget from "$lib/widgets/ImageUploadWidget.svelte"; import type { ComfyWidgetProperties } from "./ComfyWidgetNode"; import ComfyWidgetNode from "./ComfyWidgetNode"; import { get, writable, type Writable } from "svelte/store"; -import { type LineGroup } from "$lib/components/MaskCanvas.svelte" export interface ComfyImageUploadNodeProperties extends ComfyWidgetProperties { maskCount: number @@ -16,7 +15,8 @@ export default class ComfyImageUploadNode extends ComfyWidgetNode { override properties: ComfyMarkdownProperties = { tags: [], defaultValue: false, + excludeFromJourney: true, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyMultiRegionNode.ts b/src/lib/nodes/widgets/ComfyMultiRegionNode.ts index a7297f6..5dab5e1 100644 --- a/src/lib/nodes/widgets/ComfyMultiRegionNode.ts +++ b/src/lib/nodes/widgets/ComfyMultiRegionNode.ts @@ -30,7 +30,8 @@ export default class ComfyMultiRegionNode extends ComfyWidgetNode canvasWidth: 512, canvasHeight: 512, canvasImageURL: null, - inputType: "size" + inputType: "size", + excludeFromJourney: false, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyNumberNode.ts b/src/lib/nodes/widgets/ComfyNumberNode.ts index ca58b6e..c602395 100644 --- a/src/lib/nodes/widgets/ComfyNumberNode.ts +++ b/src/lib/nodes/widgets/ComfyNumberNode.ts @@ -20,7 +20,8 @@ export default class ComfyNumberNode extends ComfyWidgetNode { min: 0, max: 10, step: 1, - precision: 1 + precision: 1, + excludeFromJourney: false, } override svelteComponentType = NumberWidget diff --git a/src/lib/nodes/widgets/ComfyRadioNode.ts b/src/lib/nodes/widgets/ComfyRadioNode.ts index aee9f86..e6cbc38 100644 --- a/src/lib/nodes/widgets/ComfyRadioNode.ts +++ b/src/lib/nodes/widgets/ComfyRadioNode.ts @@ -16,6 +16,7 @@ export default class ComfyRadioNode extends ComfyWidgetNode { tags: [], choices: ["Choice A", "Choice B", "Choice C"], defaultValue: "Choice A", + excludeFromJourney: false, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyTextNode.ts b/src/lib/nodes/widgets/ComfyTextNode.ts index baab9a2..2d91824 100644 --- a/src/lib/nodes/widgets/ComfyTextNode.ts +++ b/src/lib/nodes/widgets/ComfyTextNode.ts @@ -16,6 +16,7 @@ export default class ComfyTextNode extends ComfyWidgetNode { multiline: false, lines: 5, maxLines: 5, + excludeFromJourney: false, } static slotLayout: SlotLayout = { diff --git a/src/lib/nodes/widgets/ComfyWidgetNode.ts b/src/lib/nodes/widgets/ComfyWidgetNode.ts index a0c3c8a..0761f55 100644 --- a/src/lib/nodes/widgets/ComfyWidgetNode.ts +++ b/src/lib/nodes/widgets/ComfyWidgetNode.ts @@ -37,7 +37,8 @@ export type SerializedComfyWidgetNode = { */ export interface ComfyWidgetProperties extends ComfyGraphNodeProperties { - defaultValue: any + defaultValue: any, + excludeFromJourney: boolean } export type ShownOutputProperty = { diff --git a/src/lib/restoreParameters.ts b/src/lib/restoreParameters.ts new file mode 100644 index 0000000..6d23fec --- /dev/null +++ b/src/lib/restoreParameters.ts @@ -0,0 +1,362 @@ +import type { INodeInputSlot, NodeID, SerializedLGraph, SerializedLGraphNode } from "@litegraph-ts/core"; +import type { SerializedPrompt } from "./components/ComfyApp"; +import type { ComfyWidgetNode } from "./nodes/widgets"; +import type { SerializedComfyWidgetNode } from "./nodes/widgets/ComfyWidgetNode"; +import { isComfyWidgetNode, type SerializedLayoutState } from "./stores/layoutStates"; +import type { ComfyBoxWorkflow } from "./stores/workflowState"; +import { isSerializedPromptInputLink } from "./utils"; +import ComfyBoxStdPromptSerializer from "./ComfyBoxStdPromptSerializer"; + +export type RestoreParamType = "workflow" | "backend" | "stdPrompt"; + +/* + * Data of a parameter that can be restored. Paired with a parameter name. + */ +export interface RestoreParamSource { + type: T, + + /* + * A human-readable name for this parameter + */ + name?: string, + + /* + * LiteGraph type of the widget node + */ + nodeType: string, + + /* + * The actual value to copy to the widget after all conversions have been + * applied. + */ + finalValue: any +} + +/* + * A serialized ComfyWidgetNode from the saved workflow that corresponds + * *exactly* to a node with the same ID in the current workflow. Easiest case + * since the parameter value can just be copied without much fuss. + */ +export interface RestoreParamSourceWorkflowNode extends RestoreParamSource<"workflow"> { + type: "workflow", + + prevValue?: any +} + +export type RestoreParamWorkflowNodeTargets = Record + +/* + * A value received by the ComfyUI *backend* that corresponds to a value that + * was held in a ComfyWidgetNode. These may not necessarily be one-to-one + * because there can be extra frontend-only processing nodes between the two. + * + * (Example: a node that converts a random prompt template into a final prompt + * string, then passes *that* prompt string to the backend. The backend will not + * see the template string, so it will be missing in the arguments to ComfyUI's + * prompt endpoint. Hence this parameter source won't account for those kinds of + * values.) + */ +export interface RestoreParamSourceBackendNodeInput extends RestoreParamSource<"backend"> { + type: "backend", + + backendNode: SerializedComfyWidgetNode, + + /* + * If false, this node was connected to the backend node across one or more + * additional frontend nodes, so the value in the source may not correspond + * exactly to the widget's original value + */ + isDirectAttachment: boolean +} + +/* + * A value contained in the standard prompt extracted from the saved workflow. + * + * This should only be necessary to fall back on if one workflow's parameters + * are to be used in a completely separate workflow's. + */ +export interface RestoreParamSourceStdPrompt extends RestoreParamSource<"stdPrompt"> { + type: "stdPrompt", + + /* + * Name of the group containing the value to pass + * + * "lora" + */ + groupName: string, + + /* + * The standard prompt group containing the value and metadata like + * "positive"/"negative" for identification use + * + * { "$meta": { ... }, model_name: "...", model_hashes: [...], ... } + */ + group: T, + + /* + * Key of the group parameter holding the actual value + + * "model_name" + */ + key: K, + + /* + * The raw value as saved to the prompt, not accounting for stuff like hashes + * + * "contrastFix" + */ + rawValue: T[K] + + /* + * The *actual* value that will be copied into the ComfyWidgetNode, after + * conversion to account for filepaths/etc. from prompt adapters has been + * completed + * + * "models/lora/contrastFix.safetensors" + */ + finalValue: any +} + +export type RestoreParamTargets = Record + +function isSerializedComfyWidgetNode(param: any): param is SerializedComfyWidgetNode { + return param != null && typeof param === "object" && "id" in param && "comfyValue" in param +} + +function findUpstreamSerializedWidgetNode(prompt: SerializedPrompt, input: INodeInputSlot): [SerializedComfyWidgetNode | null, boolean | null] { + let linkID = input.link; + let isDirectAttachment = true; + + while (linkID) { + const link = prompt.workflow.links[linkID] + if (link == null) + return [null, null]; + + const originNode = prompt.workflow.nodes.find(n => n.id === link[1]) + if (isSerializedComfyWidgetNode(originNode)) + return [originNode, isDirectAttachment] + + isDirectAttachment = false; + + // TODO: getUpstreamLink() for serialized nodes? + if (originNode.inputs && originNode.inputs.length === 1) + linkID = originNode.inputs[0].link + else + linkID = null; + } + + return [null, null]; +} + +const addSource = (result: RestoreParamTargets, targetNode: ComfyWidgetNode, source: RestoreParamSource) => { + result[targetNode.id] ||= [] + result[targetNode.id].push(source); +} + +export function concatRestoreParams(a: RestoreParamTargets, b: Record): RestoreParamTargets { + for (const [targetNodeID, source] of Object.entries(b)) { + a[targetNodeID] ||= [] + a[targetNodeID].push(source); + } + return a; +} + +export function concatRestoreParams2(a: RestoreParamTargets, b: RestoreParamTargets): RestoreParamTargets { + for (const [targetNodeID, vs] of Object.entries(b)) { + a[targetNodeID] ||= [] + for (const source of vs) { + a[targetNodeID].push(source); + } + } + return a; +} + +/* + * Like getWorkflowRestoreParams but applies to an instanced (non-serialized) workflow + */ +export function getWorkflowRestoreParamsFromWorkflow(workflow: ComfyBoxWorkflow, noExclude: boolean = false): RestoreParamWorkflowNodeTargets { + const result = {} + + for (const node of workflow.graph.iterateNodesInOrderRecursive()) { + if (!isComfyWidgetNode(node)) + continue; + + if (!noExclude && node.properties.excludeFromJourney) + continue; + + let name = null; + const realNode = workflow.graph.getNodeByIdRecursive(node.id); + if (realNode != null && isComfyWidgetNode(realNode)) { + name = realNode.title || name; + const widget = realNode.dragItem; + if (widget != null) { + name = widget.attrs.title || name; + } + } + + const finalValue = node.getValue(); + if (finalValue != null) { + const source: RestoreParamSourceWorkflowNode = { + type: "workflow", + nodeType: node.type, + name, + finalValue, + } + result[node.id] = source; + } + } + + return result +} + +export function getWorkflowRestoreParams(serGraph: SerializedLGraph, workflow?: ComfyBoxWorkflow, noExclude: boolean = false): RestoreParamWorkflowNodeTargets { + const result = {} + + for (const node of serGraph.nodes) { + if (!isSerializedComfyWidgetNode(node)) + continue; + + if (!noExclude && node.properties.excludeFromJourney) + continue; + + let name = null; + const realNode = workflow.graph.getNodeByIdRecursive(node.id); + if (realNode != null && isComfyWidgetNode(realNode)) { + name = realNode.title || name; + const widget = realNode.dragItem; + if (widget != null) { + name = widget.attrs.title || name; + } + } + + const finalValue = node.comfyValue + if (finalValue != null) { + const source: RestoreParamSourceWorkflowNode = { + type: "workflow", + nodeType: node.type, + name, + prevValue: finalValue, + finalValue, + } + result[node.id] = source; + } + } + + return result +} + +function* iterateSerializedNodesRecursive(serGraph: SerializedLGraph): Iterable { + for (const serNode of serGraph.nodes) { + yield serNode; + + if (serNode.type === "graph/subgraph") { + for (const childNode of iterateSerializedNodesRecursive((serNode as any).subgraph)) { + yield childNode; + } + } + } +} + +export function getWorkflowRestoreParamsUsingLayout(serGraph: SerializedLGraph, layout?: SerializedLayoutState, noExclude: boolean = false): RestoreParamWorkflowNodeTargets { + const result = {} + + for (const serNode of iterateSerializedNodesRecursive(serGraph)) { + if (!isSerializedComfyWidgetNode(serNode)) { + continue; + } + + if (!noExclude && serNode.properties.excludeFromJourney) { + continue; + } + + let name = null; + const serWidget = Array.from(Object.values(layout?.allItems || {})).find(di => di.dragItem.type === "widget" && di.dragItem.nodeId === serNode.id) + if (serWidget) { + name = serWidget.dragItem.attrs.title; + } + + const finalValue = serNode.comfyValue + if (finalValue != null) { + const source: RestoreParamSourceWorkflowNode = { + type: "workflow", + nodeType: serNode.type, + name, + finalValue, + } + result[serNode.id] = source; + } + } + + return result +} + +export function getBackendRestoreParams(workflow: ComfyBoxWorkflow, prompt: SerializedPrompt): Record { + const result = {} + + const graph = workflow.graph; + + // Figure out what parameters the backend received. If there was a widget + // node attached to a backend node's input upstream, then we can use that + // value. + for (const [serNodeID, inputs] of Object.entries(prompt.output)) { + const serNode = prompt.workflow.nodes.find(sn => sn.id === serNodeID) + if (serNode == null) + continue; + + for (const [inputName, inputValue] of Object.entries(inputs)) { + const input = serNode.inputs.find(i => i.name === inputName); + if (input == null) + continue; + + if (isSerializedPromptInputLink(inputValue)) + continue; + + const [originNode, isDirectAttachment] = findUpstreamSerializedWidgetNode(prompt, input) + + if (originNode) { + const foundNode = graph.getNodeByIdRecursive(serNode.id); + if (isComfyWidgetNode(foundNode) && foundNode.type === serNode.type) { + const source: RestoreParamSourceBackendNodeInput = { + type: "backend", + nodeType: foundNode.type, + finalValue: inputValue, + backendNode: serNode, + isDirectAttachment + } + addSource(result, foundNode, source) + } + } + } + } + + return result +} + +export default function getRestoreParameters(workflow: ComfyBoxWorkflow, prompt: SerializedPrompt): RestoreParamTargets { + const result = {} + + const workflowParams = getWorkflowRestoreParams(prompt.workflow, workflow); + concatRestoreParams(result, workflowParams); + + const backendParams = getBackendRestoreParams(workflow, prompt); + concatRestoreParams2(result, backendParams); + + // Step 3: Extract the standard prompt from the workflow and use that to + // infer parameter types + + // TODO + + // const serializer = new ComfyBoxStdPromptSerializer(); + // const stdPrompt = serializer.serialize(prompt); + + // const allWidgetNodes = Array.from(graph.iterateNodesInOrderRecursive()).filter(isComfyWidgetNode); + + // for (const widgetNode of allWidgetNodes) { + + // } + + // for (const [groupName, groups] of Object.entries(stdPrompt)) { + // } + + return result; +} diff --git a/src/lib/stores/journeyStates.ts b/src/lib/stores/journeyStates.ts new file mode 100644 index 0000000..be7e446 --- /dev/null +++ b/src/lib/stores/journeyStates.ts @@ -0,0 +1,365 @@ +import { get, writable } from 'svelte/store'; +import type { Readable, Writable } from 'svelte/store'; +import { isComfyWidgetNode, type DragItemID, type IDragItem } from './layoutStates'; +import { LiteGraph, type LGraphNode, type NodeID, type UUID } from '@litegraph-ts/core'; +import type { SerializedAppState } from '$lib/components/ComfyApp'; +import { getWorkflowRestoreParamsFromWorkflow, type RestoreParamSourceWorkflowNode, type RestoreParamTargets, type RestoreParamWorkflowNodeTargets } from '$lib/restoreParameters'; +import { v4 as uuidv4 } from "uuid"; +import deepEqual from "deep-equal"; +import notify from '$lib/notify'; +import type { ComfyBoxWorkflow } from './workflowState'; +import type { ComfyNodeID, PromptID } from '$lib/api'; +import type { SerializedPromptOutput } from '$lib/utils'; +import type { QueueEntry } from './queueState'; + +export type JourneyNodeType = "root" | "patch"; + +export type JourneyNodeID = UUID; + +export interface JourneyNode { + id: JourneyNodeID, + type: JourneyNodeType, + children: JourneyPatchNode[], + promptIDs: Set, + images?: string[] +} + +export interface JourneyRootNode extends JourneyNode { + type: "root" + + /* + * This contains all the values of the workflow to set + */ + base: RestoreParamWorkflowNodeTargets +} + +export interface JourneyPatchNode extends JourneyNode { + type: "patch" + + parent: JourneyNode, + + /* + * This contains only the subset of parameters that were changed from the + * parent + */ + patch: RestoreParamWorkflowNodeTargets +} + +function isRoot(node: JourneyNode): node is JourneyRootNode { + return node.type === "root"; +} + +function isPatch(node: JourneyNode): node is JourneyPatchNode { + return node.type === "patch"; +} + +export function resolvePatch(node: JourneyNode, memoize?: Record): RestoreParamWorkflowNodeTargets { + if (node.type === "root") { + return { ...(node as JourneyRootNode).base } + } + + if (memoize && memoize[node.id] != null) + return { ...memoize[node.id] } + + const patchNode = (node as JourneyPatchNode); + const patch = { ...patchNode.patch }; + const base = resolvePatch(patchNode.parent); + for (const [k, v] of Object.entries(patch)) { + base[k] = v; + } + + if (memoize) { + memoize[node.id] = base; + } + + return base; +} + +export function diffParams(base: RestoreParamWorkflowNodeTargets, updated: RestoreParamWorkflowNodeTargets): RestoreParamWorkflowNodeTargets { + const result = {} + + for (const [k, v] of Object.entries(updated)) { + if (!(k in base) || !deepEqual(base[k].finalValue, v.finalValue, { strict: true })) { + result[k] = v + v.prevValue = base[k].finalValue + } + } + + return result; +} + +export function calculateWorkflowParamsPatch(parent: JourneyNode, newParams: RestoreParamWorkflowNodeTargets): RestoreParamWorkflowNodeTargets { + const patch = resolvePatch(parent); + const diff = diffParams(patch, newParams) + return diff; +} + +/* + * A "journey" is like browser history for prompts, except organized in a + * tree-like graph. It lets you save incremental changes to your workflow and + * jump between past and present sets of parameters. + */ +export type JourneyState = { + root: JourneyRootNode | null, + nodesByID: Record, + nodesByPromptID: Record, + activeNodeID: JourneyNodeID | null, + + /* + * Incremented when graph structure is updated + */ + version: number +} + +type JourneyStateOps = { + clear: () => void, + getActiveNode: () => JourneyNode | null, + // addNode: (params: RestoreParamWorkflowNodeTargets, parent?: JourneyNodeID | JourneyNode) => JourneyNode, + selectNode: (id?: JourneyNodeID | JourneyNode) => void, + iterateBreadthFirst: (id?: JourneyNodeID | null) => Iterable, + iterateLinearPath: (id: JourneyNodeID) => Iterable, + pushPatchOntoActive: (workflow: ComfyBoxWorkflow, activeNode?: JourneyNode, showNotification?: boolean) => JourneyNode | null + afterQueued: (journeyNode: JourneyNode, promptID: PromptID) => void, + onExecuted: (promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutput, queueEntry: QueueEntry) => void +} + +export type WritableJourneyStateStore = Writable & JourneyStateOps; + +function create() { + const store: Writable = writable( + { + root: null, + nodesByID: {}, + nodesByPromptID: {}, + activeNodeID: null, + version: 0 + }) + + function clear() { + store.set({ + root: null, + nodesByID: {}, + nodesByPromptID: {}, + activeNodeID: null, + version: 0 + }) + } + + function getActiveNode(): JourneyNode | null { + const state = get(store) + if (state.activeNodeID === null) + return null; + const active = state.nodesByID[state.activeNodeID] + if (active == null) { + console.error("[journeyStates] Active node not found in graph!", state.activeNodeID); + } + return active; + } + + /* + * params: full state or state patch of widgets in the UI + * parent: parent node to patch against + */ + function addNode(params: RestoreParamWorkflowNodeTargets, parent?: JourneyNodeID | JourneyNode): JourneyNode { + let _node: JourneyRootNode | JourneyPatchNode; + + store.update(s => { + let parentNode: JourneyNode | null = null + if (parent != null) { + if (typeof parent === "object") + parent = parent.id; + parentNode = s.nodesByID[parent]; + if (parentNode == null) { + throw new Error(`Could not find parent node ${parent} to insert into!`) + } + } + if (parentNode == null) { + _node = { + id: uuidv4(), + type: "root", + children: [], + promptIDs: new Set(), + base: { ...params } + } + s.root = _node + } + else { + _node = { + id: uuidv4(), + type: "patch", + parent: parentNode, + children: [], + promptIDs: new Set(), + patch: params, + } + parentNode.children.push(_node); + } + s.nodesByID[_node.id] = _node; + s.version += 1; + return s; + }); + return _node; + } + + function pushPatchOntoActive(workflow: ComfyBoxWorkflow, activeNode?: JourneyNode, showNotification: boolean = false): JourneyNode | null { + const workflowParams = getWorkflowRestoreParamsFromWorkflow(workflow) + + let journeyNode + + if (activeNode == null) { + // add root node + if (get(store).root != null) { + console.debug("[journeyStates] Root already exists") + return null; + } + journeyNode = addNode(workflowParams, null); + if (showNotification) + notify("Pushed a new base workflow state.", { type: "info" }) + } + else { + // add patch node + const patch = calculateWorkflowParamsPatch(activeNode, workflowParams); + const patchedCount = Object.keys(patch).length; + if (patchedCount === 0) { + console.debug("[journeyStates] Patch had no diff") + if (showNotification) + notify("No changes were made to active parameters yet.", { type: "warning" }) + return null; + } + journeyNode = addNode(patch, activeNode); + if (showNotification) + notify(`Pushed new state with ${patchedCount} changes.`, { type: "info" }) + } + + if (journeyNode != null) { + selectNode(journeyNode); + } + + console.debug("[journeyStates] added node", journeyNode) + return journeyNode; + } + + function selectNode(obj?: JourneyNodeID | JourneyNode) { + store.update(s => { + if (typeof obj === "string") + s.activeNodeID = obj; + else + s.activeNodeID = obj.id; + return s; + }) + } + + // function removeNode(id: JourneyNodeID) { + // store.update(s => { + // const node = s.nodesByID[id]; + // if (node == null) { + // throw new Error(`Journey node not found: ${id}`) + // } + + // if (node.type === "patch") { + + // } + // else { + // s.root = null; + // } + + // delete s.nodesByID[id]; + // s.version += 1; + + // return s; + // }); + // } + + function* iterateBreadthFirst(id?: JourneyNodeID | null): Iterable { + const state = get(store); + + id ||= state.root?.id; + if (id == null) + return; + + const queue = [state.nodesByID[id]]; + while (queue.length > 0) { + const node = queue.shift(); + yield node; + if (node.children) { + for (const child of node.children) { + queue.push(state.nodesByID[child.id]); + } + } + } + } + + function* iterateNodeParents(node: JourneyNode): Iterable { + while (isPatch(node)) { + yield node.parent; + node = node.parent; + } + } + + function iterateLinearPath(id: JourneyNodeID): Iterable { + const state = get(store); + + const node = state.nodesByID[id]; + if (node == null) { + console.error("[journeyStates] Journey node not found!", id); + return + } + + let path = Array.from(iterateNodeParents(node)).reverse() + path.push(node) + + // pick first child for nodes downstream + let child = node.children[0] + while (child != null) { + path.push(child); + child = child.children[0]; + } + + return path; + } + + function afterQueued(journeyNode: JourneyNode, promptID: PromptID) { + journeyNode.promptIDs.add(promptID); + store.update(s => { + s.nodesByPromptID[promptID] = journeyNode; + return s; + }) + } + + function onExecuted(promptID: PromptID, nodeID: ComfyNodeID, output: SerializedPromptOutput, queueEntry: QueueEntry) { + const journeyNode = get(store).nodesByPromptID[promptID]; + if (journeyNode == null) + return; + + // TODO + store.update(s => { + s.version += 1; + s.activeNodeID = journeyNode.id; + return s; + }) + } + + return { + ...store, + getActiveNode, + clear, + // addNode, + pushPatchOntoActive, + selectNode, + iterateBreadthFirst, + iterateLinearPath, + afterQueued, + onExecuted, + } +} + +export type JourneyStateStaticOps = { + create: () => WritableJourneyStateStore +} + +// These will be attached to workflows. +const ops: JourneyStateStaticOps = { + create +} + +export default ops diff --git a/src/lib/stores/layoutStates.ts b/src/lib/stores/layoutStates.ts index bae1f34..36594d3 100644 --- a/src/lib/stores/layoutStates.ts +++ b/src/lib/stores/layoutStates.ts @@ -512,6 +512,14 @@ const ALL_ATTRIBUTES: AttributesSpecList = [ serialize: serializeStringArray, deserialize: deserializeStringArray }, + { + name: "excludeFromJourney", + type: "boolean", + location: "nodeProps", + editable: true, + defaultValue: false, + canShow: isComfyWidgetNode + }, // Container tags are contained in the widget attributes { diff --git a/src/lib/stores/queueState.ts b/src/lib/stores/queueState.ts index 9e45774..f93982c 100644 --- a/src/lib/stores/queueState.ts +++ b/src/lib/stores/queueState.ts @@ -36,7 +36,7 @@ type QueueStateOps = { export type QueueEntry = { /*** Data preserved on page refresh ***/ - /** Priority of the prompt. -1 means to queue at the front. */ + /** Priority of the prompt. Lower/negative numbers get higher priority. */ number: number, queuedAt?: Date, finishedAt?: Date, diff --git a/src/lib/stores/selectionState.ts b/src/lib/stores/selectionState.ts index c8c007f..c0b4a6d 100644 --- a/src/lib/stores/selectionState.ts +++ b/src/lib/stores/selectionState.ts @@ -24,7 +24,12 @@ export type SelectionState = { /* * Currently hovered nodes. */ - currentHoveredNodes: Set + currentHoveredNodes: Set, + + /* + * Nodes affected by the patch hovered in the journey pane + */ + currentPatchHoveredNodes: Set } type SelectionStateOps = { @@ -38,6 +43,7 @@ const store: Writable = writable( currentSelectionNodes: [], currentHovered: new Set(), currentHoveredNodes: new Set(), + currentPatchHoveredNodes: new Set(), }) function clear() { @@ -46,12 +52,13 @@ function clear() { currentSelectionNodes: [], currentHovered: new Set(), currentHoveredNodes: new Set(), + currentPatchHoveredNodes: new Set(), }) } -const uiStateStore: WritableSelectionStateStore = +const selectionStateStore: WritableSelectionStateStore = { ...store, clear } -export default uiStateStore; +export default selectionStateStore; diff --git a/src/lib/stores/uiQueueState.ts b/src/lib/stores/uiQueueState.ts index a361c22..5517703 100644 --- a/src/lib/stores/uiQueueState.ts +++ b/src/lib/stores/uiQueueState.ts @@ -62,7 +62,7 @@ function convertEntry(entry: QueueEntry, status: QueueUIEntryStatus): QueueUIEnt const subgraphs: string[] | null = entry.extraData?.extra_pnginfo?.comfyBoxPrompt?.subgraphs; - let message = "Prompt"; + let message = `#${entry.number}: Prompt`; if (entry.extraData?.workflowTitle != null) { message = `${entry.extraData.workflowTitle}` } @@ -89,6 +89,13 @@ function convertEntry(entry: QueueEntry, status: QueueUIEntryStatus): QueueUIEnt } } +export function getQueueEntryImages(queueEntry: QueueEntry): string[] { + return Object.values(queueEntry.outputs) + .filter(o => o.images) + .flatMap(o => o.images) + .map(convertComfyOutputToComfyURL); +} + function convertPendingEntry(entry: QueueEntry, status: QueueUIEntryStatus): QueueUIEntry { const result = convertEntry(entry, status); @@ -97,10 +104,7 @@ function convertPendingEntry(entry: QueueEntry, status: QueueUIEntryStatus): Que result.images = thumbnails.map(convertComfyOutputToComfyURL); } - const outputs = Object.values(entry.outputs) - .filter(o => o.images) - .flatMap(o => o.images) - .map(convertComfyOutputToComfyURL); + const outputs = getQueueEntryImages(entry); if (outputs) { result.images = result.images.concat(outputs) } @@ -111,11 +115,7 @@ function convertPendingEntry(entry: QueueEntry, status: QueueUIEntryStatus): Que function convertCompletedEntry(entry: CompletedQueueEntry): QueueUIEntry { const result = convertEntry(entry.entry, entry.status); - const images = Object.values(entry.entry.outputs) - .filter(o => o.images) - .flatMap(o => o.images) - .map(convertComfyOutputToComfyURL); - result.images = images + result.images = getQueueEntryImages(entry.entry) if (entry.message) result.submessage = entry.message @@ -132,6 +132,10 @@ function updateFromQueue(queuePending: QueueEntry[], queueRunning: QueueEntry[]) // newest entries appear at the top s.queuedEntries = queuePending.map((e) => convertPendingEntry(e, "pending")).reverse(); s.runningEntries = queueRunning.map((e) => convertPendingEntry(e, "running")).reverse(); + + s.queuedEntries.sort((a, b) => a.entry.number - b.entry.number) + s.runningEntries.sort((a, b) => a.entry.number - b.entry.number) + s.queueUIEntries = s.queuedEntries.concat(s.runningEntries); console.warn("[ComfyQueue] BUILDQUEUE", s.queuedEntries.length, s.runningEntries.length) return s; diff --git a/src/lib/stores/uiState.ts b/src/lib/stores/uiState.ts index e28bc90..398616e 100644 --- a/src/lib/stores/uiState.ts +++ b/src/lib/stores/uiState.ts @@ -15,6 +15,8 @@ export type UIState = { forceSaveUserState: boolean | null, activeError: PromptID | null + + saveHistory: boolean } type UIStateOps = { @@ -34,7 +36,9 @@ const store: Writable = writable( reconnecting: false, forceSaveUserState: null, - activeError: null + activeError: null, + + saveHistory: true }) function reconnecting() { diff --git a/src/lib/stores/workflowState.ts b/src/lib/stores/workflowState.ts index 559a969..930afdb 100644 --- a/src/lib/stores/workflowState.ts +++ b/src/lib/stores/workflowState.ts @@ -2,7 +2,7 @@ import type { SerializedGraphCanvasState } from '$lib/ComfyGraphCanvas'; import { clamp, LGraphNode, type LGraphCanvas, type NodeID, type SerializedLGraph, type UUID, LGraph, LiteGraph, type SlotType, NodeMode } from '@litegraph-ts/core'; import { get, writable } from 'svelte/store'; import type { Readable, Writable } from 'svelte/store'; -import { defaultWorkflowAttributes, type SerializedLayoutState, type WritableLayoutStateStore } from './layoutStates'; +import { defaultWorkflowAttributes, isComfyWidgetNode, type SerializedLayoutState, type WritableLayoutStateStore } from './layoutStates'; import ComfyGraph from '$lib/ComfyGraph'; import layoutStates from './layoutStates'; import { v4 as uuidv4 } from "uuid"; @@ -12,6 +12,9 @@ import type { SerializedAppState, SerializedPrompt } from '$lib/components/Comfy import type ComfyReceiveOutputNode from '$lib/nodes/actions/ComfyReceiveOutputNode'; import type { ComfyBoxPromptExtraData, PromptID } from '$lib/api'; import type { ComfyAPIPromptErrorResponse, ComfyExecutionError } from '$lib/apiErrors'; +import type { WritableJourneyStateStore } from './journeyState'; +import journeyStates from './journeyStates'; +import type { RestoreParamWorkflowNodeTargets } from '$lib/restoreParameters'; type ActiveCanvas = { canvas: LGraphCanvas | null; @@ -115,7 +118,12 @@ export class ComfyBoxWorkflow { /* * Completed queue entry ID that holds the last validation/execution error. */ - lastError?: PromptID + lastError?: PromptID; + + /* + * Saved prompt history ("journey") for this workflow + */ + journey: WritableJourneyStateStore; get layout(): WritableLayoutStateStore | null { return layoutStates.getLayout(this.id) @@ -133,6 +141,7 @@ export class ComfyBoxWorkflow { title, } this.graph = new ComfyGraph(this.id); + this.journey = journeyStates.create(); } notifyModified() { @@ -204,6 +213,21 @@ export class ComfyBoxWorkflow { } } + applyParamsPatch(patch: RestoreParamWorkflowNodeTargets) { + for (const [nodeId, source] of Object.entries(patch)) { + const node = this.graph.getNodeByIdRecursive(nodeId); + if (node == null) { + console.error("[applyParamsPatch] Node was missing in patch!!", nodeId, source) + continue; + } + if (!isComfyWidgetNode(node)) { + console.error("[applyParamsPatch] Node was not ComfyWidgetNode!!", nodeId, source) + continue; + } + node.value.set(source.finalValue); + } + } + /* * Creates a workflow and layout. * diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 68bdff3..1a4c924 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,8 +4,8 @@ import type { FileData as GradioFileData } from "@gradio/upload"; import { Subgraph, type LGraph, type LGraphNode, type LLink, type SerializedLGraph, type UUID, type NodeID, type SlotType, type Vector4, type SerializedLGraphNode } from "@litegraph-ts/core"; import { get } from "svelte/store"; import type { ComfyNodeID } from "./api"; -import ComfyApp, { type SerializedPrompt } from "./components/ComfyApp"; import workflowState, { type WorkflowReceiveOutputTargets } from "./stores/workflowState"; +import ComfyApp, { type SerializedPrompt, type SerializedPromptInput, type SerializedPromptInputLink } from "./components/ComfyApp"; import { ImageViewer } from "./ImageViewer"; import configState from "$lib/stores/configState"; import SendOutputModal, { type SendOutputModalResult } from "$lib/components/modal/SendOutputModal.svelte"; @@ -143,6 +143,10 @@ export function stopDrag(evt: MouseEvent, layoutState: WritableLayoutStateStore) layoutState.notifyWorkflowModified(); }; +export function isSerializedPromptInputLink(inputValue: SerializedPromptInput): inputValue is SerializedPromptInputLink { + return Array.isArray(inputValue) && inputValue.length === 2 && typeof inputValue[0] === "string" && typeof inputValue[1] === "number" +} + export function graphToGraphVis(graph: LGraph): string { let links: string[] = [] let seenLinks = new Set() @@ -247,7 +251,7 @@ export function promptToGraphVis(prompt: SerializedPrompt): string { for (const pair2 of Object.entries(o.inputs)) { const [inpName, i] = pair2; - if (Array.isArray(i) && i.length === 2 && typeof i[0] === "string" && typeof i[1] === "number") { + if (isSerializedPromptInputLink(i)) { // Link const [inpID, inpSlot] = i; if (ids[inpID] == null) @@ -741,3 +745,7 @@ const MOBILE_USER_AGENTS = ["iPhone", "iPad", "Android", "BlackBerry", "WebOs"]. export function isMobileBrowser(userAgent: string): boolean { return MOBILE_USER_AGENTS.some(a => userAgent.match(a)) } + +export function isMultiline(input: any, splitLength: number = 50): boolean { + return typeof input === "string" && (input.length > splitLength || countNewLines(input) > 1); +} diff --git a/src/lib/widgets/ImageUploadWidget.svelte b/src/lib/widgets/ImageUploadWidget.svelte index 4f9c3cf..ac85c76 100644 --- a/src/lib/widgets/ImageUploadWidget.svelte +++ b/src/lib/widgets/ImageUploadWidget.svelte @@ -259,18 +259,18 @@ {#if canMask}
- {#if editMask} - - {/if} - + {#if editMask} + + {/if}
{/if}
diff --git a/src/scss/global.scss b/src/scss/global.scss index 8e738b4..5df21c6 100644 --- a/src/scss/global.scss +++ b/src/scss/global.scss @@ -13,6 +13,10 @@ body { height: 100%; margin: 0px; font-family: Arial; + display: block; + position: absolute; + top: 0; + left: 0; } :root { diff --git a/src/tests/stores/journeyStatesTests.ts b/src/tests/stores/journeyStatesTests.ts new file mode 100644 index 0000000..6112c07 --- /dev/null +++ b/src/tests/stores/journeyStatesTests.ts @@ -0,0 +1,99 @@ +import { get } from "svelte/store"; +import journeyState, { type JourneyState } from "$lib/stores/journeyState" +import { expect } from 'vitest'; +import UnitTest from "../UnitTest"; +import { Watch } from "@litegraph-ts/nodes-basic"; +import { ComfyBoxWorkflow } from "$lib/stores/workflowState"; +import { ComfyNumberNode } from "$lib/nodes/widgets"; +import { LiteGraph } from "@litegraph-ts/core"; +import { getWorkflowRestoreParamsFromWorkflow } from "$lib/restoreParameters"; +import { calculateWorkflowParamsPatch } from "$lib/stores/journeyStates"; + +export default class journeyStateTests extends UnitTest { + test__patches() { + const [workflow, layoutState] = ComfyBoxWorkflow.create() + const { graph, journey } = workflow; + layoutState.initDefaultLayout() // adds 3 containers + + const widget1 = LiteGraph.createNode(ComfyNumberNode); + const widget2 = LiteGraph.createNode(ComfyNumberNode); + const watch1 = LiteGraph.createNode(Watch); + const watch2 = LiteGraph.createNode(Watch); + + graph.add(widget1) + graph.add(watch1) + graph.add(widget2) + graph.add(watch2) + + widget1.connect(0, watch1, 0); + widget2.connect(0, watch2, 0); + widget1.setValue(0) + widget2.setValue(0) + + let workflowParams = getWorkflowRestoreParamsFromWorkflow(workflow) + const root = journey.addNode(workflowParams, null); + + expect(root).toEqual({ + id: root.id, + type: "root", + children: [], + base: { + [widget1.id]: { + type: "workflow", + finalValue: 0, + }, + [widget2.id]: { + type: "workflow", + finalValue: 0, + } + } + }); + + widget1.setValue(5) + + workflowParams = getWorkflowRestoreParamsFromWorkflow(workflow) + const patchParams = calculateWorkflowParamsPatch(root, workflowParams) + const patch = journey.addNode(patchParams, root.id); + + expect(patch).toEqual({ + id: patch.id, + type: "patch", + parent: root, + children: [], + patch: { + [widget1.id]: { + type: "workflow", + finalValue: 5 + }, + } + }) + } + + test__patches_exclusions() { + const [workflow, layoutState] = ComfyBoxWorkflow.create() + const { graph, journey } = workflow; + layoutState.initDefaultLayout() // adds 3 containers + + const widget1 = LiteGraph.createNode(ComfyNumberNode); + const widget2 = LiteGraph.createNode(ComfyNumberNode); + const watch1 = LiteGraph.createNode(Watch); + const watch2 = LiteGraph.createNode(Watch); + + graph.add(widget1) + graph.add(watch1) + + widget1.properties.excludeFromJourney = true; + widget1.connect(0, watch1, 0); + widget1.setValue(0) + + let workflowParams = getWorkflowRestoreParamsFromWorkflow(workflow) + const root = journey.addNode(workflowParams, null); + + expect(root).toEqual({ + id: root.id, + type: "root", + children: [], + base: {} + }); + } +} diff --git a/src/tests/testSuite.ts b/src/tests/testSuite.ts index a8d80e2..d2ec5a4 100644 --- a/src/tests/testSuite.ts +++ b/src/tests/testSuite.ts @@ -4,3 +4,4 @@ export { default as parseA1111Tests } from "./parseA1111Tests" export { default as convertA1111ToStdPromptTests } from "./convertA1111ToStdPromptTests" export { default as convertVanillaWorkflowTest } from "./convertVanillaWorkflowTests" export { default as configStateTests } from "./stores/configStateTests" +export { default as journeyStates } from "./stores/journeyStatesTests" diff --git a/vite.config.ts b/vite.config.ts index 1373ffd..6c5ce0f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ import { visualizer } from "rollup-plugin-visualizer"; const isProduction = process.env.NODE_ENV === "production"; console.log("Production build: " + isProduction) -const commitHash = execSync('git rev-parse HEAD').toString(); +const commitHash = execSync('git rev-parse HEAD').toString().trim(); console.log("Commit: " + commitHash) export default defineConfig({