Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
6ea615fb8c graph mutation service implementation 2025-08-17 23:48:31 -04:00
58 changed files with 6264 additions and 6836 deletions

568
package-lock.json generated
View File

@@ -975,32 +975,6 @@
"integrity": "sha512-o6WFbYn9yAkGbkOwvhPF7pbKDvN0occZ21Tfyhya8CIsIqKpTHLft0aOqo4yhSh+kTxN16FYjsfrTH5Olk4WuA==",
"license": "GPL-3.0-only"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -4717,17 +4691,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@trivago/prettier-plugin-sort-imports": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.0.tgz",
@@ -4763,38 +4726,6 @@
}
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -5720,15 +5651,6 @@
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"deprecated": "Use your platform's native atob() and btoa() methods instead",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -5798,18 +5720,6 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-globals": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
"integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"acorn": "^8.1.0",
"acorn-walk": "^8.0.2"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -5820,34 +5730,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agentkeepalive": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
@@ -6058,14 +5940,6 @@
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -7227,14 +7101,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -7305,58 +7171,12 @@
"node": ">=4"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/cssstyle": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
"integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"cssom": "~0.3.6"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cssstyle/node_modules/cssom": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
"integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"abab": "^2.0.6",
"whatwg-mimetype": "^3.0.0",
"whatwg-url": "^11.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -7420,14 +7240,6 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/decode-named-character-reference": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@@ -7583,17 +7395,6 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
@@ -7689,21 +7490,6 @@
}
]
},
"node_modules/domexception": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
"integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
"deprecated": "Use your platform's native DOMException instead",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/domhandler": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
@@ -8065,29 +7851,6 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true
},
"node_modules/escodegen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^5.2.0",
"esutils": "^2.0.2"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=6.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/eslint": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.12.0.tgz",
@@ -9638,20 +9401,6 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"dev": true
},
"node_modules/html-encoding-sniffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"whatwg-encoding": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -9703,37 +9452,6 @@
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@tootallnate/once": "2",
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@@ -10353,14 +10071,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -10592,53 +10302,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "20.0.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"abab": "^2.0.6",
"acorn": "^8.8.1",
"acorn-globals": "^7.0.0",
"cssom": "^0.5.0",
"cssstyle": "^2.3.0",
"data-urls": "^3.0.2",
"decimal.js": "^10.4.2",
"domexception": "^4.0.0",
"escodegen": "^2.0.0",
"form-data": "^4.0.0",
"html-encoding-sniffer": "^3.0.0",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.1",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.2",
"parse5": "^7.1.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^4.1.2",
"w3c-xmlserializer": "^4.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^2.0.0",
"whatwg-mimetype": "^3.0.0",
"whatwg-url": "^11.0.0",
"ws": "^8.11.0",
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"canvas": "^2.5.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -11651,14 +11314,6 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@@ -13048,14 +12703,6 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -14215,14 +13862,6 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -14288,14 +13927,6 @@
}
]
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -14801,14 +14432,6 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -15016,20 +14639,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -15681,14 +15290,6 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/synckit": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
@@ -15943,37 +15544,6 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"punycode": "^2.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/trough": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
@@ -16004,51 +15574,6 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -16762,17 +16287,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -17014,18 +16528,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
@@ -17102,14 +16604,6 @@
"node": ">=6"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -17911,20 +17405,6 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
@@ -17986,20 +17466,6 @@
"node": ">=0.8.0"
}
},
"node_modules/whatwg-encoding": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
@@ -18009,21 +17475,6 @@
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"tr46": "^3.0.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/when-exit": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz",
@@ -18320,14 +17771,6 @@
"node": ">=12"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -18433,17 +17876,6 @@
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,208 +0,0 @@
<template>
<div class="h-full overflow-hidden pb-1">
<div class="flex item-center">
<div
v-for="item in columns"
:key="item.key"
class="flex justify-between items-center px-2 overflow-hidden hover:bg-blue-600/40 cursor-pointer"
:style="{ flexBasis: `${item.width}px`, height: '36px' }"
@click="changeSort(item)"
>
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
{{ $t(`g.${item.key}`) }}
</span>
<span
v-show="item.key === sortField"
:class="[
'text-xs pi',
sortDirection === 'asc' ? 'pi-angle-up' : 'pi-angle-down'
]"
></span>
</div>
</div>
<div :style="{ height: 'calc(100% - 36px)' }">
<VirtualScroll :items="sortedItems" :item-size="36">
<template #item="{ item: row }">
<div
class="h-full py-px"
@click="emit('itemClick', row, $event)"
@dblclick="emit('itemDbClick', row, $event)"
>
<div
:class="[
'flex items-center h-full hover:bg-blue-600/40',
selectedKeys.includes(row.key) ? 'bg-blue-700/40' : ''
]"
>
<div
v-for="(item, index) in columns"
:key="item.key"
class="flex items-center px-2 py-1 overflow-hidden select-none"
:style="{ flexBasis: `${item.width}px`, textAlign: item.align }"
>
<span v-if="index === 0" :class="['mr-2 pi', row.icon]"></span>
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
{{ (row._display as any)[item.key] }}
</span>
</div>
</div>
</div>
</template>
</VirtualScroll>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatSize } from '@/utils/formatUtil'
import VirtualScroll from './VirtualScroll.vue'
const { t } = useI18n()
type SortDirection = 'asc' | 'desc'
type Item = {
key: string
name: string
type: string
modifyTime: number
size: number
}
type RecordString<T> = Record<keyof T, any>
type ResolvedItem<T> = T & {
icon: string
_display: RecordString<T>
}
interface Column {
key: string
width: number
align?: 'left' | 'right'
defaultSort?: SortDirection
renderText: (val: any, row: Item) => string
}
const props = defineProps<{
items: Item[]
}>()
const selectedKeys = defineModel<string[]>({ default: [] })
const emit = defineEmits<{
itemClick: [Item, MouseEvent]
itemDbClick: [Item, MouseEvent]
}>()
const columns = ref<Column[]>([
{
key: 'name',
width: 300,
renderText: (val) => val
},
{
key: 'modifyTime',
width: 200,
defaultSort: 'desc',
renderText: (val) =>
new Date(val).toLocaleDateString() +
' ' +
new Date(val).toLocaleTimeString()
},
{
key: 'type',
width: 100,
renderText: (val) => t(`g.${val}`)
},
{
key: 'size',
width: 120,
defaultSort: 'desc',
align: 'right',
renderText: (val, item) => (item.type === 'folder' ? '' : formatSize(val))
}
])
provide('listExplorerColumns', columns)
const sortDirection = ref<SortDirection>('asc')
const sortField = ref('name')
const iconMapLegacy = (icon: string) => {
const prefix = 'pi-'
const legacy: Record<string, string> = {
audio: 'headphones'
}
return prefix + (legacy[icon] || icon)
}
const renderedItems = computed(() => {
const columnRenderText = columns.value.reduce(
(acc, column) => {
acc[column.key] = column.renderText
return acc
},
{} as Record<string, (val: any, row: Item) => string>
)
return props.items.map((item) => {
const display = Object.entries(item).reduce(
(acc, [key, value]) => {
acc[key] = columnRenderText[key]?.(value, item) ?? value
return acc
},
{} as Record<string, any>
)
return { ...item, icon: iconMapLegacy(item.type), _display: display }
})
})
const sortedItems = computed(() => {
const folderItems: ResolvedItem<Item>[] = []
const fileItems: ResolvedItem<Item>[] = []
for (const item of renderedItems.value) {
if (item.type === 'folder') {
folderItems.push(item)
} else {
fileItems.push(item)
}
}
const direction = sortDirection.value === 'asc' ? 1 : -1
const sorting = (a: ResolvedItem<Item>, b: ResolvedItem<Item>) => {
const aValue = (a as any)[sortField.value]
const bValue = (b as any)[sortField.value]
const result =
typeof aValue === 'string'
? aValue.localeCompare(bValue)
: aValue - bValue
return result * direction
}
folderItems.sort(sorting)
fileItems.sort(sorting)
const folderFirstField = ['modifyTime', 'type']
return direction > 0 || folderFirstField.includes(sortField.value)
? [...folderItems, ...fileItems]
: [...fileItems, ...folderItems]
})
const changeSort = (column: Column) => {
if (column.key === sortField.value) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = column.key
sortDirection.value = column.defaultSort ?? 'asc'
}
}
</script>

View File

@@ -1,85 +0,0 @@
<template>
<div ref="container" class="scroll-container">
<div :style="{ height: `${state.start * itemSize}px` }"></div>
<div :style="contentStyle">
<div
v-for="item in renderedItems"
:key="item.key"
:style="{ height: `${itemSize}px` }"
data-virtual-item
>
<slot name="item" :item="item"></slot>
</div>
</div>
<div
:style="{ height: `${(items.length - state.end) * itemSize}px` }"
></div>
</div>
</template>
<script setup lang="ts" generic="T">
import { useElementSize, useScroll } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { type CSSProperties, computed, ref } from 'vue'
type Item = T & { key: string }
const props = defineProps<{
items: Item[]
itemSize: number
contentStyle?: Partial<CSSProperties>
scrollThrottle?: number
}>()
const { scrollThrottle = 64 } = props
const container = ref<HTMLElement | null>(null)
const { height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})
const viewRows = computed(() => Math.ceil(height.value / props.itemSize))
const offsetRows = computed(() => Math.floor(scrollY.value / props.itemSize))
const state = computed(() => {
const bufferRows = viewRows.value
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
return {
start: clamp(fromRow, 0, props.items.length),
end: clamp(toRow, fromRow, props.items.length)
}
})
const renderedItems = computed(() => {
return props.items.slice(state.value.start, state.value.end)
})
const reset = () => {}
defineExpose({
reset
})
</script>
<style scoped>
.scroll-container {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -57,7 +57,7 @@
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useMinimap } from '@/composables/useMinimap'
import MiniMapPanel from './MiniMapPanel.vue'
@@ -94,9 +94,7 @@ const toggleOptionsPanel = () => {
}
onMounted(() => {
if (minimapRef.value) {
setMinimapRef(minimapRef.value)
}
setMinimapRef(minimapRef.value)
})
onUnmounted(() => {

View File

@@ -80,7 +80,7 @@
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import type { MinimapSettingsKey } from '@/renderer/extensions/minimap/types'
import { MinimapOptionKey } from '@/composables/useMinimap'
defineProps<{
panelStyles: any
@@ -92,6 +92,6 @@ defineProps<{
}>()
defineEmits<{
updateOption: [key: MinimapSettingsKey, value: boolean]
updateOption: [key: MinimapOptionKey, value: boolean]
}>()
</script>

View File

@@ -24,7 +24,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LiteGraphCanvasEvent } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useGraphMutationService } from '@/services/graphMutationService'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -42,19 +42,23 @@ const inputStyle = computed<CSSProperties>(() => ({
const titleEditorStore = useTitleEditorStore()
const canvasStore = useCanvasStore()
const previousCanvasDraggable = ref(true)
const graphMutationService = useGraphMutationService()
const onEdit = (newValue: string) => {
const onEdit = async (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
const trimmedTitle = newValue.trim()
titleEditorStore.titleEditorTarget.title = trimmedTitle
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
if (target instanceof LGraphNode) {
await graphMutationService.updateNodeTitle(target.id, trimmedTitle)
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
if (target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
} else if (target instanceof LGraphGroup) {
await graphMutationService.updateGroupTitle(target.id, trimmedTitle)
}
}
showInput.value = false
titleEditorStore.titleEditorTarget = null

View File

@@ -1,253 +0,0 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.outputExplorer')">
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.back')"
icon="pi pi-arrow-up"
severity="secondary"
text
:disabled="!currentFolder"
@click="handleBackParentFolder"
/>
<Button
v-tooltip.bottom="$t('g.refresh')"
icon="pi pi-refresh"
severity="secondary"
text
@click="loadFolderItems"
/>
</template>
<template #header>
<SearchBox
v-model:modelValue="searchQuery"
class="model-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchIn', ['output'])"
@search="handleSearch"
/>
</template>
<template #body>
<div class="h-full overflow-hidden">
<ListExplorer
class="flex-1"
:style="{ height: 'calc(100% - 36px)' }"
:items="renderedItems"
@item-db-click="handleDbClickItem"
></ListExplorer>
<div class="h-8 flex items-center px-2 text-sm">
<div class="flex gap-1">
{{ $t('g.itemsCount', [itemsCount]) }}
</div>
</div>
</div>
</template>
</SidebarTabTemplate>
<Teleport to="body">
<div
v-show="previewVisible"
class="fixed left-0 top-0 z-[5000] flex h-full w-full items-center justify-center bg-black/70"
>
<div class="absolute right-3 top-3">
<Button
icon="pi pi-times"
severity="secondary"
rounded
@click="closePreview"
></Button>
</div>
<div class="h-full w-full select-none p-10">
<img
v-if="currentItem?.type === 'image'"
class="h-full w-full object-contain"
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
alt="preview"
/>
<video
v-if="currentItem?.type === 'video'"
class="h-full w-full object-contain"
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
controls
></video>
<div
v-if="currentItem?.type === 'audio'"
class="w-full h-full flex items-center justify-center"
>
<div
class="px-8 pt-6 rounded-full"
:style="{ background: 'var(--p-button-secondary-background)' }"
>
<div class="text-center mb-2">{{ currentItem?.name }}</div>
<audio
:src="`/api/output/${folderPrefix}${currentItem?.name}`"
controls
></audio>
</div>
</div>
</div>
<div class="absolute left-2 top-1/2">
<Button
icon="pi pi-angle-left"
severity="secondary"
rounded
@click="openPreviousItem"
></Button>
</div>
<div class="absolute right-2 top-1/2">
<Button
icon="pi pi-angle-right"
severity="secondary"
rounded
@click="openNextItem"
></Button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import ListExplorer from '@/components/common/ListExplorer.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { api } from '@/scripts/api'
interface OutputItem {
key: string
name: string
type: 'folder' | 'image' | 'video' | 'audio'
size: number
createTime: number
modifyTime: number
}
const searchQuery = ref<string>('')
const folderPaths = ref<OutputItem[]>([])
const currentFolder = computed(() => {
return folderPaths.value.map((item) => item.name).join('/')
})
const currentFolderItems = ref<OutputItem[]>([])
const folderPrefix = computed(() => {
return currentFolder.value ? `${currentFolder.value}/` : ''
})
const filterContent = ref('')
const itemsCount = computed(() => {
return currentFolderItems.value.length.toLocaleString()
})
const renderedItems = computed(() => {
const query = filterContent.value
let items = currentFolderItems.value
if (query) {
items = items.filter((item) => {
return item.name.toLowerCase().includes(query.toLowerCase())
})
}
// Convert OutputItem to Item format expected by ListExplorer
return items.map((item) => ({
key: item.key,
name: item.name,
type: item.type,
size: item.size,
modifyTime: item.modifyTime
}))
})
const handleSearch = async (query: string) => {
filterContent.value = query
}
const previewVisible = ref(false)
const currentItem = ref<OutputItem | null>(null)
const currentItemIndex = ref(-1)
const currentTypeItems = ref<OutputItem[]>([])
const closePreview = () => {
previewVisible.value = false
currentItem.value = null
}
const openPreviousItem = () => {
currentItemIndex.value--
if (currentItemIndex.value < 0) {
currentItemIndex.value = currentTypeItems.value.length - 1
}
const item = currentTypeItems.value[currentItemIndex.value]
currentItem.value = item
}
const openNextItem = () => {
currentItemIndex.value++
if (currentItemIndex.value > currentTypeItems.value.length - 1) {
currentItemIndex.value = 0
}
const item = currentTypeItems.value[currentItemIndex.value]
currentItem.value = item
}
const openItemPreview = (item: OutputItem) => {
previewVisible.value = true
currentItem.value = item
const itemType = item.type
currentTypeItems.value = currentFolderItems.value.filter(
(o) => o.type === itemType
)
currentItemIndex.value = currentTypeItems.value.indexOf(item)
}
const loadFolderItems = async () => {
const resData = await api.getOutputFolderItems(currentFolder.value)
currentFolderItems.value = resData.map((item: any) => ({
key: item.name,
...item
}))
}
const openFolder = async (item: OutputItem, pathIndex: number) => {
folderPaths.value.splice(pathIndex)
folderPaths.value.push(item)
await loadFolderItems()
}
const handleBackParentFolder = async () => {
folderPaths.value.pop()
await loadFolderItems()
}
const handleDbClickItem = (item: any, _event: MouseEvent) => {
// Find the original OutputItem from currentFolderItems
const originalItem = currentFolderItems.value.find(
(outputItem) => outputItem.key === item.key
)
if (!originalItem) return
if (originalItem.type === 'folder') {
void openFolder(originalItem, folderPaths.value.length)
} else {
openItemPreview(originalItem)
}
}
onMounted(async () => {
await loadFolderItems()
})
</script>
<style scoped>
:deep(.pi-fake-spacer) {
height: 1px;
width: 16px;
}
:deep(audio::-webkit-media-controls-enclosure) {
background-color: inherit;
}
</style>

View File

@@ -40,7 +40,7 @@ import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { useWorkflowService } from '@/services/workflowService'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow } from '@/stores/workflowStore'

View File

@@ -1,18 +0,0 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import OutputExplorerSidebarTab from '@/components/sidebar/tabs/OutputExplorerSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useOutputExplorerSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'output-explorer',
icon: 'pi pi-image',
title: t('sideToolbar.outputExplorer'),
tooltip: t('sideToolbar.outputExplorer'),
component: markRaw(OutputExplorerSidebarTab),
type: 'vue'
}
}

View File

@@ -0,0 +1,849 @@
import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
}
export type MinimapOptionKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const colorPaletteStore = useColorPaletteStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<any>(null)
const visible = ref(true)
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
await settingStore.set(key, value)
needsFullRedraw.value = true
updateMinimap()
}
const initialized = ref(false)
const bounds = ref({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
const scale = ref(1)
const isDragging = ref(false)
const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 })
const needsFullRedraw = ref(true)
const needsBoundsUpdate = ref(true)
const lastNodeCount = ref(0)
const nodeStatesCache = new Map<NodeId, string>()
const linksCache = ref<string>('')
const updateFlags = ref({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const width = 250
const height = 200
// Theme-aware colors for canvas drawing
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const nodeColorDefault = computed(
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
)
const linkColor = computed(
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const groupColor = computed(() =>
isLightTheme.value ? '#A2D3EC' : '#1F547A'
)
const groupColorDefault = computed(
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
)
const bypassColor = computed(() =>
isLightTheme.value ? '#DBDBDB' : '#4B184B'
)
const containerRect = ref({
left: 0,
top: 0,
width: width,
height: height
})
const canvasDimensions = ref({
width: 0,
height: 0
})
const updateContainerRect = () => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
containerRect.value = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
}
}
const updateCanvasDimensions = () => {
const c = canvas.value
if (!c) return
const canvasEl = c.canvas
const dpr = window.devicePixelRatio || 1
canvasDimensions.value = {
width: canvasEl.clientWidth || canvasEl.width / dpr,
height: canvasEl.clientHeight || canvasEl.height / dpr
}
}
const canvas = computed(() => canvasStore.canvas)
const graph = computed(() => {
// If we're in a subgraph, use that; otherwise use the canvas graph
const activeSubgraph = workflowStore.activeSubgraph
return activeSubgraph || canvas.value?.graph
})
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
height: `${viewportTransform.value.height}px`,
border: `2px solid ${isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
backgroundColor: `#FFF33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
pointerEvents: 'none' as const
}))
const calculateGraphBounds = () => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of g._nodes) {
minX = Math.min(minX, node.pos[0])
minY = Math.min(minY, node.pos[1])
maxX = Math.max(maxX, node.pos[0] + node.size[0])
maxY = Math.max(maxY, node.pos[1] + node.size[1])
}
let currentWidth = maxX - minX
let currentHeight = maxY - minY
// Enforce minimum viewport dimensions for better visualization
const minViewportWidth = 2500
const minViewportHeight = 2000
if (currentWidth < minViewportWidth) {
const padding = (minViewportWidth - currentWidth) / 2
minX -= padding
maxX += padding
currentWidth = minViewportWidth
}
if (currentHeight < minViewportHeight) {
const padding = (minViewportHeight - currentHeight) / 2
minY -= padding
maxY += padding
currentHeight = minViewportHeight
}
return {
minX,
minY,
maxX,
maxY,
width: currentWidth,
height: currentHeight
}
}
const calculateScale = () => {
if (bounds.value.width === 0 || bounds.value.height === 0) {
return 1
}
const scaleX = width / bounds.value.width
const scaleY = height / bounds.value.height
// Apply 0.9 factor to provide padding/gap between nodes and minimap borders
return Math.min(scaleX, scaleY) * 0.9
}
const renderGroups = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._groups || g._groups.length === 0) return
for (const group of g._groups) {
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = group.size[0] * scale.value
const h = group.size[1] * scale.value
let color = groupColor.value
if (nodeColors.value) {
color = group.color ?? groupColorDefault.value
if (isLightTheme.value) {
color = adjustColor(color, { opacity: 0.5 })
}
}
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) return
for (const node of g._nodes) {
const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
let color = nodeColor.value
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
color = bypassColor.value
} else if (nodeColors.value) {
color = nodeColorDefault.value
if (node.bgcolor) {
color = isLightTheme.value
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
// Render solid node blocks
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
if (renderError.value && node.has_errors) {
ctx.strokeStyle = '#FF0000'
ctx.lineWidth = 0.3
ctx.strokeRect(x, y, w, h)
}
}
}
const renderConnections = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 0.3
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
x2: number
y2: number
}> = []
for (const node of g._nodes) {
if (!node.outputs) continue
const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = g.links[linkId]
if (!link) continue
const targetNode = g.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX
const y2 =
(targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY
const outputX = x1 + node.size[0] * scale.value
const outputY = y1 + node.size[1] * scale.value * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * scale.value * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
}
// Render connection slots on top
ctx.fillStyle = slotColor.value
for (const conn of connections) {
// Output slot
ctx.beginPath()
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
ctx.fill()
// Input slot
ctx.beginPath()
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
ctx.fill()
}
}
const renderMinimap = () => {
const g = graph.value
if (!canvasRef.value || !g) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!g._nodes || g._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}
const needsRedraw =
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
if (needsRedraw) {
ctx.clearRect(0, 0, width, height)
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
if (showGroups.value) {
renderGroups(ctx, offsetX, offsetY)
}
if (showLinks.value) {
renderConnections(ctx, offsetX, offsetY)
}
renderNodes(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
updateFlags.value.connections = false
}
}
const updateViewport = () => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
const worldX = -ds.offset[0]
const worldY = -ds.offset[1]
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
updateFlags.value.viewport = false
}
const updateMinimap = () => {
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
bounds.value = calculateGraphBounds()
scale.value = calculateScale()
needsBoundsUpdate.value = false
updateFlags.value.bounds = false
needsFullRedraw.value = true
// When bounds change, we need to update the viewport position
updateFlags.value.viewport = true
}
if (
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
) {
renderMinimap()
}
// Update viewport if needed (e.g., after bounds change)
if (updateFlags.value.viewport) {
updateViewport()
}
}
const checkForChanges = useThrottleFn(() => {
const g = graph.value
if (!g) return
let structureChanged = false
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
if (nodeStatesCache.get(key) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
structureChanged = true
}
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
}
if (connectionChanged) {
updateFlags.value.connections = true
}
if (structureChanged || positionChanged || connectionChanged) {
updateMinimap()
}
}, 500)
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
useRafFn(
async () => {
if (visible.value) {
await checkForChanges()
}
},
{ immediate: false }
)
const { startSync: startViewportSync, stopSync: stopViewportSync } =
useCanvasTransformSync(updateViewport, { autoStart: false })
// Pointer event handlers for touch screen support
const handlePointerDown = (e: PointerEvent) => {
isDragging.value = true
updateContainerRect()
handlePointerMove(e)
}
const handlePointerMove = (e: PointerEvent) => {
if (!isDragging.value || !canvasRef.value || !canvas.value) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
centerViewOn(worldX, worldY)
}
const handlePointerUp = () => {
isDragging.value = false
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const c = canvas.value
if (!c) return
if (
containerRect.value.left === 0 &&
containerRect.value.top === 0 &&
containerRef.value
) {
updateContainerRect()
}
const ds = c.ds
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = ds.scale * delta
const MIN_SCALE = 0.1
const MAX_SCALE = 10
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
ds.scale = newScale
centerViewOn(worldX, worldY)
}
const centerViewOn = (worldX: number, worldY: number) => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
ds.offset[0] = -(worldX - viewportWidth / 2)
ds.offset[1] = -(worldY - viewportHeight / 2)
updateFlags.value.viewport = true
c.setDirty(true, true)
}
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}, 500)
const setupEventListeners = () => {
const g = graph.value
if (!g) return
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChanged()
}
g.onNodeRemoved = function (node) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChanged()
}
g.onConnectionChange = function (node) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChanged()
}
}
const cleanupEventListeners = () => {
const g = graph.value
if (!g) return
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
'Attempted to cleanup event listeners for graph that was never set up'
)
return
}
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
originalCallbacksMap.delete(g.id)
}
const init = async () => {
if (initialized.value) return
visible.value = settingStore.get('Comfy.Minimap.Visible')
if (canvas.value && graph.value) {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChanged)
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
window.addEventListener('resize', updateContainerRect)
window.addEventListener('scroll', updateContainerRect)
window.addEventListener('resize', updateCanvasDimensions)
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
updateMinimap()
updateViewport()
if (visible.value) {
resumeChangeDetection()
startViewportSync()
}
initialized.value = true
}
}
const destroy = () => {
pauseChangeDetection()
stopViewportSync()
cleanupEventListeners()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
nodeStatesCache.clear()
initialized.value = false
}
watch(
canvas,
async (newCanvas, oldCanvas) => {
if (oldCanvas) {
cleanupEventListeners()
pauseChangeDetection()
stopViewportSync()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
}
if (newCanvas && !initialized.value) {
await init()
}
},
{ immediate: true, flush: 'post' }
)
// Watch for graph changes (e.g., when navigating to/from subgraphs)
watch(graph, (newGraph, oldGraph) => {
if (newGraph && newGraph !== oldGraph) {
cleanupEventListeners()
setupEventListeners()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}
})
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
await nextTick()
await nextTick()
updateMinimap()
updateViewport()
resumeChangeDetection()
startViewportSync()
} else {
pauseChangeDetection()
stopViewportSync()
}
})
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)
}
const setMinimapRef = (ref: any) => {
minimapRef.value = ref
}
return {
visible: computed(() => visible.value),
initialized: computed(() => initialized.value),
containerRef,
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
renderMinimap,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef,
updateOption
}
}

View File

@@ -1,19 +1,38 @@
import { ref } from 'vue'
import { createGraphThumbnail } from '@/renderer/thumbnail/graphThumbnailRenderer'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { useMinimap } from './useMinimap'
// Store thumbnails for each workflow
const workflowThumbnails = ref<Map<string, string>>(new Map())
// Shared minimap instance
let minimap: ReturnType<typeof useMinimap> | null = null
export const useWorkflowThumbnail = () => {
/**
* Capture a thumbnail of the canvas
*/
const createMinimapPreview = (): Promise<string | null> => {
try {
const thumbnailDataUrl = createGraphThumbnail()
return Promise.resolve(thumbnailDataUrl)
if (!minimap) {
minimap = useMinimap()
minimap.canvasRef.value = document.createElement('canvas')
minimap.canvasRef.value.width = minimap.width
minimap.canvasRef.value.height = minimap.height
}
minimap.renderMinimap()
return new Promise((resolve) => {
minimap!.canvasRef.value!.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob))
} else {
resolve(null)
}
})
})
} catch (error) {
console.error('Failed to capture canvas thumbnail:', error)
return Promise.resolve(null)

View File

@@ -54,12 +54,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Workspace.ToggleSidebarTab.model-library'
},
{
combo: {
key: 'e'
},
commandId: 'Workspace.ToggleSidebarTab.output-explorer'
},
{
combo: {
key: 's',

View File

@@ -24,6 +24,8 @@ import {
} from './measure'
import type { ISerialisedGroup } from './types/serialisation'
export type GroupId = number
export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true
}

View File

@@ -31,6 +31,8 @@ import {
} from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
export type SubgraphId = string
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/

View File

@@ -277,10 +277,6 @@
"label": "تبديل الشريط الجانبي لمكتبة العقد",
"tooltip": "مكتبة العقد"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "تبديل الشريط الجانبي لمستكشف النتائج",
"tooltip": "مستكشف النتائج"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
"tooltip": "قائمة الانتظار"

File diff suppressed because it is too large Load Diff

View File

@@ -277,10 +277,6 @@
"label": "Toggle Node Library Sidebar",
"tooltip": "Node Library"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "Toggle Output Explorer Sidebar",
"tooltip": "Output Explorer"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Toggle Queue Sidebar",
"tooltip": "Queue"

View File

@@ -146,16 +146,7 @@
"micPermissionDenied": "Microphone permission denied",
"noAudioRecorded": "No audio recorded",
"nodesRunning": "nodes running",
"duplicate": "Duplicate",
"audio": "Audio",
"folder": "Folder",
"image": "Image",
"itemsCount": "{0} Items",
"modifyTime": "Modify Time",
"searchIn": "Search in {0}",
"size": "Size",
"type": "Type",
"video": "Video"
"duplicate": "Duplicate"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -506,8 +497,7 @@
"bookmarks": "Bookmarks",
"open": "Open"
}
},
"outputExplorer": "Output Explorer"
}
},
"helpCenter": {
"docs": "Docs",
@@ -1046,7 +1036,6 @@
"Focus Mode": "Focus Mode",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Output Explorer": "Output Explorer",
"Queue Panel": "Queue Panel",
"Workflows": "Workflows"
},

View File

@@ -277,10 +277,6 @@
"label": "Alternar Barra Lateral de Biblioteca de Nodos",
"tooltip": "Biblioteca de Nodos"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "Alternar barra lateral del Explorador de Salidas",
"tooltip": "Explorador de Salidas"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Alternar Barra Lateral de Cola",
"tooltip": "Cola"

View File

@@ -1,12 +1,12 @@
{
"apiNodesCostBreakdown": {
"costPerRun": "Costo por ejecución",
"title": "Nodo(s) API",
"title": "Nodo(s) de API",
"totalCost": "Costo total"
},
"apiNodesSignInDialog": {
"message": "Este flujo de trabajo contiene nodos API, que requieren que inicies sesión en tu cuenta para ejecutarse.",
"title": "Inicio de sesión requerido para usar nodos API"
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"auth": {
"apiKey": {
@@ -86,7 +86,7 @@
"clearWorkflow": "Limpiar flujo de trabajo",
"deleteWorkflow": "Eliminar flujo de trabajo",
"duplicate": "Duplicar",
"enterNewName": "Introduce un nuevo nombre"
"enterNewName": "Ingrese un nuevo nombre"
},
"chatHistory": {
"cancelEdit": "Cancelar",
@@ -97,7 +97,7 @@
},
"clipboard": {
"errorMessage": "Error al copiar al portapapeles",
"errorNotSupported": "La API del portapapeles no es compatible con tu navegador",
"errorNotSupported": "API del portapapeles no soportada en su navegador",
"successMessage": "Copiado al portapapeles"
},
"color": {
@@ -108,54 +108,54 @@
"cyan": "Cian",
"default": "Predeterminado",
"green": "Verde",
"noColor": "Sin color",
"pale_blue": "Azul pálido",
"noColor": "Sin Color",
"pale_blue": "Azul Pálido",
"pink": "Rosa",
"purple": "Púrpura",
"purple": "Morado",
"red": "Rojo",
"yellow": "Amarillo"
},
"contextMenu": {
"Add Group": "Agregar grupo",
"Add Group For Selected Nodes": "Agregar grupo para los nodos seleccionados",
"Add Node": "Agregar nodo",
"Add Group": "Agregar Grupo",
"Add Group For Selected Nodes": "Agregar Grupo para Nodos Seleccionados",
"Add Node": "Agregar Nodo",
"Bypass": "Omitir",
"Clone": "Clonar",
"Collapse": "Colapsar",
"Colors": "Colores",
"Convert to Group Node": "Convertir en nodo de grupo",
"Copy (Clipspace)": "Copiar (Clipspace)",
"Convert to Group Node": "Convertir en Nodo de Grupo",
"Copy (Clipspace)": "Copiar (Espacio de Clip)",
"Expand": "Expandir",
"Inputs": "Entradas",
"Manage": "Gestionar",
"Manage Group Nodes": "Gestionar nodos de grupo",
"Manage": "Administrar",
"Manage Group Nodes": "Administrar Nodos de Grupo",
"Mode": "Modo",
"Node Templates": "Plantillas de nodo",
"Node Templates": "Plantillas de Nodos",
"Outputs": "Salidas",
"Pin": "Fijar",
"Pin": "Anclar",
"Properties": "Propiedades",
"Properties Panel": "Panel de propiedades",
"Properties Panel": "Panel de Propiedades",
"Remove": "Eliminar",
"Resize": "Redimensionar",
"Save Selected as Template": "Guardar selección como plantilla",
"Save Selected as Template": "Guardar Seleccionado como Plantilla",
"Search": "Buscar",
"Shapes": "Formas",
"Title": "Título",
"Unpin": "Desfijar"
"Unpin": "Desanclar"
},
"credits": {
"accountInitialized": "Cuenta inicializada",
"activity": "Actividad",
"added": "Añadido",
"additionalInfo": "Información adicional",
"apiPricing": "Precios de API",
"apiPricing": "Precios de la API",
"credits": "Créditos",
"details": "Detalles",
"eventType": "Tipo de evento",
"faqs": "Preguntas frecuentes",
"invoiceHistory": "Historial de facturas",
"lastUpdated": "Última actualización",
"messageSupport": "Soporte por mensaje",
"messageSupport": "Contactar soporte",
"model": "Modelo",
"purchaseCredits": "Comprar créditos",
"time": "Hora",
@@ -214,7 +214,7 @@
"UPSCALE_MODEL": "MODELO_DE_ESCALADO",
"VAE": "VAE",
"VIDEO": "VÍDEO",
"VOXEL": "VÓXEL",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
"desktopMenu": {
@@ -228,7 +228,7 @@
"errorCheckingUpdate": "Error al buscar actualizaciones",
"errorInstallingUpdate": "Error al instalar la actualización",
"noUpdateFound": "No se encontró ninguna actualización",
"terminalDefaultMessage": "Cualquier salida de la consola de la actualización se mostrará aquí.",
"terminalDefaultMessage": "Cualquier salida de consola de la actualización se mostrará aquí.",
"title": "Actualizando ComfyUI Desktop",
"updateAvailableMessage": "Hay una actualización disponible. ¿Quieres reiniciar y actualizar ahora?",
"updateFoundTitle": "Actualización encontrada (v{version})"
@@ -252,8 +252,8 @@
"errorDialog": {
"defaultTitle": "Ocurrió un error",
"extensionFileHint": "Esto puede deberse al siguiente script",
"loadWorkflowTitle": "Carga abortada debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay traza de pila disponible",
"loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo",
"noStackTrace": "No hay seguimiento de pila disponible",
"promptExecutionError": "La ejecución del prompt falló"
},
"g": {
@@ -264,14 +264,13 @@
"amount": "Cantidad",
"apply": "Aplicar",
"architecture": "Arquitectura",
"audio": "Audio",
"audioFailedToLoad": "No se pudo cargar el audio",
"author": "Autor",
"back": "Atrás",
"cancel": "Cancelar",
"capture": "capturar",
"capture": "captura",
"category": "Categoría",
"choose_file_to_upload": "elige un archivo para subir",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
@@ -283,8 +282,8 @@
"confirm": "Confirmar",
"confirmed": "Confirmado",
"continue": "Continuar",
"control_after_generate": "controlar después de generar",
"control_before_generate": "controlar antes de generar",
"control_after_generate": "control después de generar",
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
@@ -314,13 +313,11 @@
"filter": "Filtrar",
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"folder": "Carpeta",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere {requiredVersion} o superior.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
"image": "Imagen",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
"import": "Importar",
@@ -330,20 +327,18 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemsCount": "{0} elementos",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"learnMore": "Saber más",
"learnMore": "Aprende más",
"loadAllFolders": "Cargar todas las carpetas",
"loadWorkflow": "Cargar flujo de trabajo",
"loading": "Cargando",
"loadingPanel": "Cargando panel de {panel}...",
"loadingPanel": "Cargando panel {panel}...",
"login": "Iniciar sesión",
"logs": "Registros",
"micPermissionDenied": "Permiso de micrófono denegado",
"migrate": "Migrar",
"missing": "Faltante",
"modifyTime": "Hora de modificación",
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",
@@ -371,14 +366,13 @@
"reportSent": "Informe enviado",
"reset": "Reiniciar",
"resetAll": "Restablecer todo",
"resetAllKeybindingsTooltip": "Restablecer todas las combinaciones de teclas a los valores predeterminados",
"resetAllKeybindingsTooltip": "Restablecer todas las teclas de acceso rápido a la configuración predeterminada",
"restart": "Reiniciar",
"resultsCount": "{count} resultados encontrados",
"resultsCount": "Encontrados {count} resultados",
"save": "Guardar",
"saving": "Guardando",
"searchExtensions": "Buscar extensiones",
"searchFailedMessage": "No pudimos encontrar ninguna configuración que coincida con tu búsqueda. Intenta ajustar tus términos de búsqueda.",
"searchIn": "Buscar en {0}",
"searchKeybindings": "Buscar combinaciones de teclas",
"searchModels": "Buscar modelos",
"searchNodes": "Buscar nodos",
@@ -387,10 +381,9 @@
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showReport": "Mostrar informe",
"size": "Tamaño",
"sort": "Ordenar",
"source": "Fuente",
"startRecording": "Comenzar grabación",
"startRecording": "Iniciar grabación",
"status": "Estado",
"stopRecording": "Detener grabación",
"success": "Éxito",
@@ -398,10 +391,9 @@
"terminal": "Terminal",
"title": "Título",
"triggerPhrase": "Frase de activación",
"type": "Tipo",
"unknownError": "Error desconocido",
"update": "Actualizar",
"updateAvailable": "Actualización disponible",
"updateAvailable": "Actualización Disponible",
"updateFrontend": "Actualizar frontend",
"updated": "Actualizado",
"updating": "Actualizando",
@@ -409,8 +401,7 @@
"usageHint": "Sugerencia de uso",
"user": "Usuario",
"versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para instrucciones de actualización.",
"video": "Video",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video",
"workflow": "Flujo de trabajo"
},
@@ -485,7 +476,7 @@
"installLocationDescription": "Selecciona el directorio para los datos de usuario de ComfyUI. Un entorno de python será instalado en la ubicación seleccionada.",
"installLocationTooltip": "Directorio de datos de usuario de ComfyUI. Almacena:\n- Entorno Python\n- Modelos\n- Nodos personalizados\n",
"insufficientFreeSpace": "Espacio insuficiente - espacio libre mínimo",
"isOneDrive": "OneDrive no es compatible. Por favor, instala ComfyUI en otra ubicación.",
"isOneDrive": "OneDrive no es compatible. Por favor instala ComfyUI en otra ubicación.",
"manualConfiguration": {
"createVenv": "Necesitarás crear un entorno virtual en el siguiente directorio",
"requirements": "Requisitos",
@@ -500,7 +491,7 @@
"migrationOptional": "La migración es opcional. Si no tienes una instalación existente, puedes saltarte este paso.",
"migrationSourcePathDescription": "Si tienes una instalación existente de ComfyUI, podemos copiar/enlazar tus archivos de usuario existentes y modelos a la nueva instalación. Tu instalación existente de ComfyUI no será afectada.",
"moreInfo": "Para más información, por favor lee nuestra",
"nonDefaultDrive": "Por favor, instala ComfyUI en el disco del sistema (por ejemplo, C:\\). Unidades con diferentes sistemas de archivos pueden causar problemas impredecibles. Los modelos y otros archivos pueden almacenarse en otras unidades después de la instalación.",
"nonDefaultDrive": "Por favor instala ComfyUI en tu unidad de sistema (ej. C:\\). Las unidades con diferentes sistemas de archivos pueden causar problemas impredecibles. Los modelos y otros archivos pueden ser almacenados en otras unidades después de la instalación.",
"parentMissing": "La ruta no existe - crea el directorio contenedor primero",
"pathExists": "El directorio ya existe - por favor asegúrate de haber respaldado todos los datos",
"pathValidationFailed": "Falló la validación de la ruta",
@@ -511,7 +502,7 @@
"allowMetricsDescription": "Ayuda a mejorar ComfyUI enviando métricas de uso anónimas. No se recogerá ninguna información personal o contenido de flujo de trabajo.",
"autoUpdate": "Actualizaciones Automáticas",
"autoUpdateDescription": "Descarga automáticamente las actualizaciones cuando estén disponibles. Se te notificará antes de que las actualizaciones sean instaladas.",
"checkingMirrors": "Comprobando el acceso a la red de los mirrors de Python...",
"checkingMirrors": "Comprobando el acceso a la red a los espejos de python...",
"dataCollectionDialog": {
"collect": {
"errorReports": "Mensaje de error y rastreo de pila",
@@ -532,11 +523,11 @@
"errorUpdatingConsent": "Error Actualizando Consentimiento",
"errorUpdatingConsentDetail": "Falló al actualizar la configuración de consentimiento de métricas",
"learnMoreAboutData": "Aprende más sobre la recolección de datos",
"mirrorSettings": "Configuración de mirrors",
"mirrorsReachable": "El acceso a la red de los mirrors de Python es bueno",
"mirrorsUnreachable": "El acceso a la red de algunos mirrors de Python es deficiente",
"pypiMirrorPlaceholder": "Introduce la URL del mirror de PyPI",
"pythonMirrorPlaceholder": "Introduce la URL del mirror de Python"
"mirrorSettings": "Configuraciones de Espejo",
"mirrorsReachable": "El acceso a la red a los espejos de python es bueno",
"mirrorsUnreachable": "El acceso a la red a algunos espejos de python es malo",
"pypiMirrorPlaceholder": "Ingresa la URL del espejo de PyPI",
"pythonMirrorPlaceholder": "Ingresa la URL del espejo de Python"
},
"systemLocations": "Ubicaciones del Sistema",
"unhandledError": "Error desconocido",
@@ -546,7 +537,7 @@
"contactFollowUp": "Contáctame para seguimiento",
"contactSupportDescription": "Por favor, complete el siguiente formulario con su reporte",
"contactSupportTitle": "Contactar Soporte",
"describeTheProblem": "Describe el problema",
"describeTheProblem": "Describa el problema",
"email": "Correo electrónico",
"feedbackTitle": "Ayúdanos a mejorar ComfyUI proporcionando comentarios",
"helpFix": "Ayuda a Solucionar Esto",
@@ -555,25 +546,25 @@
"bugReport": "Reporte de error",
"giveFeedback": "Enviar comentarios",
"loginAccessIssues": "Problemas de inicio de sesión / acceso",
"somethingElse": "Otra cosa"
"somethingElse": "Otro"
},
"notifyResolve": "Notifícame cuando se resuelva",
"provideAdditionalDetails": "Proporciona detalles adicionales (opcional)",
"provideEmail": "Danos tu correo electrónico (opcional)",
"rating": "Calificación",
"selectIssue": "Selecciona el problema",
"selectIssue": "Seleccione el problema",
"stackTrace": "Rastreo de Pila",
"submitErrorReport": "Enviar Reporte de Error (Opcional)",
"systemStats": "Estadísticas del Sistema",
"validation": {
"descriptionRequired": "La descripción es obligatoria",
"helpTypeRequired": "El tipo de ayuda es obligatorio",
"descriptionRequired": "Se requiere una descripción",
"helpTypeRequired": "Se requiere el tipo de ayuda",
"invalidEmail": "Por favor ingresa una dirección de correo electrónico válida",
"maxLength": "Mensaje demasiado largo",
"selectIssueType": "Por favor, selecciona un tipo de problema"
"selectIssueType": "Por favor, seleccione un tipo de problema"
},
"whatCanWeInclude": "Especifica qué incluir en el reporte",
"whatDoYouNeedHelpWith": "¿Con qué necesitas ayuda?"
"whatCanWeInclude": "Especifique qué incluir en el reporte",
"whatDoYouNeedHelpWith": "¿Con qué necesita ayuda?"
},
"load3d": {
"applyingTexture": "Aplicando textura...",
@@ -591,7 +582,7 @@
"exportingModel": "Exportando modelo...",
"fov": "FOV",
"light": "Luz",
"lightIntensity": "Intensidad de la luz",
"lightIntensity": "Intensidad de luz",
"loadingBackgroundImage": "Cargando imagen de fondo",
"loadingModel": "Cargando modelo 3D...",
"materialMode": "Modo de material",
@@ -600,10 +591,10 @@
"lineart": "Dibujo lineal",
"normal": "Normal",
"original": "Original",
"wireframe": "Alámbrico"
"wireframe": "Malla"
},
"model": "Modelo",
"openIn3DViewer": "Abrir en visor 3D",
"openIn3DViewer": "Abrir en el visor 3D",
"previewOutput": "Vista previa de salida",
"removeBackgroundImage": "Eliminar imagen de fondo",
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
@@ -613,7 +604,7 @@
"stopRecording": "Detener grabación",
"switchCamera": "Cambiar cámara",
"switchingMaterialMode": "Cambiando modo de material...",
"upDirection": "Dirección superior",
"upDirection": "Dirección hacia arriba",
"upDirections": {
"original": "Original"
},
@@ -642,13 +633,13 @@
"Skipped": "Omitido",
"allOk": "No se detectaron problemas.",
"confirmTitle": "¿Estás seguro?",
"consoleLogs": "Registros de consola",
"consoleLogs": "Registros de la consola",
"detected": "Detectado",
"error": {
"cannotContinue": "No se puede continuar: quedan errores",
"defaultDescription": "Ocurrió un error al ejecutar una tarea de mantenimiento.",
"taskFailed": "La tarea no se pudo ejecutar.",
"toastTitle": "Error en la tarea"
"cannotContinue": "No se puede continuar - quedan errores",
"defaultDescription": "Ocurrió un error mientras se ejecutaba una tarea de mantenimiento.",
"taskFailed": "La tarea falló al ejecutarse.",
"toastTitle": "Error de tarea"
},
"refreshing": "Actualizando",
"showManual": "Mostrar tareas de mantenimiento",
@@ -657,43 +648,43 @@
"title": "Mantenimiento"
},
"manager": {
"changingVersion": "Cambiando la versión de {from} a {to}",
"createdBy": "Creado por",
"changingVersion": "Cambiando versión de {from} a {to}",
"createdBy": "Creado Por",
"dependencies": "Dependencias",
"discoverCommunityContent": "Descubre paquetes de nodos, extensiones y más creados por la comunidad...",
"downloads": "Descargas",
"errorConnecting": "Error al conectar con el Registro de Nodos de Comfy.",
"failed": "Fallido ({count})",
"errorConnecting": "Error al conectar con el Registro de Nodos Comfy.",
"failed": "Falló ({count})",
"filter": {
"disabled": "Deshabilitado",
"enabled": "Habilitado",
"nodePack": "Paquete de Nodos"
},
"inWorkflow": "En el flujo de trabajo",
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar seleccionados",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última actualización",
"lastUpdated": "Última Actualización",
"latestVersion": "Última",
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"nightlyVersion": "Nocturna",
"noDescription": "No hay descripción disponible",
"noNodesFound": "No se encontraron nodos",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene nodos.",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
"nodePack": "Paquete de Nodos",
"packsSelected": "Paquetes seleccionados",
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
"restartToApplyChanges": "Para aplicar los cambios, reinicia ComfyUI",
"restartToApplyChanges": "Para aplicar los cambios, por favor reinicia ComfyUI",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar versión",
"selectVersion": "Seleccionar Versión",
"sort": {
"created": "Más nuevos",
"downloads": "Más populares",
"created": "Más reciente",
"downloads": "Más Popular",
"publisher": "Editor",
"updated": "Actualizados recientemente"
"updated": "Actualizado recientemente"
},
"status": {
"active": "Activo",
@@ -704,11 +695,11 @@
"unknown": "Desconocido"
},
"title": "Administrador de Nodos Personalizados",
"totalNodes": "Nodos totales",
"tryAgainLater": "Por favor, inténtalo de nuevo más tarde.",
"tryDifferentSearch": "Por favor, prueba con otra consulta de búsqueda.",
"totalNodes": "Total de Nodos",
"tryAgainLater": "Por favor intenta de nuevo más tarde.",
"tryDifferentSearch": "Por favor intenta con una consulta de búsqueda diferente.",
"uninstall": "Desinstalar",
"uninstallSelected": "Desinstalar seleccionados",
"uninstallSelected": "Desinstalar Seleccionado",
"uninstalling": "Desinstalando",
"update": "Actualizar",
"updatingAllPacks": "Actualizando todos los paquetes",
@@ -716,9 +707,9 @@
},
"maskEditor": {
"Apply to Whole Image": "Aplicar a toda la imagen",
"Brush Settings": "Configuración del pincel",
"Brush Shape": "Forma del pincel",
"Clear": "Limpiar",
"Brush Settings": "Configuración de pincel",
"Brush Shape": "Forma de pincel",
"Clear": "Borrar",
"Color Select Settings": "Configuración de selección de color",
"Fill Opacity": "Opacidad de relleno",
"Hardness": "Dureza",
@@ -731,11 +722,11 @@
"Mask Tolerance": "Tolerancia de máscara",
"Method": "Método",
"Opacity": "Opacidad",
"Paint Bucket Settings": "Configuración del bote de pintura",
"Paint Bucket Settings": "Configuración de cubo de pintura",
"Reset to Default": "Restablecer a predeterminado",
"Selection Opacity": "Opacidad de selección",
"Smoothing Precision": "Precisión de suavizado",
"Stop at mask": "Detener en la máscara",
"Stop at mask": "Detener en máscara",
"Thickness": "Grosor",
"Tolerance": "Tolerancia"
},
@@ -762,8 +753,8 @@
"refresh": "Actualizar definiciones de nodos",
"resetView": "Restablecer vista del lienzo",
"run": "Ejecutar",
"runWorkflow": "Ejecutar flujo de trabajo (Shift para poner al frente de la cola)",
"runWorkflowFront": "Ejecutar flujo de trabajo (Poner al frente de la cola)",
"runWorkflow": "Ejecutar flujo de trabajo (Shift para encolar al frente)",
"runWorkflowFront": "Ejecutar flujo de trabajo (Encolar al frente)",
"settings": "Configuración",
"showMenu": "Mostrar menú",
"theme": "Tema",
@@ -832,7 +823,6 @@
"Open Outputs Folder": "Abrir carpeta de salidas",
"Open Sign In Dialog": "Abrir diálogo de inicio de sesión",
"Open extra_model_paths_yaml": "Abrir extra_model_paths.yaml",
"Output Explorer": "Explorador de salidas",
"Pin/Unpin Selected Items": "Anclar/Desanclar elementos seleccionados",
"Pin/Unpin Selected Nodes": "Anclar/Desanclar nodos seleccionados",
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
@@ -912,7 +902,7 @@
"controlnet": "controlnet",
"create": "crear",
"custom_sampling": "muestreo_personalizado",
"debug": "depuración",
"debug": "depurar",
"deprecated": "obsoleto",
"flux": "flux",
"gligen": "gligen",
@@ -1198,7 +1188,7 @@
"essentials": "Esenciales",
"keyboardShortcuts": "Atajos de teclado",
"manageShortcuts": "Gestionar atajos",
"noKeybinding": "Sin combinación de teclas",
"noKeybinding": "Sin asignación de tecla",
"subcategories": {
"node": "Nodo",
"panelControls": "Controles del panel",
@@ -1231,9 +1221,9 @@
"module": "Módulo",
"moduleDesc": "Agrupar por fuente del módulo",
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Personalizado, API)"
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"resetView": "Restablecer vista por defecto",
"resetView": "Restablecer vista a la predeterminada",
"sortBy": {
"alphabetical": "Alfabético",
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
@@ -1243,7 +1233,6 @@
"sortMode": "Modo de ordenación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"outputExplorer": "Explorador de salidas",
"queue": "Cola",
"queueTab": {
"backToAllTasks": "Volver a todas las tareas",
@@ -1267,7 +1256,7 @@
"deleteFailedTitle": "Eliminación fallida",
"deleted": "Flujo de trabajo eliminado",
"dirtyClose": "Los archivos a continuación han sido modificados. ¿Te gustaría guardarlos antes de cerrar?",
"dirtyCloseHint": "Mantén presionada la tecla Shift para cerrar sin aviso",
"dirtyCloseHint": "Mantén presionada la tecla Shift para cerrar sin preguntar",
"dirtyCloseTitle": "¿Guardar cambios?",
"workflowTreeType": {
"bookmarks": "Marcadores",
@@ -1289,7 +1278,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Todas las Plantillas",
"All": "Todas las plantillas",
"Area Composition": "Composición de Área",
"Audio": "Audio",
"Basics": "Básicos",
@@ -1300,7 +1289,7 @@
"Image": "Imagen",
"Image API": "API de Imagen",
"LLM API": "API LLM",
"Upscaling": "Escalado",
"Upscaling": "Ampliación",
"Video": "Video",
"Video API": "API de Video"
},
@@ -1311,7 +1300,7 @@
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MV",
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turbo",
"stable_zero123_example": "Stable Zero123"
"stable_zero123_example": "Estable Zero123"
},
"3D API": {
"api_rodin_image_to_model": "Rodin: Imagen a Modelo",
@@ -1325,15 +1314,15 @@
"area_composition_square_area_for_subject": "Composición de Área Cuadrada para el Sujeto"
},
"Audio": {
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Edición",
"audio_ace_step_1_m2m_editing": "ACE Step v1 Edición M2M",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Texto a Música Instrumental",
"audio_ace_step_1_t2a_song": "ACE Step v1 Texto a Canción",
"audio_stable_audio_example": "Stable Audio"
},
"Basics": {
"default": "Generación de Imágenes",
"embedding_example": "Embedding",
"gligen_textbox_example": "Gligen Textbox",
"default": "Generación de Imagen",
"embedding_example": "Incrustación",
"gligen_textbox_example": "Caja de Texto Gligen",
"image2image": "Imagen a Imagen",
"inpaint_example": "Inpaint",
"inpaint_model_outpainting": "Outpaint",
@@ -1341,42 +1330,42 @@
"lora_multiple": "LoRA Múltiple"
},
"ControlNet": {
"2_pass_pose_worship": "Pose ControlNet 2 Pasos",
"controlnet_example": "Scribble ControlNet",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
"mixing_controlnets": "Mixing ControlNets"
"2_pass_pose_worship": "ControlNet de Pose 2 Pasadas",
"controlnet_example": "ControlNet de Garabato",
"depth_controlnet": "ControlNet de Profundidad",
"depth_t2i_adapter": "Adaptador de Profundidad T2I",
"mixing_controlnets": "Mezcla de ControlNets"
},
"Flux": {
"flux_canny_model_example": "Flux Canny Model",
"flux_depth_lora_example": "Flux Depth LoRA",
"flux_dev_checkpoint_example": "Flux Dev fp8",
"flux_dev_full_text_to_image": "Flux Dev full text to image",
"flux_dev_full_text_to_image": "Flux Dev texto a imagen completo",
"flux_fill_inpaint_example": "Flux Inpaint",
"flux_fill_outpaint_example": "Flux Outpaint",
"flux_kontext_dev_basic": "Flux Kontext Dev (Básico)",
"flux_kontext_dev_grouped": "Flux Kontext Dev (Agrupado)",
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell fp8",
"flux_schnell_full_text_to_image": "Flux Schnell full text to image"
"flux_schnell_full_text_to_image": "Flux Schnell texto a imagen completo"
},
"Image": {
"hidream_e1_full": "HiDream E1 Full",
"hidream_e1_full": "HiDream E1 Completo",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"hidream_i1_fast": "HiDream I1 Rápido",
"hidream_i1_full": "HiDream I1 Completo",
"image_chroma_text_to_image": "Chroma texto a imagen",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
"image_lotus_depth_v1_1": "Lotus Depth",
"image_omnigen2_image_edit": "OmniGen2 Edición de Imagen",
"image_omnigen2_t2i": "OmniGen2 Texto a Imagen",
"sd3_5_large_blur": "SD3.5 Large Blur",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Large Depth",
"sd3_5_large_blur": "SD3.5 Grande Desenfoque",
"sd3_5_large_canny_controlnet_example": "SD3.5 Grande Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Grande Profundidad",
"sd3_5_simple_example": "SD3.5 Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxl_refiner_prompt_example": "SDXL Refinador de Solicitud",
"sdxl_revision_text_prompts": "SDXL Revisión de Solicitud de Texto",
"sdxl_revision_zero_positive": "SDXL Revisión Cero Positivo",
"sdxl_simple_example": "SDXL Simple",
"sdxlturbo_example": "SDXL Turbo"
},
@@ -1388,16 +1377,16 @@
"api_ideogram_v3_t2i": "Ideogram V3: Texto a Imagen",
"api_luma_photon_i2i": "Luma Photon: Imagen a Imagen",
"api_luma_photon_style_ref": "Luma Photon: Referencia de Estilo",
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Inpaint",
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Rellenar",
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 Texto a Imagen",
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 Texto a Imagen",
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 Imagen a Imagen",
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Inpaint",
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Rellenar",
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 Múltiples Entradas",
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 Texto a Imagen",
"api_recraft_image_gen_with_color_control": "Recraft: Generación de Imagen con Control de Color",
"api_recraft_image_gen_with_style_control": "Recraft: Generación de Imagen con Control de Estilo",
"api_recraft_vector_gen": "Recraft: Generación Vectorial",
"api_recraft_vector_gen": "Recraft: Generación de Vectores",
"api_runway_reference_to_image": "Runway: Referencia a Imagen",
"api_runway_text_to_image": "Runway: Texto a Imagen",
"api_stability_ai_i2i": "Stability AI: Imagen a Imagen",
@@ -1411,9 +1400,9 @@
},
"Upscaling": {
"esrgan_example": "ESRGAN",
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
"hiresfix_latent_workflow": "Escalado",
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
"hiresfix_esrgan_workflow": "Flujo de Trabajo HiresFix ESRGAN",
"hiresfix_latent_workflow": "Ampliación",
"latent_upscale_different_prompt_model": "Ampliación Latente Modelo de Solicitud Diferente"
},
"Video": {
"hunyuan_video_text_to_video": "Hunyuan Video Texto a Video",
@@ -1430,7 +1419,7 @@
"video_wan_vace_14B_ref2v": "Wan VACE Referencia a Video",
"video_wan_vace_14B_t2v": "Wan VACE Texto a Video",
"video_wan_vace_14B_v2v": "Wan VACE Control Video",
"video_wan_vace_flf2v": "Wan VACE First-Last Frame",
"video_wan_vace_flf2v": "Wan VACE Primer-Ultimo Fotograma",
"video_wan_vace_inpainting": "Wan VACE Inpainting",
"video_wan_vace_outpainting": "Wan VACE Outpainting",
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
@@ -1448,11 +1437,11 @@
"api_moonvalley_image_to_video": "Moonvalley: Imagen a Video",
"api_moonvalley_text_to_video": "Moonvalley: Texto a Video",
"api_pika_i2v": "Pika: Imagen a Video",
"api_pika_scene": "Pika Scenes: Imágenes a Video",
"api_pika_scene": "Pika Escenas: Imágenes a Video",
"api_pixverse_i2v": "PixVerse: Imagen a Video",
"api_pixverse_t2v": "PixVerse: Texto a Video",
"api_pixverse_template_i2v": "PixVerse Plantillas: Imagen a Video",
"api_runway_first_last_frame": "Runway: Primer Último Cuadro a Video",
"api_runway_first_last_frame": "Runway: Primer Último Fotograma a Video",
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo Imagen a Video",
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo Imagen a Video",
"api_veo2_i2v": "Veo2: Imagen a Video"
@@ -1467,34 +1456,34 @@
},
"3D API": {
"api_rodin_image_to_model": "Genera modelos 3D detallados a partir de una sola foto usando Rodin AI.",
"api_rodin_multiview_to_model": "Esculpe modelos 3D completos usando la reconstrucción multiángulo de Rodin.",
"api_rodin_multiview_to_model": "Esculpe modelos 3D completos usando reconstrucción multivista de Rodin.",
"api_tripo_image_to_model": "Genera activos 3D profesionales a partir de imágenes 2D usando el motor Tripo.",
"api_tripo_multiview_to_model": "Construye modelos 3D desde múltiples ángulos con el escáner avanzado de Tripo.",
"api_tripo_text_to_model": "Crea objetos 3D a partir de descripciones con el modelado basado en texto de Tripo."
"api_tripo_multiview_to_model": "Construye modelos 3D a partir de múltiples ángulos con el escáner avanzado de Tripo.",
"api_tripo_text_to_model": "Crea objetos 3D a partir de descripciones con modelado basado en texto de Tripo."
},
"Area Composition": {
"area_composition": "Genera imágenes controlando la composición con áreas definidas.",
"area_composition_square_area_for_subject": "Genera imágenes con colocación consistente del sujeto usando composición de área."
"area_composition_square_area_for_subject": "Genera imágenes con colocación consistente del sujeto usando composición de áreas."
},
"Audio": {
"audio_ace_step_1_m2m_editing": "Edita canciones existentes para cambiar estilo y letra usando ACE-Step v1 M2M.",
"audio_ace_step_1_m2m_editing": "Edita canciones existentes para cambiar el estilo y la letra usando ACE-Step v1 M2M.",
"audio_ace_step_1_t2a_instrumentals": "Genera música instrumental a partir de texto usando ACE-Step v1.",
"audio_ace_step_1_t2a_song": "Genera canciones con voz a partir de texto usando ACE-Step v1, soportando multilingüe y personalización de estilo.",
"audio_stable_audio_example": "Genera audio a partir de texto usando Stable Audio."
"audio_ace_step_1_t2a_song": "Genera canciones con voz a partir de texto usando ACE-Step v1, soportando múltiples idiomas y personalización de estilo.",
"audio_stable_audio_example": "Genera audio a partir de descripciones de texto usando Stable Audio."
},
"Basics": {
"default": "Genera imágenes a partir de indicaciones de texto.",
"default": "Genera imágenes a partir de descripciones de texto.",
"embedding_example": "Genera imágenes usando inversión textual para estilos consistentes.",
"gligen_textbox_example": "Genera imágenes con colocación precisa de objetos usando cuadros de texto.",
"gligen_textbox_example": "Genera imágenes con colocación precisa de objetos usando cajas de texto.",
"image2image": "Transforma imágenes existentes usando indicaciones de texto.",
"inpaint_example": "Edita partes específicas de imágenes de forma fluida.",
"inpaint_example": "Edita partes específicas de imágenes de manera fluida.",
"inpaint_model_outpainting": "Extiende imágenes más allá de sus límites originales.",
"lora": "Genera imágenes con modelos LoRA para estilos o temas especializados.",
"lora_multiple": "Genera imágenes combinando múltiples modelos LoRA."
},
"ControlNet": {
"2_pass_pose_worship": "Genera imágenes guiadas por referencias de pose usando ControlNet.",
"controlnet_example": "Genera imágenes guiadas por imágenes de referencia de garabatos usando ControlNet.",
"controlnet_example": "Genera imágenes guiadas por imágenes de garabato usando ControlNet.",
"depth_controlnet": "Genera imágenes guiadas por información de profundidad usando ControlNet.",
"depth_t2i_adapter": "Genera imágenes guiadas por información de profundidad usando el adaptador T2I.",
"mixing_controlnets": "Genera imágenes combinando múltiples modelos ControlNet."
@@ -1502,31 +1491,31 @@
"Flux": {
"flux_canny_model_example": "Genera imágenes guiadas por detección de bordes usando Flux Canny.",
"flux_depth_lora_example": "Genera imágenes guiadas por información de profundidad usando Flux LoRA.",
"flux_dev_checkpoint_example": "Genera imágenes usando Flux Dev versión fp8 cuantizada. Adecuado para dispositivos con VRAM limitada, solo requiere un archivo de modelo, pero la calidad de imagen es ligeramente inferior a la versión completa.",
"flux_dev_full_text_to_image": "Genera imágenes de alta calidad con Flux Dev versión completa. Requiere mayor VRAM y múltiples archivos de modelo, pero ofrece la mejor capacidad de seguimiento de indicaciones y calidad de imagen.",
"flux_fill_inpaint_example": "Rellena partes faltantes de imágenes usando Flux inpainting.",
"flux_fill_outpaint_example": "Extiende imágenes más allá de los límites usando Flux outpainting.",
"flux_kontext_dev_basic": "Edita imágenes usando Flux Kontext con visibilidad total de nodos, perfecto para aprender el flujo de trabajo.",
"flux_dev_checkpoint_example": "Genera imágenes usando la versión cuantizada fp8 de Flux Dev. Ideal para dispositivos con poca VRAM, solo requiere un archivo de modelo, pero la calidad es ligeramente inferior a la versión completa.",
"flux_dev_full_text_to_image": "Genera imágenes de alta calidad con la versión completa de Flux Dev. Requiere más VRAM y múltiples archivos de modelo, pero ofrece la mejor adherencia a la indicación y calidad de imagen.",
"flux_fill_inpaint_example": "Rellena partes faltantes de imágenes usando inpainting de Flux.",
"flux_fill_outpaint_example": "Extiende imágenes más allá de los límites usando outpainting de Flux.",
"flux_kontext_dev_basic": "Edita imágenes usando Flux Kontext con visibilidad total de nodos, ideal para aprender el flujo de trabajo.",
"flux_kontext_dev_grouped": "Versión simplificada de Flux Kontext con nodos agrupados para un espacio de trabajo más limpio.",
"flux_redux_model_example": "Genera imágenes transfiriendo estilo de imágenes de referencia usando Flux Redux.",
"flux_schnell": "Genera imágenes rápidamente con Flux Schnell versión fp8 cuantizada. Ideal para hardware de gama baja, solo requiere 4 pasos para generar imágenes.",
"flux_schnell_full_text_to_image": "Genera imágenes rápidamente con Flux Schnell versión completa. Usa licencia Apache2.0, solo requiere 4 pasos para generar imágenes manteniendo buena calidad."
"flux_redux_model_example": "Genera imágenes transfiriendo el estilo de imágenes de referencia usando Flux Redux.",
"flux_schnell": "Genera imágenes rápidamente con la versión cuantizada fp8 de Flux Schnell. Perfecto para hardware de gama baja, solo requiere 4 pasos.",
"flux_schnell_full_text_to_image": "Genera imágenes rápidamente con la versión completa de Flux Schnell. Licencia Apache2.0, solo requiere 4 pasos manteniendo buena calidad."
},
"Image": {
"hidream_e1_full": "Edita imágenes con HiDream E1 - Modelo profesional de edición de imágenes por lenguaje natural.",
"hidream_i1_dev": "Genera imágenes con HiDream I1 Dev - Versión equilibrada con 28 pasos de inferencia, adecuada para hardware de gama media.",
"hidream_i1_fast": "Genera imágenes rápidamente con HiDream I1 Fast - Versión ligera con 16 pasos de inferencia, ideal para previsualizaciones rápidas en hardware de gama baja.",
"hidream_i1_full": "Genera imágenes con HiDream I1 Full - Versión completa con 50 pasos de inferencia para la máxima calidad.",
"image_chroma_text_to_image": "Chroma está modificado de flux y tiene algunos cambios en la arquitectura.",
"image_cosmos_predict2_2B_t2i": "Genera imágenes con Cosmos-Predict2 2B T2I, logrando generación físicamente precisa, de alta fidelidad y con gran nivel de detalle.",
"image_lotus_depth_v1_1": "Ejecuta Lotus Depth en ComfyUI para estimación monocular de profundidad eficiente y de alta retención de detalles.",
"hidream_i1_dev": "Genera imágenes con HiDream I1 Dev - Versión equilibrada con 28 pasos de inferencia, adecuada para hardware medio.",
"hidream_i1_fast": "Genera imágenes rápidamente con HiDream I1 Fast - Versión ligera con 16 pasos, ideal para previsualizaciones rápidas.",
"hidream_i1_full": "Genera imágenes con HiDream I1 Full - Versión completa con 50 pasos para la máxima calidad.",
"image_chroma_text_to_image": "Chroma está modificado de Flux y tiene algunos cambios en la arquitectura.",
"image_cosmos_predict2_2B_t2i": "Genera imágenes con Cosmos-Predict2 2B T2I, logrando generación física precisa, alta fidelidad y gran detalle.",
"image_lotus_depth_v1_1": "Ejecuta Lotus Depth en ComfyUI para estimación de profundidad monocular eficiente y detallada.",
"image_omnigen2_image_edit": "Edita imágenes con instrucciones en lenguaje natural usando las avanzadas capacidades de edición de imagen y soporte de texto de OmniGen2.",
"image_omnigen2_t2i": "Genera imágenes de alta calidad a partir de texto usando el modelo multimodal unificado 7B de OmniGen2 con arquitectura de doble vía.",
"sd3_5_large_blur": "Genera imágenes guiadas por imágenes de referencia desenfocadas usando SD 3.5.",
"sd3_5_large_blur": "Genera imágenes guiadas por imágenes de referencia borrosas usando SD 3.5.",
"sd3_5_large_canny_controlnet_example": "Genera imágenes guiadas por detección de bordes usando SD 3.5 Canny ControlNet.",
"sd3_5_large_depth": "Genera imágenes guiadas por información de profundidad usando SD 3.5.",
"sd3_5_simple_example": "Genera imágenes usando SD 3.5.",
"sdxl_refiner_prompt_example": "Mejora imágenes SDXL usando modelos refiner.",
"sdxl_refiner_prompt_example": "Mejora imágenes SDXL usando modelos refinadores.",
"sdxl_revision_text_prompts": "Genera imágenes transfiriendo conceptos de imágenes de referencia usando SDXL Revision.",
"sdxl_revision_zero_positive": "Genera imágenes usando tanto indicaciones de texto como imágenes de referencia con SDXL Revision.",
"sdxl_simple_example": "Genera imágenes de alta calidad usando SDXL.",
@@ -1539,23 +1528,23 @@
"api_bfl_flux_pro_t2i": "Genera imágenes con excelente seguimiento de indicaciones y calidad visual usando FLUX.1 Pro.",
"api_ideogram_v3_t2i": "Genera imágenes de calidad profesional con excelente alineación de indicaciones, fotorrealismo y renderizado de texto usando Ideogram V3.",
"api_luma_photon_i2i": "Guía la generación de imágenes usando una combinación de imágenes e indicaciones.",
"api_luma_photon_style_ref": "Genera imágenes mezclando referencias de estilo con control preciso usando Luma Photon.",
"api_luma_photon_style_ref": "Genera imágenes combinando referencias de estilo con control preciso usando Luma Photon.",
"api_openai_dall_e_2_inpaint": "Edita imágenes usando inpainting con la API OpenAI Dall-E 2.",
"api_openai_dall_e_2_t2i": "Genera imágenes a partir de texto usando la API OpenAI Dall-E 2.",
"api_openai_dall_e_3_t2i": "Genera imágenes a partir de texto usando la API OpenAI Dall-E 3.",
"api_openai_image_1_i2i": "Genera imágenes a partir de imágenes de entrada usando la API OpenAI GPT Image 1.",
"api_openai_image_1_i2i": "Genera imágenes a partir de imágenes usando la API OpenAI GPT Image 1.",
"api_openai_image_1_inpaint": "Edita imágenes usando inpainting con la API OpenAI GPT Image 1.",
"api_openai_image_1_multi_inputs": "Genera imágenes a partir de múltiples entradas usando la API OpenAI GPT Image 1.",
"api_openai_image_1_t2i": "Genera imágenes a partir de texto usando la API OpenAI GPT Image 1.",
"api_recraft_image_gen_with_color_control": "Genera imágenes con paletas de colores personalizadas y visuales específicos de marca usando Recraft.",
"api_recraft_image_gen_with_style_control": "Controla el estilo con ejemplos visuales, alinea la posición y ajusta objetos. Guarda y comparte estilos para coherencia de marca perfecta.",
"api_recraft_image_gen_with_color_control": "Genera imágenes con paletas de color personalizadas y visuales de marca usando Recraft.",
"api_recraft_image_gen_with_style_control": "Controla el estilo con ejemplos visuales, alinea la posición y ajusta objetos. Guarda y comparte estilos para consistencia de marca.",
"api_recraft_vector_gen": "Genera imágenes vectoriales de alta calidad a partir de texto usando el generador de vectores IA de Recraft.",
"api_runway_reference_to_image": "Genera nuevas imágenes basadas en estilos y composiciones de referencia con la IA de Runway.",
"api_runway_reference_to_image": "Genera nuevas imágenes basadas en estilos y composiciones de referencia con Runway.",
"api_runway_text_to_image": "Genera imágenes de alta calidad a partir de texto usando el modelo IA de Runway.",
"api_stability_ai_i2i": "Transforma imágenes con generación de alta calidad usando Stability AI, perfecto para edición profesional y transferencia de estilo.",
"api_stability_ai_sd3_5_i2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para casos profesionales a 1 megapíxel de resolución.",
"api_stability_ai_sd3_5_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para casos profesionales a 1 megapíxel de resolución.",
"api_stability_ai_stable_image_ultra_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para casos profesionales a 1 megapíxel de resolución."
"api_stability_ai_i2i": "Transforma imágenes con generación de alta calidad usando Stability AI, ideal para edición profesional y transferencia de estilo.",
"api_stability_ai_sd3_5_i2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel.",
"api_stability_ai_sd3_5_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel.",
"api_stability_ai_stable_image_ultra_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel."
},
"LLM API": {
"api_google_gemini": "Experimenta la IA multimodal de Google con las capacidades de razonamiento de Gemini.",
@@ -1563,48 +1552,48 @@
},
"Upscaling": {
"esrgan_example": "Escala imágenes usando modelos ESRGAN para mejorar la calidad.",
"hiresfix_esrgan_workflow": "Escala imágenes usando modelos ESRGAN durante pasos intermedios de generación.",
"hiresfix_esrgan_workflow": "Escala imágenes usando modelos ESRGAN durante pasos intermedios.",
"hiresfix_latent_workflow": "Escala imágenes mejorando la calidad en el espacio latente.",
"latent_upscale_different_prompt_model": "Escala imágenes cambiando las indicaciones entre pasadas de generación."
"latent_upscale_different_prompt_model": "Escala imágenes cambiando las indicaciones entre pasadas."
},
"Video": {
"hunyuan_video_text_to_video": "Genera videos a partir de texto usando el modelo Hunyuan.",
"image_to_video": "Genera videos a partir de imágenes fijas.",
"image_to_video_wan": "Genera videos a partir de imágenes usando Wan 2.1.",
"ltxv_image_to_video": "Genera videos a partir de imágenes fijas.",
"ltxv_text_to_video": "Genera videos a partir de indicaciones de texto.",
"ltxv_text_to_video": "Genera videos a partir de texto.",
"mochi_text_to_video_example": "Genera videos a partir de texto usando el modelo Mochi.",
"text_to_video_wan": "Genera videos a partir de texto usando Wan 2.1.",
"txt_to_image_to_video": "Genera videos creando primero imágenes a partir de texto.",
"video_cosmos_predict2_2B_video2world_480p_16fps": "Genera videos con Cosmos-Predict2 2B Video2World, logrando simulaciones de video físicamente precisas, de alta fidelidad y consistentes.",
"video_cosmos_predict2_2B_video2world_480p_16fps": "Genera videos con Cosmos-Predict2 2B Video2World, logrando simulaciones físicas precisas, alta fidelidad y consistencia.",
"video_wan2_1_fun_camera_v1_1_14B": "Genera videos de alta calidad con control avanzado de cámara usando el modelo completo de 14B.",
"video_wan2_1_fun_camera_v1_1_1_3B": "Genera videos dinámicos con movimientos de cámara cinematográficos usando el modelo Wan 2.1 Fun Camera 1.3B.",
"video_wan_vace_14B_ref2v": "Crea videos que coinciden con el estilo y contenido de una imagen de referencia. Perfecto para generación de video consistente en estilo.",
"video_wan2_1_fun_camera_v1_1_1_3B": "Genera videos dinámicos con movimientos de cámara cinematográficos usando Wan 2.1 Fun Camera 1.3B.",
"video_wan_vace_14B_ref2v": "Crea videos que coinciden con el estilo y contenido de una imagen de referencia.",
"video_wan_vace_14B_t2v": "Transforma descripciones de texto en videos de alta calidad. Soporta 480p y 720p con el modelo VACE-14B.",
"video_wan_vace_14B_v2v": "Genera videos controlando videos de entrada e imágenes de referencia usando Wan VACE.",
"video_wan_vace_flf2v": "Genera transiciones de video suaves definiendo cuadros iniciales y finales. Soporta secuencias de cuadros personalizadas.",
"video_wan_vace_inpainting": "Edita regiones específicas en videos preservando el contenido circundante. Ideal para eliminar o reemplazar objetos.",
"video_wan_vace_outpainting": "Genera videos extendidos ampliando el tamaño del video usando Wan VACE outpainting.",
"wan2_1_flf2v_720_f16": "Genera videos controlando los primeros y últimos cuadros usando Wan 2.1 FLF2V.",
"video_wan_vace_flf2v": "Genera transiciones suaves definiendo fotogramas iniciales y finales. Soporta secuencias de fotogramas personalizadas.",
"video_wan_vace_inpainting": "Edita regiones específicas en videos preservando el contenido circundante.",
"video_wan_vace_outpainting": "Genera videos extendidos expandiendo el tamaño usando Wan VACE outpainting.",
"wan2_1_flf2v_720_f16": "Genera videos controlando primer y último fotograma usando Wan 2.1 FLF2V.",
"wan2_1_fun_control": "Genera videos guiados por pose, profundidad y bordes usando Wan 2.1 ControlNet.",
"wan2_1_fun_inp": "Genera videos a partir de cuadros iniciales y finales usando Wan 2.1 inpainting."
"wan2_1_fun_inp": "Genera videos a partir de fotogramas iniciales y finales usando Wan 2.1 inpainting."
},
"Video API": {
"api_hailuo_minimax_i2v": "Genera videos refinados a partir de imágenes y texto con integración CGI usando MiniMax.",
"api_hailuo_minimax_t2v": "Genera videos de alta calidad directamente desde texto. Explora las capacidades avanzadas de IA de MiniMax para crear narrativas visuales diversas con efectos CGI profesionales y elementos estilísticos.",
"api_hailuo_minimax_t2v": "Genera videos de alta calidad directamente desde texto. Explora las capacidades avanzadas de IA de MiniMax para crear narrativas visuales diversas con efectos CGI profesionales.",
"api_kling_effects": "Genera videos dinámicos aplicando efectos visuales a imágenes usando Kling.",
"api_kling_flf": "Genera videos controlando el primer y último cuadro.",
"api_kling_flf": "Genera videos controlando los primeros y últimos fotogramas.",
"api_kling_i2v": "Genera videos con excelente adherencia a la indicación para acciones, expresiones y movimientos de cámara usando Kling.",
"api_luma_i2v": "Convierte imágenes estáticas en animaciones mágicas de alta calidad al instante.",
"api_luma_t2v": "Se pueden generar videos de alta calidad usando indicaciones simples.",
"api_moonvalley_image_to_video": "Genera videos cinematográficos en 1080p a partir de una imagen mediante un modelo entrenado exclusivamente con datos licenciados.",
"api_moonvalley_text_to_video": "Genera videos cinematográficos en 1080p a partir de texto mediante un modelo entrenado exclusivamente con datos licenciados.",
"api_pika_i2v": "Genera videos animados suaves a partir de una sola imagen estática usando Pika AI.",
"api_luma_t2v": "Genera videos de alta calidad usando indicaciones simples.",
"api_moonvalley_image_to_video": "Genera videos cinematográficos 1080p a partir de una imagen usando un modelo entrenado solo con datos licenciados.",
"api_moonvalley_text_to_video": "Genera videos cinematográficos 1080p a partir de texto usando un modelo entrenado solo con datos licenciados.",
"api_pika_i2v": "Genera videos animados suaves a partir de imágenes estáticas usando Pika AI.",
"api_pika_scene": "Genera videos que incorporan múltiples imágenes de entrada usando Pika Scenes.",
"api_pixverse_i2v": "Genera videos dinámicos a partir de imágenes estáticas con movimiento y efectos usando PixVerse.",
"api_pixverse_t2v": "Genera videos con interpretación precisa de indicaciones y dinámicas visuales impresionantes.",
"api_pixverse_t2v": "Genera videos con interpretación precisa de indicaciones y dinámica visual impresionante.",
"api_pixverse_template_i2v": "Genera videos dinámicos a partir de imágenes estáticas con movimiento y efectos usando PixVerse.",
"api_runway_first_last_frame": "Genera transiciones de video suaves entre dos cuadros clave con la precisión de Runway.",
"api_runway_first_last_frame": "Genera transiciones de video suaves entre dos fotogramas clave con precisión de Runway.",
"api_runway_gen3a_turbo_image_to_video": "Genera videos cinematográficos a partir de imágenes estáticas usando Runway Gen3a Turbo.",
"api_runway_gen4_turo_image_to_video": "Genera videos dinámicos a partir de imágenes usando Runway Gen4 Turbo.",
"api_veo2_i2v": "Genera videos a partir de imágenes usando la API Google Veo2."
@@ -1613,29 +1602,29 @@
"title": "Comienza con una Plantilla"
},
"toastMessages": {
"cannotCreateSubgraph": "No se puede crear subgrafo",
"cannotCreateSubgraph": "No se puede crear el subgrafo",
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
"dropFileError": "No se pudo procesar el elemento soltado: {error}",
"dropFileError": "No se puede procesar el elemento soltado: {error}",
"emptyCanvas": "Lienzo vacío",
"errorCopyImage": "Error al copiar la imagen: {error}",
"errorLoadingModel": "Error al cargar el modelo",
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
"failedToApplyTexture": "No se pudo aplicar la textura",
"failedToConvertToSubgraph": "No se pudo convertir los elementos a subgrafo",
"failedToApplyTexture": "Error al aplicar textura",
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
"failedToDownloadFile": "No se pudo descargar el archivo",
"failedToExportModel": "No se pudo exportar el modelo como {format}",
"failedToDownloadFile": "Error al descargar el archivo",
"failedToExportModel": "Error al exportar modelo como {format}",
"failedToFetchBalance": "No se pudo obtener el saldo: {error}",
"failedToFetchLogs": "No se pudieron obtener los registros del servidor",
"failedToFetchLogs": "Error al obtener los registros del servidor",
"failedToInitializeLoad3dViewer": "No se pudo inicializar el visor 3D",
"failedToInitiateCreditPurchase": "No se pudo iniciar la compra de créditos: {error}",
"failedToPurchaseCredits": "No se pudo comprar créditos: {error}",
"fileLoadError": "No se pudo encontrar el flujo de trabajo en {fileName}",
"fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}",
"fileUploadFailed": "Error al subir el archivo",
"interrupted": "La ejecución ha sido interrumpida",
"migrateToLitegraphReroute": "Los nodos de redirección serán eliminados en futuras versiones. Haz clic para migrar a la redirección nativa de litegraph.",
"no3dScene": "No hay escena 3D para aplicar la textura",
"migrateToLitegraphReroute": "Los nodos de reroute se eliminarán en futuras versiones. Haz clic para migrar a reroute nativo de litegraph.",
"no3dScene": "No hay escena 3D para aplicar textura",
"no3dSceneToExport": "No hay escena 3D para exportar",
"noTemplatesToExport": "No hay plantillas para exportar",
"nodeDefinitionsUpdated": "Definiciones de nodos actualizadas",
@@ -1643,12 +1632,12 @@
"nothingToGroup": "Nada para agrupar",
"nothingToQueue": "Nada para poner en cola",
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, selecciona los nodos (u otros grupos) para crear un grupo",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",
"unableToGetModelFilePath": "No se pudo obtener la ruta del archivo del modelo",
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"updateRequested": "Actualización solicitada",
"useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de Comfy API Key.",
"useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de clave API de Comfy.",
"userNotAuthenticated": "Usuario no autenticado"
},
"userSelect": {
@@ -1662,12 +1651,12 @@
"email": "Correo electrónico",
"name": "Nombre",
"notSet": "No establecido",
"provider": "Proveedor de inicio de sesión",
"provider": "Método de inicio de sesión",
"title": "Configuración de usuario",
"updatePassword": "Actualizar contraseña"
},
"validation": {
"invalidEmail": "Dirección de correo electrónico no válida",
"invalidEmail": "Dirección de correo electrónico inválida",
"length": "Debe tener {length} caracteres",
"maxLength": "No debe tener más de {length} caracteres",
"minLength": "Debe tener al menos {length} caracteres",
@@ -1682,7 +1671,7 @@
},
"personalDataConsentRequired": "Debes aceptar el procesamiento de tus datos personales.",
"prefix": "Debe comenzar con {prefix}",
"required": "Obligatorio"
"required": "Requerido"
},
"versionMismatchWarning": {
"dismiss": "Descartar",
@@ -1696,7 +1685,7 @@
"title": "Bienvenido a ComfyUI"
},
"whatsNewPopup": {
"learnMore": "Más información",
"learnMore": "Aprende más",
"noReleaseNotes": "No hay notas de la versión disponibles."
},
"workflowService": {

View File

@@ -277,10 +277,6 @@
"label": "Basculer la barre latérale de la bibliothèque de nœuds",
"tooltip": "Bibliothèque de nœuds"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "Basculer la barre latérale de lExplorateur de sortie",
"tooltip": "Explorateur de sortie"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Basculer la barre latérale de la file d'attente",
"tooltip": "File d'attente"

File diff suppressed because it is too large Load Diff

View File

@@ -277,10 +277,6 @@
"label": "ノードライブラリサイドバーの切り替え",
"tooltip": "ノードライブラリ"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "出力エクスプローラーサイドバーを切り替え",
"tooltip": "出力エクスプローラー"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "キューサイドバーの切り替え",
"tooltip": "キュー"

File diff suppressed because it is too large Load Diff

View File

@@ -277,10 +277,6 @@
"label": "노드 라이브러리 사이드바 토글",
"tooltip": "노드 라이브러리"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "출력 탐색기 사이드바 전환",
"tooltip": "출력 탐색기"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "실행 큐 사이드바 토글",
"tooltip": "실행 큐"

File diff suppressed because it is too large Load Diff

View File

@@ -277,10 +277,6 @@
"label": "Переключить боковую панель библиотеки нод",
"tooltip": "Библиотека нод"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "Переключить боковую панель проводника вывода",
"tooltip": "Проводник вывода"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Переключить боковую панель очереди",
"tooltip": "Очередь"

File diff suppressed because it is too large Load Diff

View File

@@ -277,10 +277,6 @@
"label": "切換節點庫側邊欄",
"tooltip": "節點庫"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "切換輸出總覽側邊欄",
"tooltip": "輸出總覽"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切換佇列側邊欄",
"tooltip": "佇列"

View File

@@ -119,12 +119,12 @@
"Add Group": "新增群組",
"Add Group For Selected Nodes": "為選取的節點新增群組",
"Add Node": "新增節點",
"Bypass": "過",
"Bypass": "過",
"Clone": "複製",
"Collapse": "收合",
"Colors": "顏色",
"Convert to Group Node": "轉換為群組節點",
"Copy (Clipspace)": "複製(Clipspace",
"Copy (Clipspace)": "複製(剪貼空間",
"Expand": "展開",
"Inputs": "輸入",
"Manage": "管理",
@@ -163,7 +163,7 @@
"buyNow": "立即購買",
"insufficientMessage": "您的點數不足,無法執行此工作流程。",
"insufficientTitle": "點數不足",
"maxAmount": "(最高 $1,000 USD",
"maxAmount": "(最高 $1,000 美元",
"quickPurchase": "快速購買",
"seeDetails": "查看詳情",
"topUp": "儲值"
@@ -174,7 +174,7 @@
"*": "*",
"AUDIO": "音訊",
"BOOLEAN": "布林值",
"CAMERA_CONTROL": "機控制",
"CAMERA_CONTROL": "攝影機控制",
"CLIP": "CLIP",
"CLIP_VISION": "CLIP 視覺",
"CLIP_VISION_OUTPUT": "CLIP 視覺輸出",
@@ -191,7 +191,7 @@
"INT": "整數",
"LATENT": "latent (潛空間)",
"LATENT_OPERATION": "latent 操作",
"LOAD3D_CAMERA": "載入3D機",
"LOAD3D_CAMERA": "載入 3D 攝影機",
"LOAD_3D": "載入 3D",
"LOAD_3D_ANIMATION": "載入 3D 動畫",
"LUMA_CONCEPTS": "LUMA 概念",
@@ -224,13 +224,13 @@
"reinstall": "重新安裝"
},
"desktopUpdate": {
"description": "ComfyUI Desktop 正在安裝新依賴項。這可能需要幾分鐘。",
"description": "ComfyUI Desktop 正在安裝新相依套件,這可能需要幾分鐘。",
"errorCheckingUpdate": "檢查更新時發生錯誤",
"errorInstallingUpdate": "安裝更新時發生錯誤",
"noUpdateFound": "未發現更新",
"terminalDefaultMessage": "任何來自更新的主控台輸出都會顯示在這裡。",
"title": "正在更新 ComfyUI Desktop",
"updateAvailableMessage": "有可用的更新。要立即重新啟動並更新嗎?",
"updateAvailableMessage": "有可用的更新。要立即重新啟動並更新嗎?",
"updateFoundTitle": "發現更新v{version}"
},
"downloadGit": {
@@ -264,8 +264,7 @@
"amount": "數量",
"apply": "套用",
"architecture": "架構",
"audio": "音訊",
"audioFailedToLoad": "音訊載入失敗",
"audioFailedToLoad": "無法載入音訊",
"author": "作者",
"back": "返回",
"cancel": "取消",
@@ -273,7 +272,7 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearFilters": "清除篩選條件",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
"comingSoon": "即將推出",
@@ -314,13 +313,11 @@
"filter": "篩選",
"findIssues": "尋找問題",
"firstTimeUIMessage": "這是您第一次使用新介面。若要返回舊介面,請前往「選單」>「使用新介面」>「關閉」。",
"folder": "資料夾",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "前往節點",
"help": "說明",
"icon": "圖示",
"image": "影像",
"imageFailedToLoad": "無法載入圖片",
"imageUrl": "圖片網址",
"import": "匯入",
@@ -330,25 +327,23 @@
"installed": "已安裝",
"installing": "安裝中",
"interrupted": "已中斷",
"itemsCount": "{0} 項",
"keybinding": "快捷鍵",
"keybindingAlreadyExists": "快捷鍵已存在於",
"learnMore": "了解更多",
"loadAllFolders": "載入所有資料夾",
"loadWorkflow": "載入工作流程",
"loading": "載入中",
"loadingPanel": "正在載入 {panel} 面板...",
"loadingPanel": "正在載入{panel}面板...",
"login": "登入",
"logs": "日誌",
"micPermissionDenied": "麥克風權限被拒絕",
"migrate": "遷移",
"missing": "缺少",
"modifyTime": "修改時間",
"name": "名稱",
"newFolder": "新資料夾",
"next": "下一步",
"no": "否",
"noAudioRecorded": "錄製音訊",
"noAudioRecorded": "沒有錄製音訊",
"noResultsFound": "找不到結果",
"noTasksFound": "找不到任務",
"noTasksFoundMessage": "佇列中沒有任務。",
@@ -363,7 +358,7 @@
"reconnected": "已重新連線",
"reconnecting": "重新連線中",
"refresh": "重新整理",
"releaseTitle": "{package} {version} 發布",
"releaseTitle": "{package} {version} 版本發佈",
"reloadToApplyChanges": "重新載入以套用變更",
"rename": "重新命名",
"reportIssue": "送出回報",
@@ -378,7 +373,6 @@
"saving": "儲存中",
"searchExtensions": "搜尋擴充套件",
"searchFailedMessage": "找不到符合您搜尋的設定。請嘗試調整搜尋條件。",
"searchIn": "在 {0} 中搜尋",
"searchKeybindings": "搜尋快捷鍵",
"searchModels": "搜尋模型",
"searchNodes": "搜尋節點",
@@ -387,7 +381,6 @@
"setAsBackground": "設為背景",
"settings": "設定",
"showReport": "顯示報告",
"size": "大小",
"sort": "排序",
"source": "來源",
"startRecording": "開始錄音",
@@ -398,7 +391,6 @@
"terminal": "終端機",
"title": "標題",
"triggerPhrase": "觸發詞",
"type": "類型",
"unknownError": "未知錯誤",
"update": "更新",
"updateAvailable": "有可用更新",
@@ -410,7 +402,6 @@
"user": "使用者",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"video": "影片",
"videoFailedToLoad": "無法載入影片",
"workflow": "工作流程"
},
@@ -434,13 +425,13 @@
"docs": "文件",
"github": "Github",
"helpFeedback": "幫助與回饋",
"loadingReleases": "正在載入版本...",
"more": "更多...",
"noRecentReleases": "近期無版本更新",
"loadingReleases": "正在載入版本資訊…",
"more": "更多",
"noRecentReleases": "近期沒有新版本",
"openDevTools": "開啟開發者工具",
"reinstall": "重新安裝",
"updateAvailable": "有更新",
"whatsNew": "最新消息"
"whatsNew": "有什麼新功能"
},
"icon": {
"bookmark": "書籤",
@@ -485,7 +476,7 @@
"installLocationDescription": "選擇 ComfyUI 使用者資料的目錄。Python 環境將安裝在所選位置。",
"installLocationTooltip": "ComfyUI 的使用者資料目錄。儲存:\n- Python 環境\n- 模型\n- 自訂節點\n",
"insufficientFreeSpace": "空間不足 - 最低可用空間",
"isOneDrive": "不支援 OneDrive。請在其他位置安裝 ComfyUI。",
"isOneDrive": "不支援 OneDrive。請 ComfyUI 安裝在其他位置。",
"manualConfiguration": {
"createVenv": "您需要在下列目錄建立虛擬環境",
"requirements": "需求",
@@ -500,7 +491,7 @@
"migrationOptional": "遷移為選擇性步驟。如果您沒有現有安裝,可以略過此步驟。",
"migrationSourcePathDescription": "如果您已有 ComfyUI 安裝,我們可以將您現有的使用者檔案與模型複製/連結到新安裝。您現有的 ComfyUI 安裝不會受到影響。",
"moreInfo": "更多資訊請參閱",
"nonDefaultDrive": "請將 ComfyUI 安裝在系統磁碟(例如 C:\\)。不同檔案系統的磁碟可能會導致不可預期的問題。模型和其他檔案可於安裝後儲存在其他磁碟。",
"nonDefaultDrive": "請將 ComfyUI 安裝在您的系統磁碟(例如 C:\\)。不同檔案系統的磁碟可能會導致不可預期的問題。安裝後,模型和其他檔案可儲存在其他磁碟。",
"parentMissing": "路徑不存在 - 請先建立上層目錄",
"pathExists": "目錄已存在 - 請確保您已備份所有資料",
"pathValidationFailed": "路徑驗證失敗",
@@ -511,7 +502,7 @@
"allowMetricsDescription": "協助改進 ComfyUI傳送匿名使用統計資料。不會收集個人資訊或工作流程內容。",
"autoUpdate": "自動更新",
"autoUpdateDescription": "自動下載可用更新。安裝前會通知您。",
"checkingMirrors": "正在檢查 Python 鏡像的網路連線...",
"checkingMirrors": "正在檢查 Python 鏡像的網路連線...",
"dataCollectionDialog": {
"collect": {
"errorReports": "錯誤訊息與堆疊追蹤",
@@ -533,10 +524,10 @@
"errorUpdatingConsentDetail": "無法更新統計同意設定",
"learnMoreAboutData": "了解更多資料收集資訊",
"mirrorSettings": "鏡像設定",
"mirrorsReachable": "Python 鏡像網路連線良好",
"mirrorsUnreachable": "部分 Python 鏡像網路連線不佳",
"pypiMirrorPlaceholder": "輸入 PyPI 鏡像網址",
"pythonMirrorPlaceholder": "輸入 Python 鏡像網址"
"mirrorsReachable": "Python 鏡像網路連線正常",
"mirrorsUnreachable": "部分 Python 鏡像網路連線異常",
"pypiMirrorPlaceholder": "輸入 PyPI 鏡像網址",
"pythonMirrorPlaceholder": "輸入 Python 鏡像網址"
},
"systemLocations": "系統位置",
"unhandledError": "未知錯誤",
@@ -544,14 +535,14 @@
},
"issueReport": {
"contactFollowUp": "需要聯絡我以便後續追蹤",
"contactSupportDescription": "請在下方表單填寫您的回報內容",
"contactSupportTitle": "聯絡支援",
"contactSupportDescription": "請填寫下列表單並提交您的報告",
"contactSupportTitle": "聯絡客服支援",
"describeTheProblem": "請描述問題",
"email": "電子郵件",
"feedbackTitle": "協助我們改進 ComfyUI請提供您的回饋",
"helpFix": "協助修復此問題",
"helpTypes": {
"billingPayments": "帳單/付款",
"billingPayments": "帳單/付款問題",
"bugReport": "錯誤回報",
"giveFeedback": "提供回饋",
"loginAccessIssues": "登入/存取問題",
@@ -561,12 +552,12 @@
"provideAdditionalDetails": "提供更多細節",
"provideEmail": "請提供您的電子郵件(選填)",
"rating": "評分",
"selectIssue": "選擇問題",
"selectIssue": "選擇問題",
"stackTrace": "堆疊追蹤",
"submitErrorReport": "提交錯誤報告(選填)",
"systemStats": "系統狀態",
"validation": {
"descriptionRequired": "請填寫描述",
"descriptionRequired": "請填寫問題描述",
"helpTypeRequired": "請選擇協助類型",
"invalidEmail": "請輸入有效的電子郵件地址",
"maxLength": "訊息過長",
@@ -597,7 +588,7 @@
"materialMode": "材質模式",
"materialModes": {
"depth": "深度",
"lineart": "線稿",
"lineart": "線條藝術",
"normal": "一般",
"original": "原始",
"wireframe": "線框"
@@ -625,10 +616,10 @@
"cameraType": "相機類型",
"cancel": "取消",
"exportSettings": "匯出設定",
"lightSettings": "光設定",
"lightSettings": "光設定",
"modelSettings": "模型設定",
"sceneSettings": "場景設定",
"title": "3D 檢視器(Beta"
"title": "3D 檢視器(測試版"
}
},
"loadWorkflowWarning": {
@@ -640,10 +631,10 @@
"None": "無",
"OK": "正常",
"Skipped": "已略過",
"allOk": "未測到任何問題。",
"confirmTitle": "確定嗎?",
"consoleLogs": "控台日誌",
"detected": "已測",
"allOk": "未測到任何問題。",
"confirmTitle": "確定要繼續嗎?",
"consoleLogs": "控台日誌",
"detected": "已測",
"error": {
"cannotContinue": "無法繼續 - 仍有錯誤存在",
"defaultDescription": "執行維護任務時發生錯誤。",
@@ -678,10 +669,10 @@
"latestVersion": "最新版本",
"license": "授權條款",
"loadingVersions": "正在載入版本...",
"nightlyVersion": "夜間版",
"noDescription": "沒有可用的描述",
"nightlyVersion": "每夜建置版",
"noDescription": "沒有可用的說明",
"noNodesFound": "找不到任何節點",
"noNodesFoundDescription": "此套件的節點無法解析,或此套件僅為前端擴充沒有任何節點。",
"noNodesFoundDescription": "此套件的節點無法解析,或此套件僅為前端擴充功能,沒有任何節點。",
"noResultsFound": "找不到符合搜尋條件的結果。",
"nodePack": "節點包",
"packsSelected": "已選擇套件",
@@ -715,7 +706,7 @@
"version": "版本"
},
"maskEditor": {
"Apply to Whole Image": "套用至整張圖",
"Apply to Whole Image": "套用至整張圖",
"Brush Settings": "筆刷設定",
"Brush Shape": "筆刷形狀",
"Clear": "清除",
@@ -762,8 +753,8 @@
"refresh": "重新整理節點定義",
"resetView": "重設畫布視圖",
"run": "執行",
"runWorkflow": "執行工作流程Shift 以排到最前面",
"runWorkflowFront": "執行工作流程(排到最前面",
"runWorkflow": "執行工作流程Shift 於前方排隊",
"runWorkflowFront": "執行工作流程(前方排隊",
"settings": "設定",
"showMenu": "顯示選單",
"theme": "主題",
@@ -832,7 +823,6 @@
"Open Outputs Folder": "開啟輸出資料夾",
"Open Sign In Dialog": "開啟登入對話框",
"Open extra_model_paths_yaml": "開啟 extra_model_paths.yaml",
"Output Explorer": "輸出總管",
"Pin/Unpin Selected Items": "釘選/取消釘選選取項目",
"Pin/Unpin Selected Nodes": "釘選/取消釘選選取節點",
"Previous Opened Workflow": "上一個已開啟的工作流程",
@@ -853,12 +843,12 @@
"Show Model Selector (Dev)": "顯示模型選擇器(開發用)",
"Show Settings Dialog": "顯示設定對話框",
"Sign Out": "登出",
"Toggle Essential Bottom Panel": "切換基本底部面板",
"Toggle Essential Bottom Panel": "切換基本下方面板",
"Toggle Logs Bottom Panel": "切換日誌下方面板",
"Toggle Search Box": "切換搜尋框",
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle View Controls Bottom Panel": "切換檢視控制底部面板",
"Toggle View Controls Bottom Panel": "切換檢視控制下方面板",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
@@ -899,7 +889,7 @@
"advanced": "進階",
"animation": "動畫",
"api": "API",
"api node": "api 節點",
"api node": "API 節點",
"attention_experiments": "注意力實驗",
"audio": "音訊",
"batch": "批次",
@@ -936,12 +926,12 @@
"photomaker": "photomaker",
"postprocessing": "後處理",
"preprocessors": "前處理器",
"primitive": "基元件",
"primitive": "基元件",
"samplers": "取樣器",
"sampling": "取樣",
"schedulers": "排程器",
"scheduling": "排程",
"sd": "sd",
"sd": "SD",
"sd3": "sd3",
"sigmas": "西格瑪值",
"stable_cascade": "stable_cascade",
@@ -990,7 +980,7 @@
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "未選取任何輸出節點",
"tooltip": "執行至選取的輸出節點(以色邊框標示)"
"tooltip": "執行至選取的輸出節點(以色邊框標示)"
}
},
"serverConfig": {
@@ -1153,7 +1143,7 @@
"Comfy": "Comfy",
"Comfy-Desktop": "Comfy-Desktop",
"ContextMenu": "右鍵選單",
"Credits": "製作團隊",
"Credits": "點數",
"CustomColorPalettes": "自訂色彩調色盤",
"DevMode": "開發者模式",
"EditTokenWeight": "編輯權重",
@@ -1195,7 +1185,7 @@
"Workflow": "工作流程"
},
"shortcuts": {
"essentials": "基本功能",
"essentials": "基本",
"keyboardShortcuts": "鍵盤快捷鍵",
"manageShortcuts": "管理快捷鍵",
"noKeybinding": "無快捷鍵",
@@ -1243,7 +1233,6 @@
"sortMode": "排序模式"
},
"openWorkflow": "在本機檔案系統中開啟工作流程",
"outputExplorer": "輸出總覽",
"queue": "佇列",
"queueTab": {
"backToAllTasks": "返回所有任務",
@@ -1267,7 +1256,7 @@
"deleteFailedTitle": "刪除失敗",
"deleted": "工作流程已刪除",
"dirtyClose": "下列檔案已被修改。您要在關閉前儲存它們嗎?",
"dirtyCloseHint": "按住 Shift 可直接關閉不提示",
"dirtyCloseHint": "按住 Shift 可直接關閉不提示",
"dirtyCloseTitle": "儲存變更?",
"workflowTreeType": {
"bookmarks": "書籤",
@@ -1290,15 +1279,15 @@
"category": {
"3D": "3D",
"All": "所有範本",
"Area Composition": "區域構圖",
"Area Composition": "區域合成",
"Audio": "音訊",
"Basics": "基礎",
"ComfyUI Examples": "ComfyUI 範例",
"ControlNet": "ControlNet",
"Custom Nodes": "自訂節點",
"Flux": "Flux",
"Image": "影像",
"Image API": "影像 API",
"Image": "圖片",
"Image API": "圖片 API",
"LLM API": "LLM API",
"Upscaling": "放大",
"Video": "影片",
@@ -1314,96 +1303,96 @@
"stable_zero123_example": "Stable Zero123"
},
"3D API": {
"api_rodin_image_to_model": "Rodin影像轉模型",
"api_rodin_image_to_model": "Rodin圖片轉模型",
"api_rodin_multiview_to_model": "Rodin多視角轉模型",
"api_tripo_image_to_model": "Tripo影像轉模型",
"api_tripo_image_to_model": "Tripo圖片轉模型",
"api_tripo_multiview_to_model": "Tripo多視角轉模型",
"api_tripo_text_to_model": "Tripo文字轉模型"
},
"Area Composition": {
"area_composition": "區域構圖",
"area_composition_square_area_for_subject": "區域構圖主體方格"
"area_composition": "區域合成",
"area_composition_square_area_for_subject": "主體區域一致合成"
},
"Audio": {
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M 編輯",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 文字轉純樂器",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 文字轉純樂器音樂",
"audio_ace_step_1_t2a_song": "ACE Step v1 文字轉歌曲",
"audio_stable_audio_example": "Stable Audio"
},
"Basics": {
"default": "影像生成",
"default": "圖片生成",
"embedding_example": "Embedding",
"gligen_textbox_example": "Gligen 文字框",
"image2image": "影像轉影像",
"inpaint_example": "修補",
"gligen_textbox_example": "Gligen Textbox",
"image2image": "圖片轉圖片",
"inpaint_example": "Inpaint",
"inpaint_model_outpainting": "外延",
"lora": "LoRA",
"lora_multiple": "多重 LoRA"
"lora_multiple": "多重LoRA"
},
"ControlNet": {
"2_pass_pose_worship": "姿勢 ControlNet 兩階段",
"controlnet_example": "塗鴉 ControlNet",
"depth_controlnet": "深度 ControlNet",
"depth_t2i_adapter": "深度 T2I Adapter",
"mixing_controlnets": "混合 ControlNets"
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
"controlnet_example": "Scribble ControlNet",
"depth_controlnet": "Depth ControlNet",
"depth_t2i_adapter": "Depth T2I Adapter",
"mixing_controlnets": "Mixing ControlNets"
},
"Flux": {
"flux_canny_model_example": "Flux Canny 模型",
"flux_depth_lora_example": "Flux 深度 LoRA",
"flux_canny_model_example": "Flux Canny Model",
"flux_depth_lora_example": "Flux Depth LoRA",
"flux_dev_checkpoint_example": "Flux Dev fp8",
"flux_dev_full_text_to_image": "Flux Dev 完整文字轉影像",
"flux_fill_inpaint_example": "Flux 修補",
"flux_fill_outpaint_example": "Flux 外延",
"flux_dev_full_text_to_image": "Flux Dev 完整文字轉",
"flux_fill_inpaint_example": "Flux Inpaint",
"flux_fill_outpaint_example": "Flux Outpaint",
"flux_kontext_dev_basic": "Flux Kontext Dev基礎",
"flux_kontext_dev_grouped": "Flux Kontext Dev組)",
"flux_redux_model_example": "Flux Redux 模型",
"flux_kontext_dev_grouped": "Flux Kontext Dev合版",
"flux_redux_model_example": "Flux Redux Model",
"flux_schnell": "Flux Schnell fp8",
"flux_schnell_full_text_to_image": "Flux Schnell 完整文字轉影像"
"flux_schnell_full_text_to_image": "Flux Schnell 完整文字轉"
},
"Image": {
"hidream_e1_full": "HiDream E1 Full",
"hidream_e1_full": "HiDream E1 完整版",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"image_chroma_text_to_image": "Chroma 文字轉影像",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
"image_chroma_text_to_image": "Chroma 文字轉",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B 文字轉圖",
"image_lotus_depth_v1_1": "Lotus Depth",
"image_omnigen2_image_edit": "OmniGen2 影像編輯",
"image_omnigen2_t2i": "OmniGen2 文字轉影像",
"sd3_5_large_blur": "SD3.5 大型模糊",
"sd3_5_large_canny_controlnet_example": "SD3.5 大型 Canny ControlNet",
"sd3_5_large_depth": "SD3.5 大型深度",
"sd3_5_simple_example": "SD3.5 簡易",
"sdxl_refiner_prompt_example": "SDXL 精煉提示",
"sdxl_revision_text_prompts": "SDXL Revision 文字提示",
"image_omnigen2_image_edit": "OmniGen2 圖片編輯",
"image_omnigen2_t2i": "OmniGen2 文字轉",
"sd3_5_large_blur": "SD3.5 Large Blur",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
"sd3_5_large_depth": "SD3.5 Large Depth",
"sd3_5_simple_example": "SD3.5 Simple",
"sdxl_refiner_prompt_example": "SDXL Refiner Prompt",
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxl_simple_example": "SDXL 簡易",
"sdxl_simple_example": "SDXL Simple",
"sdxlturbo_example": "SDXL Turbo"
},
"Image API": {
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext 多影像輸入",
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext 多輸入",
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]:文字轉影像",
"api_ideogram_v3_t2i": "Ideogram V3:文字轉影像",
"api_luma_photon_i2i": "Luma Photon:影像轉影像",
"api_luma_photon_style_ref": "Luma Photon:風格參考",
"api_bfl_flux_pro_t2i": "BFL Flux 1.1[pro] Ultra Text to Image",
"api_ideogram_v3_t2i": "Ideogram V3 Text to Image",
"api_luma_photon_i2i": "Luma Photon Image to Image",
"api_luma_photon_style_ref": "Luma Photon Style Reference",
"api_openai_dall_e_2_inpaint": "OpenAIDall-E 2 修補",
"api_openai_dall_e_2_t2i": "OpenAIDall-E 2 文字轉影像",
"api_openai_dall_e_3_t2i": "OpenAIDall-E 3 文字轉影像",
"api_openai_image_1_i2i": "OpenAIGPT-Image-1 影像轉影像",
"api_openai_image_1_inpaint": "OpenAIGPT-Image-1 修補",
"api_openai_image_1_multi_inputs": "OpenAIGPT-Image-1 多重輸入",
"api_openai_image_1_t2i": "OpenAIGPT-Image-1 文字轉影像",
"api_recraft_image_gen_with_color_control": "Recraft:色彩控制影像生成",
"api_recraft_image_gen_with_style_control": "Recraft:風格控制影像生成",
"api_recraft_vector_gen": "Recraft:向量生成",
"api_runway_reference_to_image": "Runway參考轉影像",
"api_runway_text_to_image": "Runway文字轉影像",
"api_stability_ai_i2i": "Stability AI影像轉影像",
"api_stability_ai_sd3_5_i2i": "Stability AISD3.5 影像轉影像",
"api_stability_ai_sd3_5_t2i": "Stability AISD3.5 文字轉影像",
"api_stability_ai_stable_image_ultra_t2i": "Stability AIStable Image Ultra 文字轉影像"
"api_openai_dall_e_2_t2i": "OpenAIDall-E 2 文字轉",
"api_openai_dall_e_3_t2i": "OpenAIDall-E 3 文字轉",
"api_openai_image_1_i2i": "OpenAI Image-1 Image to Image",
"api_openai_image_1_inpaint": "OpenAI Image-1 Inpaint",
"api_openai_image_1_multi_inputs": "OpenAI Image-1 Multi Inputs",
"api_openai_image_1_t2i": "OpenAI Image-1 Text to Image",
"api_recraft_image_gen_with_color_control": "Recraft Color Control Image Generation",
"api_recraft_image_gen_with_style_control": "Recraft Style Control Image Generation",
"api_recraft_vector_gen": "Recraft Vector Generation",
"api_runway_reference_to_image": "Runway參考圖轉圖",
"api_runway_text_to_image": "Runway文字轉",
"api_stability_ai_i2i": "Stability AI圖轉圖",
"api_stability_ai_sd3_5_i2i": "Stability AISD3.5 圖轉圖",
"api_stability_ai_sd3_5_t2i": "Stability AISD3.5 文字轉",
"api_stability_ai_stable_image_ultra_t2i": "Stability AIStable Image Ultra 文字轉"
},
"LLM API": {
"api_google_gemini": "Google Gemini聊天",
@@ -1411,23 +1400,23 @@
},
"Upscaling": {
"esrgan_example": "ESRGAN",
"hiresfix_esrgan_workflow": "HiresFix ESRGAN 工作流",
"hiresfix_latent_workflow": "放大",
"latent_upscale_different_prompt_model": "Latent 放大不同提示模型"
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
"hiresfix_latent_workflow": "Upscale",
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
},
"Video": {
"hunyuan_video_text_to_video": "Hunyuan 影片文字轉影片",
"image_to_video": "SVD 影像轉影片",
"image_to_video_wan": "Wan 2.1 影像轉影片",
"ltxv_image_to_video": "LTXV 影像轉影片",
"ltxv_text_to_video": "LTXV 文字轉影片",
"mochi_text_to_video_example": "Mochi 文字轉影片",
"text_to_video_wan": "Wan 2.1 文字轉影片",
"txt_to_image_to_video": "SVD 文字轉影像再轉影片",
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
"image_to_video": "SVD Image to Video",
"image_to_video_wan": "Wan 2.1 Image to Video",
"ltxv_image_to_video": "LTXV Image to Video",
"ltxv_text_to_video": "LTXV Text to Video",
"mochi_text_to_video_example": "Mochi Text to Video",
"text_to_video_wan": "Wan 2.1 Text to Video",
"txt_to_image_to_video": "SVD Text to Image to Video",
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps",
"video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B",
"video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B",
"video_wan_vace_14B_ref2v": "Wan VACE 參考轉影片",
"video_wan_vace_14B_ref2v": "Wan VACE 參考轉影片",
"video_wan_vace_14B_t2v": "Wan VACE 文字轉影片",
"video_wan_vace_14B_v2v": "Wan VACE 控制影片",
"video_wan_vace_flf2v": "Wan VACE 首尾影格",
@@ -1435,179 +1424,179 @@
"video_wan_vace_outpainting": "Wan VACE 外延",
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
"wan2_1_fun_control": "Wan 2.1 ControlNet",
"wan2_1_fun_inp": "Wan 2.1 修補"
"wan2_1_fun_inp": "Wan 2.1 Inpainting"
},
"Video API": {
"api_hailuo_minimax_i2v": "MiniMax:影像轉影片",
"api_hailuo_minimax_i2v": "MiniMax Image to Video",
"api_hailuo_minimax_t2v": "MiniMax文字轉影片",
"api_kling_effects": "Kling影片特效",
"api_kling_flf": "KlingFLF2V",
"api_kling_i2v": "Kling:影像轉影片",
"api_luma_i2v": "Luma:影像轉影片",
"api_kling_flf": "Kling首尾影格",
"api_kling_i2v": "Kling Image to Video",
"api_luma_i2v": "Luma Image to Video",
"api_luma_t2v": "Luma文字轉影片",
"api_moonvalley_image_to_video": "Moonvalley影像轉影片",
"api_moonvalley_image_to_video": "Moonvalley圖片轉影片",
"api_moonvalley_text_to_video": "Moonvalley文字轉影片",
"api_pika_i2v": "Pika影像轉影片",
"api_pika_scene": "Pika Scenes:多影像轉影片",
"api_pixverse_i2v": "PixVerse影像轉影片",
"api_pixverse_t2v": "PixVerse:文字轉影片",
"api_pixverse_template_i2v": "PixVerse 範本:影像轉影片",
"api_pika_i2v": "Pika圖片轉影片",
"api_pika_scene": "Pika Scenes: Images to Video",
"api_pixverse_i2v": "PixVerse圖片轉影片",
"api_pixverse_t2v": "PixVerse Text to Video",
"api_pixverse_template_i2v": "PixVerse Template Effects: Image to Video",
"api_runway_first_last_frame": "Runway首尾影格轉影片",
"api_runway_gen3a_turbo_image_to_video": "RunwayGen3a Turbo 影像轉影片",
"api_runway_gen4_turo_image_to_video": "RunwayGen4 Turbo 影像轉影片",
"api_veo2_i2v": "Veo2:影像轉影片"
"api_runway_gen3a_turbo_image_to_video": "RunwayGen3a Turbo 圖片轉影片",
"api_runway_gen4_turo_image_to_video": "RunwayGen4 Turbo 圖片轉影片",
"api_veo2_i2v": "Veo2 Image to Video"
}
},
"templateDescription": {
"3D": {
"3d_hunyuan3d_image_to_model": "使用 Hunyuan3D 2.0 由單張影像生成 3D 模型。",
"3d_hunyuan3d_multiview_to_model": "使用 Hunyuan3D 2.0 MV多視角生成 3D 模型。",
"3d_hunyuan3d_multiview_to_model_turbo": "使用 Hunyuan3D 2.0 MV Turbo多視角生成 3D 模型。",
"stable_zero123_example": "使用 Stable Zero123 由單張影像生成 3D 視角。"
"3d_hunyuan3d_image_to_model": "使用 Hunyuan3D 2.0,單張圖片生成 3D 模型。",
"3d_hunyuan3d_multiview_to_model": "使用 Hunyuan3D 2.0 MV多視角生成 3D 模型。",
"3d_hunyuan3d_multiview_to_model_turbo": "使用 Hunyuan3D 2.0 MV Turbo多視角快速生成 3D 模型。",
"stable_zero123_example": "由單張圖片產生 3D 視角。"
},
"3D API": {
"api_rodin_image_to_model": "使用 Rodin AI單張照片生成細緻 3D 模型。",
"api_rodin_multiview_to_model": "使用 Rodin 多角度重建雕塑完整 3D 模型。",
"api_tripo_image_to_model": "使用 Tripo 引擎 2D 影像生成專業 3D 素材。",
"api_tripo_multiview_to_model": "使用 Tripo 進階掃描器由多角度生成 3D 模型。",
"api_tripo_text_to_model": "使用 Tripo 文字驅動建模,創作 3D 物件。"
"api_rodin_image_to_model": "使用 Rodin AI單張照片生成細緻 3D 模型。",
"api_rodin_multiview_to_model": "用 Rodin 多角度重建雕塑完整 3D 模型。",
"api_tripo_image_to_model": "使用 Tripo 引擎,將 2D 圖片生成專業 3D 素材。",
"api_tripo_multiview_to_model": "用 Tripo 進階掃描,從多角度建立 3D 模型。",
"api_tripo_text_to_model": "用 Tripo 文字驅動建模,創作 3D 物件。"
},
"Area Composition": {
"area_composition": "以區域控制構圖生成影像。",
"area_composition_square_area_for_subject": "以區域構圖確保主體位置一致生成影像。"
"area_composition": "以區域控制圖片構圖。",
"area_composition_square_area_for_subject": "建立主體一致擺放。"
},
"Audio": {
"audio_ace_step_1_m2m_editing": "使用 ACE-Step v1 M2M 編輯現有歌曲,變更風格與歌詞。",
"audio_ace_step_1_t2a_instrumentals": "使用 ACE-Step v1文字提示生純樂器音樂。",
"audio_ace_step_1_t2a_song": "使用 ACE-Step v1文字提示生成歌曲,支援多語言與風格自訂。",
"audio_stable_audio_example": "使用 Stable Audio文字提示生音訊。"
"audio_ace_step_1_m2m_editing": "使用 ACE-Step v1 M2M編輯現有歌曲,變更風格與歌詞。",
"audio_ace_step_1_t2a_instrumentals": "使用 ACE-Step v1,根據文字提示生純樂器音樂。",
"audio_ace_step_1_t2a_song": "使用 ACE-Step v1,根據文字提示產生含人聲歌曲,支援多語言與風格自訂。",
"audio_stable_audio_example": "使用 Stable Audio,根據文字提示生音訊。"
},
"Basics": {
"default": "從文字提示生成影像。",
"embedding_example": "用文反轉生成一致風格的影像。",
"gligen_textbox_example": "使用文字框精確放置物件生成影像。",
"image2image": "使用文字提示轉換現有影像。",
"inpaint_example": "無縫編輯影像的特定區域。",
"default": "根據文字描述產生圖片。",
"embedding_example": "使用文反轉技術以保持風格一致。",
"gligen_textbox_example": "指定物件的位置與大小。",
"image2image": "使用文字提示轉換現有圖片。",
"inpaint_example": "無縫編輯圖片的特定區域。",
"inpaint_model_outpainting": "將影像延伸至原始邊界之外。",
"lora": "使用 LoRA 模型生成特定風格或主題的影像。",
"lora_multiple": "結合多個 LoRA 模型生成影像。"
"lora": "用 LoRA 模型以獲得特殊風格或主題。",
"lora_multiple": "結合多個 LoRA 模型創造獨特效果。"
},
"ControlNet": {
"2_pass_pose_worship": "使用 ControlNet 以姿勢參考引導生成影像。",
"controlnet_example": "使用 ControlNet 以塗鴉參考影像引導生成影像。",
"depth_controlnet": "使用 ControlNet 以深度資訊引導生成影像。",
"depth_t2i_adapter": "使用 T2I adapter 以深度資訊引導生成影像。",
"mixing_controlnets": "結合多個 ControlNet 模型生成影像。"
"2_pass_pose_worship": "由姿勢參考產生圖片。",
"controlnet_example": "以參考圖控制圖片生成。",
"depth_controlnet": "產生深度感知圖片。",
"depth_t2i_adapter": "使用 T2I adapter 快速產生深度感知圖片。",
"mixing_controlnets": "結合多個 ControlNet 模型。"
},
"Flux": {
"flux_canny_model_example": "使用 Flux Canny 邊緣偵測引導生成影像。",
"flux_depth_lora_example": "使用 Flux LoRA 深度資訊引導生成影像。",
"flux_dev_checkpoint_example": "使用 Flux Dev fp8 量化版生成影像。適合顯存有限的裝置,只需一個模型檔案,但畫質略低於完整版。",
"flux_dev_full_text_to_image": "使用 Flux Dev 完整版生高品質影像。需較大顯存及多個模型檔案,但提示遵循度與畫質最佳。",
"flux_fill_inpaint_example": "使用 Flux 修補影像缺失區域。",
"flux_fill_outpaint_example": "使用 Flux 將影像延伸至邊界之外。",
"flux_canny_model_example": "從邊緣偵測產生圖片。",
"flux_depth_lora_example": "結合深度感知 LoRA 產生圖片。",
"flux_dev_checkpoint_example": "使用 Flux 開發模型產生圖片。",
"flux_dev_full_text_to_image": "使用 Flux Dev 完整版本產生高品質影像。需較大 VRAM 與多個模型檔案,但能提供最佳提示遵循能力與影像品質。",
"flux_fill_inpaint_example": "填補圖片缺失區域。",
"flux_fill_outpaint_example": "使用 Flux 外延技術延伸圖片。",
"flux_kontext_dev_basic": "使用 Flux Kontext 編輯影像,完整節點可見,適合學習工作流程。",
"flux_kontext_dev_grouped": "Flux Kontext 精簡版,節點分組,工作區更整潔。",
"flux_redux_model_example": "使用 Flux Redux 參考影像風格轉換生成影像。",
"flux_schnell": "使用 Flux Schnell fp8 量化版快速生成影像。適合低階硬體,只需 4 步即可生成影像。",
"flux_schnell_full_text_to_image": "使用 Flux Schnell 完整版快速生影像。採用 Apache2.0 授權,需 4 步即可維持良好畫質。"
"flux_redux_model_example": "從參考圖片轉移風格,指引 Flux 生成圖片。",
"flux_schnell": "使用 Flux Schnell 快速產生圖片。",
"flux_schnell_full_text_to_image": "使用 Flux Schnell 完整版快速生影像。採用 Apache2.0 授權,需 4 步即可生成並維持良好畫質。"
},
"Image": {
"hidream_e1_full": "使用 HiDream E1 - 專業自然語言影像編輯模型進行影像編輯。",
"hidream_i1_dev": "使用 HiDream I1 Dev - 平衡版28 步推理,適合中階硬體生成影像。",
"hidream_i1_fast": "使用 HiDream I1 Fast - 輕量版16 步推理,適合低階硬體快速預覽。",
"hidream_i1_full": "使用 HiDream I1 Full - 完整版50 步推理,產出最高品質影像。",
"image_chroma_text_to_image": "Chroma 由 flux 修改,架構有所變動。",
"image_cosmos_predict2_2B_t2i": "使用 Cosmos-Predict2 2B T2I,生成物理精確、高保真細節豐富的影像。",
"image_lotus_depth_v1_1": "在 ComfyUI 執行 Lotus Depth零樣本高效單目深度估測,細節保留佳。",
"image_omnigen2_image_edit": "利用 OmniGen2 進階影像編輯與文字渲染,透過自然語言指令編輯影像。",
"image_omnigen2_t2i": "使用 OmniGen2 統一 7B 多模態雙路架構,文字提示生高品質影像。",
"sd3_5_large_blur": "使用 SD 3.5 模糊參考影像引導生成影像。",
"sd3_5_large_canny_controlnet_example": "使用 SD 3.5 Canny ControlNet 邊緣偵測引導生成影像。",
"sd3_5_large_depth": "使用 SD 3.5 深度資訊引導生成影像。",
"sd3_5_simple_example": "使用 SD 3.5 生成影像。",
"sdxl_refiner_prompt_example": "使用精煉模型提升 SDXL 影像品質。",
"sdxl_revision_text_prompts": "使用 SDXL Revision 參考影像概念生成影像。",
"sdxl_revision_zero_positive": "使用 SDXL Revision 結合文字提示與參考影像生成影像。",
"sdxl_simple_example": "使用 SDXL 生高品質影像。",
"sdxlturbo_example": "使用 SDXL Turbo 一步生成影像。"
"hidream_e1_full": "使用 HiDream E1 編輯圖片。",
"hidream_i1_dev": "使用 HiDream I1 Dev 產生圖片。",
"hidream_i1_fast": "使用 HiDream I1 快速產生圖片。",
"hidream_i1_full": "使用 HiDream I1 產生圖片。",
"image_chroma_text_to_image": "Chroma 由 flux 修改而來,架構上有部分變動。",
"image_cosmos_predict2_2B_t2i": "使用 Cosmos-Predict2 2B T2I 產生物理精確、高保真細節豐富的影像。",
"image_lotus_depth_v1_1": "在 ComfyUI 執行 Lotus Depth進行零樣本高效率的單眼深度估測,保留高細節。",
"image_omnigen2_image_edit": "利用 OmniGen2 進階影像編輯能力與文字渲染支援,透過自然語言指令編輯影像。",
"image_omnigen2_t2i": "使用 OmniGen2 統一 7B 多模態模型與雙路架構,根據文字提示生高品質影像。",
"sd3_5_large_blur": "使用 SD 3.5 模糊參考圖產生圖片。",
"sd3_5_large_canny_controlnet_example": "使用邊緣偵測搭配 SD 3.5 指引圖片生成。",
"sd3_5_large_depth": "使用 SD 3.5 產生深度感知圖片。",
"sd3_5_simple_example": "使用 SD 3.5 產生圖片。",
"sdxl_refiner_prompt_example": "使用精煉器增強 SDXL 輸出。",
"sdxl_revision_text_prompts": "將參考圖概念轉移至 SDXL 生成流程。",
"sdxl_revision_zero_positive": "結合文字提示與參考圖指引 SDXL 生成圖片。",
"sdxl_simple_example": "使用 SDXL 生高品質圖片。",
"sdxlturbo_example": "使用 SDXL Turbo 一步產生圖片。"
},
"Image API": {
"api_bfl_flux_1_kontext_max_image": "使用 Flux.1 Kontext max 編輯影像。",
"api_bfl_flux_1_kontext_multiple_images_input": "輸入多張影像並用 Flux.1 Kontext 編輯。",
"api_bfl_flux_1_kontext_pro_image": "使用 Flux.1 Kontext pro 編輯影像。",
"api_bfl_flux_pro_t2i": "使用 FLUX.1 Pro 生成提示遵循度與視覺品質極佳的影像。",
"api_ideogram_v3_t2i": "使用 Ideogram V3 生成專業品質、提示對齊、寫實與文字渲染影像。",
"api_luma_photon_i2i": "結合影像與提示詞引導影像生成。",
"api_luma_photon_style_ref": "結合風格參考與精確控制生成影像。",
"api_openai_dall_e_2_inpaint": "使用 OpenAI Dall-E 2 API 進行影像修補編輯。",
"api_openai_dall_e_2_t2i": "使用 OpenAI Dall-E 2 API文字提示生成影像。",
"api_openai_dall_e_3_t2i": "使用 OpenAI Dall-E 3 API文字提示生成影像。",
"api_openai_image_1_i2i": "使用 OpenAI GPT Image 1 API 從輸入影像生成新影像。",
"api_openai_image_1_inpaint": "使用 OpenAI GPT Image 1 API 進行影像修補編輯。",
"api_openai_image_1_multi_inputs": "使用 OpenAI GPT Image 1 API 多重輸入生成影像。",
"api_openai_image_1_t2i": "使用 OpenAI GPT Image 1 API 從文字提示生成影像。",
"api_recraft_image_gen_with_color_control": "自訂色板與品牌視覺生成影像,使用 Recraft。",
"api_recraft_image_gen_with_style_control": "以視覺範例控制風格、對齊位置微調物件。儲存並分享風格,確保品牌一致性。",
"api_recraft_vector_gen": "使用 Recraft AI 向量生成器,從文字提示生成高品質向量影像。",
"api_runway_reference_to_image": "使用 Runway AI 根據參考風格與構圖生成新影像。",
"api_runway_text_to_image": "使用 Runway AI 模型文字提示生高品質影像。",
"api_stability_ai_i2i": "使用 Stability AI 進行高品質影像生成,適合專業編輯與風格轉換。",
"api_stability_ai_sd3_5_i2i": "生高品質、提示遵循度佳的影像。1 百萬像素,專業用途首選。",
"api_stability_ai_sd3_5_t2i": "生高品質、提示遵循度佳的影像。1 百萬像素,專業用途首選。",
"api_stability_ai_stable_image_ultra_t2i": "生高品質、提示遵循度佳的影像。1 百萬像素,專業用途首選。"
"api_bfl_flux_1_kontext_max_image": "使用 Flux.1 Kontext max 編輯圖片。",
"api_bfl_flux_1_kontext_multiple_images_input": "輸入多張圖片並用 Flux.1 Kontext 編輯。",
"api_bfl_flux_1_kontext_pro_image": "使用 Flux.1 Kontext pro 編輯圖片。",
"api_bfl_flux_pro_t2i": "使用 FLUX.1 [pro] 產生優異提示遵循視覺品質、細節與多樣化圖片。",
"api_ideogram_v3_t2i": "產生高品質圖片與提示對齊、寫實與文字渲染。可製作專業標誌、宣傳海報、登陸頁概念、產品攝影等。輕鬆打造複雜背景、精確光影與真實環境細節的空間構圖。",
"api_luma_photon_i2i": "結合圖片與提示指引圖片生成。",
"api_luma_photon_style_ref": "精確控制套用與混合風格參考。Luma Photon 捕捉每張參考圖的精髓,讓你結合不同視覺元素並維持專業品質。",
"api_openai_dall_e_2_inpaint": "使用 OpenAI Dall-E 2 API 進行圖片修補編輯。",
"api_openai_dall_e_2_t2i": "使用 OpenAI Dall-E 2 API,根據文字提示產生圖片。",
"api_openai_dall_e_3_t2i": "使用 OpenAI Dall-E 3 API,根據文字提示產生圖片。",
"api_openai_image_1_i2i": "使用 GPT Image 1 API 由圖片產生圖片。",
"api_openai_image_1_inpaint": "使用 GPT Image 1 API 修補圖片。",
"api_openai_image_1_multi_inputs": "使用 GPT Image 1 API 多重輸入產生圖片。",
"api_openai_image_1_t2i": "使用 GPT Image 1 API 根據文字描述產生圖片。",
"api_recraft_image_gen_with_color_control": "建立自訂調色盤以多圖共用,或為每張照片手動挑選顏色。配合品牌色彩,打造專屬視覺風格。",
"api_recraft_image_gen_with_style_control": "以視覺範例控制風格、對齊位置微調物件。儲存並分享風格,確保品牌一致性。",
"api_recraft_vector_gen": "從文字提示生成向量圖,Recraft AI 向量生成器可產出最佳品質的標誌、海報、圖示、廣告、橫幅與模型。以高品質 SVG 完善設計,數秒內為你的應用或網站創建品牌向量插圖。",
"api_runway_reference_to_image": "用 Runway AI根據參考風格與構圖產生新圖片。",
"api_runway_text_to_image": "使用 Runway AI 模型,根據文字提示生高品質圖片。",
"api_stability_ai_i2i": "使用 Stability AI 進行高品質圖片生成,適合專業編輯與風格轉換。",
"api_stability_ai_sd3_5_i2i": "生高品質、極佳提示遵循度的圖片。1 百萬像素解析度,專業用途首選。",
"api_stability_ai_sd3_5_t2i": "生高品質、極佳提示遵循度的圖片。1 百萬像素解析度,專業用途首選。",
"api_stability_ai_stable_image_ultra_t2i": "生高品質、極佳提示遵循度的圖片。1 百萬像素解析度,專業用途首選。"
},
"LLM API": {
"api_google_gemini": "體驗 Google Gemini 多模態 AI 推理能力。",
"api_openai_chat": "與 OpenAI 進階語言模型互動對話。"
"api_google_gemini": "體驗 Google Gemini 多模態 AI 推理能力。",
"api_openai_chat": "與 OpenAI 進階語言模型互動,展開智慧對話。"
},
"Upscaling": {
"esrgan_example": "使用 ESRGAN 模型放大影像並提升品質。",
"hiresfix_esrgan_workflow": "在中間生成步驟中結合 ESRGAN 模型放大影像。",
"hiresfix_latent_workflow": "在 latent 空間提升影像品質進行放大。",
"latent_upscale_different_prompt_model": "跨生成階段變更提示詞同時放大影像。"
"esrgan_example": "使用放大模型提升圖片品質。",
"hiresfix_esrgan_workflow": "在中間步驟使用放大模型。",
"hiresfix_latent_workflow": "在 latent 空間提升圖片品質。",
"latent_upscale_different_prompt_model": "跨多次處理放大並更換提示。"
},
"Video": {
"hunyuan_video_text_to_video": "使用 Hunyuan 模型由文字提示生成影片。",
"image_to_video": "由靜態影像生成影片。",
"image_to_video_wan": "使用 Wan 2.1 由影像生成影片。",
"ltxv_image_to_video": "靜態影像生成影片。",
"ltxv_text_to_video": "由文字提示生成影片。",
"mochi_text_to_video_example": "使用 Mochi 模型由文字提示生成影片。",
"text_to_video_wan": "使用 Wan 2.1 由文字提示生成影片。",
"txt_to_image_to_video": "先由文字生成影像,再生成影片。",
"video_cosmos_predict2_2B_video2world_480p_16fps": "使用 Cosmos-Predict2 2B Video2World 生物理精確、高保真且一致的影片模擬。",
"video_wan2_1_fun_camera_v1_1_14B": "使用 14B 完整版進階鏡頭控制生高品質影片。",
"video_wan2_1_fun_camera_v1_1_1_3B": "使用 Wan 2.1 Fun Camera 1.3B 生具電影感鏡頭運動的動態影片。",
"video_wan_vace_14B_ref2v": "根據參考影像生成風格一致的影片適合風格一致性需求。",
"video_wan_vace_14B_t2v": "將文字描述轉換為高品質影片。VACE-14B 支援 480p 與 720p。",
"video_wan_vace_14B_v2v": "使用 Wan VACE 控制輸入影片與參考影像生成影片。",
"video_wan_vace_flf2v": "自訂起始與結束影格,生成平滑影片過渡支援自關鍵影格序列。",
"video_wan_vace_inpainting": "編輯影片特定區域,同時保留周圍內容適合物件移除或替換。",
"video_wan_vace_outpainting": "使用 Wan VACE 外延生成擴展尺寸的影片。",
"wan2_1_flf2v_720_f16": "使用 Wan 2.1 FLF2V 控制首尾影格生影片。",
"wan2_1_fun_control": "使用 Wan 2.1 ControlNet 以姿勢、深度、邊緣引導生成影片。",
"wan2_1_fun_inp": "使用 Wan 2.1 由起始與結束影格生成影片(修補)。"
"hunyuan_video_text_to_video": "使用 Hunyuan 模型產生影片。",
"image_to_video": "將圖片轉換為動畫影片。",
"image_to_video_wan": "快速將圖片轉換為影片。",
"ltxv_image_to_video": "靜態圖片轉換為影片。",
"ltxv_text_to_video": "根據文字描述產生影片。",
"mochi_text_to_video_example": "使用 Mochi 模型產生影片。",
"text_to_video_wan": "快速將文字描述轉換為影片。",
"txt_to_image_to_video": "先由文字產生圖片,再轉換為影片。",
"video_cosmos_predict2_2B_video2world_480p_16fps": "使用 Cosmos-Predict2 2B Video2World 生物理精確、高保真且一致的影片模擬。",
"video_wan2_1_fun_camera_v1_1_14B": "使用完整 14B 模型,進階鏡頭控制生高品質影片。",
"video_wan2_1_fun_camera_v1_1_1_3B": "使用 Wan 2.1 Fun Camera 1.3B 模型,產生具電影感鏡頭運動的動態影片。",
"video_wan_vace_14B_ref2v": "根據參考圖片產生風格與內容一致的影片適合風格一致的影片生成。",
"video_wan_vace_14B_t2v": "將文字描述轉換為高品質影片。支援 480p 與 720p,採用 VACE-14B 模型。",
"video_wan_vace_14B_v2v": "透過控制輸入影片與參考圖片,使用 Wan VACE 產生影片。",
"video_wan_vace_flf2v": "自訂起始與結束畫面,產生平滑影片過渡支援自定義關鍵影格序列。",
"video_wan_vace_inpainting": "編輯影片特定區域,同時保留周圍內容適合物件移除或替換。",
"video_wan_vace_outpainting": "使用 Wan VACE 外延功能,擴展影片尺寸產生延伸影片。",
"wan2_1_flf2v_720_f16": "透過控制首尾影格生影片。",
"wan2_1_fun_control": "以姿勢、深度、邊緣等控制影片生成。",
"wan2_1_fun_inp": "從起始與結束影格產生影片。"
},
"Video API": {
"api_hailuo_minimax_i2v": "MiniMax 由影像與文字生精緻影片,整合 CGI 效果。",
"api_hailuo_minimax_t2v": "MiniMax 由文字提示直接生成高品質影片,支援專業 CGI 與多樣風格敘事。",
"api_kling_effects": "使用 Kling 將視覺特效套用於影像生成動態影片。",
"api_kling_flf": "控制首尾影格生成影片。",
"api_kling_i2v": "使用 Kling 生成動作、表情、鏡頭運動提示遵循度極佳的影片。",
"api_luma_i2v": "將靜態影像即時轉換為高品質動畫。",
"api_luma_t2v": "只需簡單提示即可生高品質影片。",
"api_moonvalley_image_to_video": "由影像生成電影級 1080p 影片,模型僅訓練於授權資料。",
"api_moonvalley_text_to_video": "文字提示生電影級 1080p 影片,模型僅訓練於授權資料。",
"api_pika_i2v": "使用 Pika AI 將單張靜態影像生成平滑動畫影片。",
"api_pika_scene": "使用 Pika Scenes 結合多張輸入影像生成影片。",
"api_pixverse_i2v": "使用 PixVerse 將靜態影像生成具動態與特效的影片。",
"api_pixverse_t2v": "使用 PixVerse 生成提示解讀精準、動態效果出色的影片。",
"api_pixverse_template_i2v": "使用 PixVerse 範本將靜態影像生成具動態與特效的影片。",
"api_runway_first_last_frame": "使用 Runway 精準控制兩個關鍵影格間平滑影片過渡。",
"api_runway_gen3a_turbo_image_to_video": "使用 Runway Gen3a Turbo 將靜態影像生成電影影片。",
"api_runway_gen4_turo_image_to_video": "使用 Runway Gen4 Turbo 由影像生成動態影片。",
"api_veo2_i2v": "使用 Google Veo2 API 由影像生成影片。"
"api_hailuo_minimax_i2v": "結合圖片與文字生精緻影片,支援 CGI 整合與流行 AI 擁抱等特效。多種影片風格與主題任你選擇,滿足創意需求。",
"api_hailuo_minimax_t2v": "直接從文字提示產生高品質影片。探索 MiniMax 進階 AI打造多元視覺敘事專業 CGI 效果與風格元素,讓描述栩栩如生。",
"api_kling_effects": "使用 Kling 將視覺特效套用於圖片,產生動態影片。",
"api_kling_flf": "透過控制首尾畫面產生影片。",
"api_kling_i2v": "產生動作、表情、鏡頭移動等提示遵循度的影片。支援複雜連續動作提示,讓你成為導演。",
"api_luma_i2v": "將靜態圖片即時轉換為高品質動畫。",
"api_luma_t2v": "只需簡單提示即可生高品質影片。",
"api_moonvalley_image_to_video": "透過專為授權資料訓練的模型,使用圖片產生電影級 1080p 影片。",
"api_moonvalley_text_to_video": "透過專為授權資料訓練的模型,根據文字提示生電影級 1080p 影片。",
"api_pika_i2v": "使用 Pika AI將單張靜態圖片轉為流暢動畫影片。",
"api_pika_scene": "將多張圖片作為素材,產生融合所有圖片的影片。",
"api_pixverse_i2v": "使用 PixVerse將靜態圖片轉為具動態與特效的影片。",
"api_pixverse_t2v": "根據提示精確解讀並產生動態出色的影片。",
"api_pixverse_template_i2v": "將靜態圖片轉換為具動態與特效的影片。",
"api_runway_first_last_frame": "用 Runway 精準控制,於兩個關鍵影格間產生平滑影片過渡。",
"api_runway_gen3a_turbo_image_to_video": "使用 Runway Gen3a Turbo將靜態圖片轉為電影影片。",
"api_runway_gen4_turo_image_to_video": "使用 Runway Gen4 Turbo,將圖片轉為動態影片。",
"api_veo2_i2v": "使用 Google Veo2 API 由圖片產生影片。"
}
},
"title": "從範本開始"
@@ -1616,24 +1605,24 @@
"cannotCreateSubgraph": "無法建立子圖",
"couldNotDetermineFileType": "無法判斷檔案類型",
"dropFileError": "無法處理拖放項目:{error}",
"emptyCanvas": "空白畫布",
"emptyCanvas": "畫布為空",
"errorCopyImage": "複製圖片時發生錯誤:{error}",
"errorLoadingModel": "載入模型時發生錯誤",
"errorSaveSetting": "儲存設定 {id} 時發生錯誤:{err}",
"failedToAccessBillingPortal": "存取帳單入口失敗:{error}",
"failedToAccessBillingPortal": "無法存取帳單入口",
"failedToApplyTexture": "套用材質失敗",
"failedToConvertToSubgraph": "轉換項目為子圖失敗",
"failedToCreateCustomer": "建立客戶失敗:{error}",
"failedToDownloadFile": "檔案下載失敗",
"failedToExportModel": "模型匯出為 {format} 失敗",
"failedToExportModel": "無法將模型匯出為 {format}",
"failedToFetchBalance": "取得餘額失敗:{error}",
"failedToFetchLogs": "取得伺服器日誌失敗",
"failedToFetchLogs": "無法取得伺服器日誌",
"failedToInitializeLoad3dViewer": "初始化 3D 檢視器失敗",
"failedToInitiateCreditPurchase": "啟動點數購買失敗:{error}",
"failedToPurchaseCredits": "購買點數失敗:{error}",
"fileLoadError": "無法在 {fileName} 中找到工作流程",
"fileUploadFailed": "檔案上傳失敗",
"interrupted": "執行已中斷",
"interrupted": "執行已中斷",
"migrateToLitegraphReroute": "重導節點將於未來版本移除。點擊以遷移至 litegraph 原生重導。",
"no3dScene": "沒有 3D 場景可套用材質",
"no3dSceneToExport": "沒有 3D 場景可匯出",
@@ -1670,11 +1659,11 @@
"invalidEmail": "無效的電子郵件地址",
"length": "必須為 {length} 個字元",
"maxLength": "不得超過 {length} 個字元",
"minLength": "至少需 {length} 個字元",
"minLength": "至少需 {length} 個字元",
"password": {
"lowercase": "必須包含至少一個小寫字母",
"match": "密碼必須相符",
"minLength": "必須 8 32 個字元",
"minLength": "必須介於 8 32 個字元之間",
"number": "必須包含至少一個數字",
"requirements": "密碼要求",
"special": "必須包含至少一個特殊字元",
@@ -1687,7 +1676,7 @@
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要版本 {requiredVersion} 或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},

View File

@@ -277,10 +277,6 @@
"label": "切换节点库侧边栏",
"tooltip": "节点库"
},
"Workspace_ToggleSidebarTab_output-explorer": {
"label": "切换输出资源管理器侧边栏",
"tooltip": "输出资源管理器"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切换执行队列侧边栏",
"tooltip": "执行队列"

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +0,0 @@
/**
* Spatial bounds calculations for node layouts
*/
export interface SpatialBounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
}
export interface PositionedNode {
pos: ArrayLike<number>
size: ArrayLike<number>
}
/**
* Calculate the spatial bounding box of positioned nodes
*/
export function calculateNodeBounds(
nodes: PositionedNode[]
): SpatialBounds | null {
if (!nodes || nodes.length === 0) {
return null
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
const x = node.pos[0]
const y = node.pos[1]
const width = node.size[0]
const height = node.size[1]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + width)
maxY = Math.max(maxY, y + height)
}
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY
}
}
/**
* Enforce minimum viewport dimensions for better visualization
*/
export function enforceMinimumBounds(
bounds: SpatialBounds,
minWidth: number = 2500,
minHeight: number = 2000
): SpatialBounds {
let { minX, minY, maxX, maxY, width, height } = bounds
if (width < minWidth) {
const padding = (minWidth - width) / 2
minX -= padding
maxX += padding
width = minWidth
}
if (height < minHeight) {
const padding = (minHeight - height) / 2
minY -= padding
maxY += padding
height = minHeight
}
return { minX, minY, maxX, maxY, width, height }
}
/**
* Calculate the scale factor to fit bounds within a viewport
*/
export function calculateMinimapScale(
bounds: SpatialBounds,
viewportWidth: number,
viewportHeight: number,
padding: number = 0.9
): number {
if (bounds.width === 0 || bounds.height === 0) {
return 1
}
const scaleX = viewportWidth / bounds.width
const scaleY = viewportHeight / bounds.height
return Math.min(scaleX, scaleY) * padding
}

View File

@@ -1,251 +0,0 @@
import { useRafFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { MinimapCanvas, MinimapSettingsKey } from '../types'
import { useMinimapGraph } from './useMinimapGraph'
import { useMinimapInteraction } from './useMinimapInteraction'
import { useMinimapRenderer } from './useMinimapRenderer'
import { useMinimapSettings } from './useMinimapSettings'
import { useMinimapViewport } from './useMinimapViewport'
export function useMinimap() {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<HTMLElement | null>(null)
const visible = ref(true)
const initialized = ref(false)
const width = 250
const height = 200
const canvas = computed(() => canvasStore.canvas as MinimapCanvas | null)
const graph = computed(() => {
// If we're in a subgraph, use that; otherwise use the canvas graph
const activeSubgraph = workflowStore.activeSubgraph
return (activeSubgraph || canvas.value?.graph) as LGraph | null
})
// Settings
const settings = useMinimapSettings()
const {
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
containerStyles,
panelStyles
} = settings
const updateOption = async (key: MinimapSettingsKey, value: boolean) => {
await settingStore.set(key, value)
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
}
// Viewport management
const viewport = useMinimapViewport(canvas, graph, width, height)
// Interaction handling
const interaction = useMinimapInteraction(
containerRef,
viewport.bounds,
viewport.scale,
width,
height,
viewport.centerViewOn,
canvas
)
// Graph event management
const graphManager = useMinimapGraph(graph, () => {
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
})
// Rendering
const renderer = useMinimapRenderer(
canvasRef,
graph,
viewport.bounds,
viewport.scale,
graphManager.updateFlags,
settings,
width,
height
)
// RAF loop for continuous updates
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
useRafFn(
async () => {
if (visible.value) {
const hasChanges = await graphManager.checkForChanges()
if (hasChanges) {
renderer.updateMinimap(
viewport.updateBounds,
viewport.updateViewport
)
}
}
},
{ immediate: false }
)
const init = async () => {
if (initialized.value) return
visible.value = settingStore.get('Comfy.Minimap.Visible')
if (canvas.value && graph.value) {
graphManager.init()
if (containerRef.value) {
interaction.updateContainerRect()
}
viewport.updateCanvasDimensions()
window.addEventListener('resize', interaction.updateContainerRect)
window.addEventListener('scroll', interaction.updateContainerRect)
window.addEventListener('resize', viewport.updateCanvasDimensions)
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
viewport.updateViewport()
if (visible.value) {
resumeChangeDetection()
viewport.startViewportSync()
}
initialized.value = true
}
}
const destroy = () => {
pauseChangeDetection()
viewport.stopViewportSync()
graphManager.destroy()
window.removeEventListener('resize', interaction.updateContainerRect)
window.removeEventListener('scroll', interaction.updateContainerRect)
window.removeEventListener('resize', viewport.updateCanvasDimensions)
initialized.value = false
}
watch(
canvas,
async (newCanvas, oldCanvas) => {
if (oldCanvas) {
graphManager.cleanupEventListeners()
pauseChangeDetection()
viewport.stopViewportSync()
graphManager.destroy()
window.removeEventListener('resize', interaction.updateContainerRect)
window.removeEventListener('scroll', interaction.updateContainerRect)
window.removeEventListener('resize', viewport.updateCanvasDimensions)
}
if (newCanvas && !initialized.value) {
await init()
}
},
{ immediate: true, flush: 'post' }
)
// Watch for graph changes (e.g., when navigating to/from subgraphs)
watch(graph, (newGraph, oldGraph) => {
if (newGraph && newGraph !== oldGraph) {
graphManager.cleanupEventListeners(oldGraph || undefined)
graphManager.setupEventListeners()
renderer.forceFullRedraw()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
}
})
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
interaction.updateContainerRect()
}
viewport.updateCanvasDimensions()
renderer.forceFullRedraw()
await nextTick()
await nextTick()
renderer.updateMinimap(viewport.updateBounds, viewport.updateViewport)
viewport.updateViewport()
resumeChangeDetection()
viewport.startViewportSync()
} else {
pauseChangeDetection()
viewport.stopViewportSync()
}
})
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)
}
const setMinimapRef = (ref: HTMLElement | null) => {
minimapRef.value = ref
}
// Dynamic viewport styles based on actual viewport transform
const viewportStyles = computed(() => {
const transform = viewport.viewportTransform.value
return {
transform: `translate(${transform.x}px, ${transform.y}px)`,
width: `${transform.width}px`,
height: `${transform.height}px`,
border: `2px solid ${settings.isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
backgroundColor: `rgba(255, 255, 255, 0.2)`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
pointerEvents: 'none' as const
}
})
return {
visible: computed(() => visible.value),
initialized: computed(() => initialized.value),
containerRef,
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
renderMinimap: renderer.renderMinimap,
handlePointerDown: interaction.handlePointerDown,
handlePointerMove: interaction.handlePointerMove,
handlePointerUp: interaction.handlePointerUp,
handleWheel: interaction.handleWheel,
setMinimapRef,
updateOption
}
}

View File

@@ -1,166 +0,0 @@
import { useThrottleFn } from '@vueuse/core'
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import type { UpdateFlags } from '../types'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
}
export function useMinimapGraph(
graph: Ref<LGraph | null>,
onGraphChanged: () => void
) {
const nodeStatesCache = new Map<NodeId, string>()
const linksCache = ref<string>('')
const lastNodeCount = ref(0)
const updateFlags = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChangedThrottled = useThrottleFn(() => {
onGraphChanged()
}, 500)
const setupEventListeners = () => {
const g = graph.value
if (!g) return
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
originalCallbacksMap.set(g.id, originalCallbacks)
g.onNodeAdded = function (node: LGraphNode) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChangedThrottled()
}
g.onNodeRemoved = function (node: LGraphNode) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChangedThrottled()
}
g.onConnectionChange = function (node: LGraphNode) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
const g = oldGraph || graph.value
if (!g) return
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
'Attempted to cleanup event listeners for graph that was never set up'
)
return
}
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
originalCallbacksMap.delete(g.id)
}
const checkForChangesInternal = () => {
const g = graph.value
if (!g) return false
let structureChanged = false
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
if (nodeStatesCache.get(key) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
structureChanged = true
}
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
}
if (connectionChanged) {
updateFlags.value.connections = true
}
return structureChanged || positionChanged || connectionChanged
}
const init = () => {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChangedThrottled)
}
const destroy = () => {
cleanupEventListeners()
api.removeEventListener('graphChanged', handleGraphChangedThrottled)
nodeStatesCache.clear()
}
const clearCache = () => {
nodeStatesCache.clear()
linksCache.value = ''
lastNodeCount.value = 0
}
return {
updateFlags,
setupEventListeners,
cleanupEventListeners,
checkForChanges: checkForChangesInternal,
init,
destroy,
clearCache
}
}

View File

@@ -1,107 +0,0 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { MinimapCanvas } from '../types'
export function useMinimapInteraction(
containerRef: Ref<HTMLDivElement | undefined>,
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
scale: Ref<number>,
width: number,
height: number,
centerViewOn: (worldX: number, worldY: number) => void,
canvas: Ref<MinimapCanvas | null>
) {
const isDragging = ref(false)
const containerRect = ref({
left: 0,
top: 0,
width: width,
height: height
})
const updateContainerRect = () => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
containerRect.value = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
}
}
const handlePointerDown = (e: PointerEvent) => {
isDragging.value = true
updateContainerRect()
handlePointerMove(e)
}
const handlePointerMove = (e: PointerEvent) => {
if (!isDragging.value || !canvas.value) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
centerViewOn(worldX, worldY)
}
const handlePointerUp = () => {
isDragging.value = false
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const c = canvas.value
if (!c) return
if (
containerRect.value.left === 0 &&
containerRect.value.top === 0 &&
containerRef.value
) {
updateContainerRect()
}
const ds = c.ds
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = ds.scale * delta
const MIN_SCALE = 0.1
const MAX_SCALE = 10
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
ds.scale = newScale
centerViewOn(worldX, worldY)
}
return {
isDragging,
containerRect,
updateContainerRect,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel
}
}

View File

@@ -1,110 +0,0 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { renderMinimapToCanvas } from '../minimapCanvasRenderer'
import type { UpdateFlags } from '../types'
export function useMinimapRenderer(
canvasRef: Ref<HTMLCanvasElement | undefined>,
graph: Ref<LGraph | null>,
bounds: Ref<{ minX: number; minY: number; width: number; height: number }>,
scale: Ref<number>,
updateFlags: Ref<UpdateFlags>,
settings: {
nodeColors: Ref<boolean>
showLinks: Ref<boolean>
showGroups: Ref<boolean>
renderBypass: Ref<boolean>
renderError: Ref<boolean>
},
width: number,
height: number
) {
const needsFullRedraw = ref(true)
const needsBoundsUpdate = ref(true)
const renderMinimap = () => {
const g = graph.value
if (!canvasRef.value || !g) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!g._nodes || g._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}
const needsRedraw =
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
if (needsRedraw) {
renderMinimapToCanvas(canvasRef.value, g, {
bounds: bounds.value,
scale: scale.value,
settings: {
nodeColors: settings.nodeColors.value,
showLinks: settings.showLinks.value,
showGroups: settings.showGroups.value,
renderBypass: settings.renderBypass.value,
renderError: settings.renderError.value
},
width,
height
})
needsFullRedraw.value = false
updateFlags.value.nodes = false
updateFlags.value.connections = false
}
}
const updateMinimap = (
updateBounds: () => void,
updateViewport: () => void
) => {
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
updateBounds()
needsBoundsUpdate.value = false
updateFlags.value.bounds = false
needsFullRedraw.value = true
// When bounds change, we need to update the viewport position
updateFlags.value.viewport = true
}
if (
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
) {
renderMinimap()
}
// Update viewport if needed (e.g., after bounds change)
if (updateFlags.value.viewport) {
updateViewport()
updateFlags.value.viewport = false
}
}
const forceFullRedraw = () => {
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
}
return {
needsFullRedraw,
needsBoundsUpdate,
renderMinimap,
updateMinimap,
forceFullRedraw
}
}

View File

@@ -1,62 +0,0 @@
import { computed } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
/**
* Composable for minimap configuration options that are set by the user in the
* settings. Provides reactive computed properties for the settings.
*/
export function useMinimapSettings() {
const settingStore = useSettingStore()
const colorPaletteStore = useColorPaletteStore()
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const width = 250
const height = 200
// Theme-aware colors
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
return {
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
containerStyles,
panelStyles,
isLightTheme
}
}

View File

@@ -1,145 +0,0 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types'
export function useMinimapViewport(
canvas: Ref<MinimapCanvas | null>,
graph: Ref<LGraph | null>,
width: number,
height: number
) {
const bounds = ref<MinimapBounds>({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
const scale = ref(1)
const viewportTransform = ref<ViewportTransform>({
x: 0,
y: 0,
width: 0,
height: 0
})
const canvasDimensions = ref({
width: 0,
height: 0
})
const updateCanvasDimensions = () => {
const c = canvas.value
if (!c) return
const canvasEl = c.canvas
const dpr = window.devicePixelRatio || 1
canvasDimensions.value = {
width: canvasEl.clientWidth || canvasEl.width / dpr,
height: canvasEl.clientHeight || canvasEl.height / dpr
}
}
const calculateGraphBounds = (): MinimapBounds => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
const bounds = calculateNodeBounds(g._nodes)
if (!bounds) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
return enforceMinimumBounds(bounds)
}
const calculateScale = () => {
return calculateMinimapScale(bounds.value, width, height)
}
const updateViewport = () => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
const worldX = -ds.offset[0]
const worldY = -ds.offset[1]
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
}
const updateBounds = () => {
bounds.value = calculateGraphBounds()
scale.value = calculateScale()
}
const centerViewOn = (worldX: number, worldY: number) => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
ds.offset[0] = -(worldX - viewportWidth / 2)
ds.offset[1] = -(worldY - viewportHeight / 2)
c.setDirty(true, true)
}
const { startSync: startViewportSync, stopSync: stopViewportSync } =
useCanvasTransformSync(updateViewport, { autoStart: false })
return {
bounds: computed(() => bounds.value),
scale: computed(() => scale.value),
viewportTransform: computed(() => viewportTransform.value),
canvasDimensions: computed(() => canvasDimensions.value),
updateCanvasDimensions,
updateViewport,
updateBounds,
centerViewOn,
startViewportSync,
stopViewportSync
}
}

View File

@@ -1,238 +0,0 @@
import { LGraph, LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import type { MinimapRenderContext } from './types'
/**
* Get theme-aware colors for the minimap
*/
function getMinimapColors() {
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return {
nodeColor: isLightTheme ? '#3DA8E099' : '#0B8CE999',
nodeColorDefault: isLightTheme ? '#D9D9D9' : '#353535',
linkColor: isLightTheme ? '#616161' : '#B3B3B3',
slotColor: isLightTheme ? '#616161' : '#B3B3B3',
groupColor: isLightTheme ? '#A2D3EC' : '#1F547A',
groupColorDefault: isLightTheme ? '#283640' : '#B3C1CB',
bypassColor: isLightTheme ? '#DBDBDB' : '#4B184B',
errorColor: '#FF0000',
isLightTheme
}
}
/**
* Render groups on the minimap
*/
function renderGroups(
ctx: CanvasRenderingContext2D,
graph: LGraph,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._groups || graph._groups.length === 0) return
for (const group of graph._groups) {
const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = group.size[0] * context.scale
const h = group.size[1] * context.scale
let color = colors.groupColor
if (context.settings.nodeColors) {
color = group.color ?? colors.groupColorDefault
if (colors.isLightTheme) {
color = adjustColor(color, { opacity: 0.5 })
}
}
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
}
/**
* Render nodes on the minimap with performance optimizations
*/
function renderNodes(
ctx: CanvasRenderingContext2D,
graph: LGraph,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph._nodes || graph._nodes.length === 0) return
// Group nodes by color for batch rendering
const nodesByColor = new Map<
string,
Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }>
>()
for (const node of graph._nodes) {
const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
const w = node.size[0] * context.scale
const h = node.size[1] * context.scale
let color = colors.nodeColor
if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) {
color = colors.bypassColor
} else if (context.settings.nodeColors) {
color = colors.nodeColorDefault
if (node.bgcolor) {
color = colors.isLightTheme
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
if (!nodesByColor.has(color)) {
nodesByColor.set(color, [])
}
nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors })
}
// Batch render nodes by color
for (const [color, nodes] of nodesByColor) {
ctx.fillStyle = color
for (const node of nodes) {
ctx.fillRect(node.x, node.y, node.w, node.h)
}
}
// Render error outlines if needed
if (context.settings.renderError) {
ctx.strokeStyle = colors.errorColor
ctx.lineWidth = 0.3
for (const nodes of nodesByColor.values()) {
for (const node of nodes) {
if (node.hasErrors) {
ctx.strokeRect(node.x, node.y, node.w, node.h)
}
}
}
}
}
/**
* Render connections on the minimap
*/
function renderConnections(
ctx: CanvasRenderingContext2D,
graph: LGraph,
offsetX: number,
offsetY: number,
context: MinimapRenderContext,
colors: ReturnType<typeof getMinimapColors>
) {
if (!graph || !graph._nodes) return
ctx.strokeStyle = colors.linkColor
ctx.lineWidth = 0.3
const slotRadius = Math.max(context.scale, 0.5)
const connections: Array<{
x1: number
y1: number
x2: number
y2: number
}> = []
for (const node of graph._nodes) {
if (!node.outputs) continue
const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX
const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) continue
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX
const y2 =
(targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY
const outputX = x1 + node.size[0] * context.scale
const outputY = y1 + node.size[1] * context.scale * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * context.scale * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
}
// Render connection slots on top
ctx.fillStyle = colors.slotColor
for (const conn of connections) {
// Output slot
ctx.beginPath()
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
ctx.fill()
// Input slot
ctx.beginPath()
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
ctx.fill()
}
}
/**
* Render a graph to a minimap canvas
*/
export function renderMinimapToCanvas(
canvas: HTMLCanvasElement,
graph: LGraph,
context: MinimapRenderContext
) {
const ctx = canvas.getContext('2d')
if (!ctx) return
// Clear canvas
ctx.clearRect(0, 0, context.width, context.height)
// Fast path for empty graph
if (!graph || !graph._nodes || graph._nodes.length === 0) {
return
}
const colors = getMinimapColors()
const offsetX = (context.width - context.bounds.width * context.scale) / 2
const offsetY = (context.height - context.bounds.height * context.scale) / 2
// Render in correct order: groups -> links -> nodes
if (context.settings.showGroups) {
renderGroups(ctx, graph, offsetX, offsetY, context, colors)
}
if (context.settings.showLinks) {
renderConnections(ctx, graph, offsetX, offsetY, context, colors)
}
renderNodes(ctx, graph, offsetX, offsetY, context, colors)
}

View File

@@ -1,68 +0,0 @@
/**
* Minimap-specific type definitions
*/
import type { LGraph } from '@/lib/litegraph/src/litegraph'
/**
* Minimal interface for what the minimap needs from the canvas
*/
export interface MinimapCanvas {
canvas: HTMLCanvasElement
ds: {
scale: number
offset: [number, number]
}
graph?: LGraph | null
setDirty: (fg?: boolean, bg?: boolean) => void
}
export interface MinimapRenderContext {
bounds: {
minX: number
minY: number
width: number
height: number
}
scale: number
settings: MinimapRenderSettings
width: number
height: number
}
export interface MinimapRenderSettings {
nodeColors: boolean
showLinks: boolean
showGroups: boolean
renderBypass: boolean
renderError: boolean
}
export interface MinimapBounds {
minX: number
minY: number
maxX: number
maxY: number
width: number
height: number
}
export interface ViewportTransform {
x: number
y: number
width: number
height: number
}
export interface UpdateFlags {
bounds: boolean
nodes: boolean
connections: boolean
viewport: boolean
}
export type MinimapSettingsKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'

View File

@@ -1,64 +0,0 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { renderMinimapToCanvas } from '../extensions/minimap/minimapCanvasRenderer'
/**
* Create a thumbnail of the current canvas's active graph.
* Used by workflow thumbnail generation.
*/
export function createGraphThumbnail(): string | null {
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const graph = workflowStore.activeSubgraph || canvasStore.canvas?.graph
if (!graph || !graph._nodes || graph._nodes.length === 0) {
return null
}
const width = 250
const height = 200
// Calculate bounds using spatial calculator
const bounds = calculateNodeBounds(graph._nodes)
if (!bounds) {
return null
}
const scale = calculateMinimapScale(bounds, width, height)
// Create detached canvas
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
// Render the minimap
renderMinimapToCanvas(canvas, graph as LGraph, {
bounds,
scale,
settings: {
nodeColors: true,
showLinks: false,
showGroups: true,
renderBypass: false,
renderError: false
},
width,
height
})
const dataUrl = canvas.toDataURL()
// Explicit cleanup (optional but good practice)
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.clearRect(0, 0, width, height)
}
return dataUrl
}

View File

@@ -699,19 +699,6 @@ export class ComfyApi extends EventTarget {
return await res.json()
}
/**
* Gets a list of output folder items (eg ['output', 'output/images', 'output/videos', ...])
* @param {string} folder The folder to list items from, such as 'output'
* @returns The list of output folder items within the specified folder
*/
async getOutputFolderItems(folder: string) {
const res = await this.fetchApi(`/output${folder}`)
if (res.status === 404) {
return []
}
return await res.json()
}
/**
* Gets the metadata for a model
* @param {string} folder The folder containing the model

View File

@@ -0,0 +1,462 @@
# GraphMutationService Design and Implementation
## Overview
GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified and validated API for graph mutations, serving as the single entry point for all graph modification operations.
## Project Background
### Current System Analysis
ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including:
1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`)
- Core graph management class
- Provides basic operations like `add()`, `remove()`
- Supports `beforeChange()`/`afterChange()` transaction mechanism
2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`)
- Node class containing position, connections, and other properties
- Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()`
3. **ChangeTracker** (`src/scripts/changeTracker.ts`)
- Existing undo/redo system
- Snapshot-based history tracking
- Supports up to 50 history states
**Primary Goals:**
- Single entry point for all graph modifications
- Built-in validation and error handling
- Transaction support for atomic operations
- Natural undo/redo through existing ChangeTracker
- Clean architecture for future extensibility
### Interface-Based Architecture
The GraphMutationService follows an **interface-based design pattern** with singleton state management:
- **IGraphMutationService Interface**: Defines the complete contract for all graph operations
- **GraphMutationService Class**: Implements the interface with LiteGraph integration
- **Singleton State**: Shared clipboard and transaction state across components
```typescript
interface IGraphMutationService {
// Node operations
addNode(params: AddNodeParams): Promise<NodeId>
removeNode(nodeId: NodeId): Promise<void>
updateNodeProperty(nodeId: NodeId, property: string, value: any): Promise<void>
// ... 50+ total operations
// Transaction support
transaction<T>(fn: () => Promise<T>): Promise<T>
// Undo/Redo
undo(): Promise<void>
redo(): Promise<void>
}
class GraphMutationService implements IGraphMutationService {
// Implementation details...
}
```
The `useGraphMutationService()` hook returns the interface type, maintaining backward compatibility while enabling new architectural benefits.
### Core Components
```typescript
// Interface Definition
interface IGraphMutationService {
// Complete method signatures for all 50+ operations
// Organized by functional categories
}
// Implementation Class
class GraphMutationService implements IGraphMutationService {
private workflowStore = useWorkflowStore()
private transactionDepth = 0
private clipboard: ClipboardData | null = null
// All interface methods implemented
}
// Singleton Hook
export const useGraphMutationService = (): IGraphMutationService => {
if (!graphMutationServiceInstance) {
graphMutationServiceInstance = new GraphMutationService()
}
return graphMutationServiceInstance
}
```
### Validation Framework
Each operation includes validation to ensure data integrity:
```typescript
interface ValidationResult {
isValid: boolean
errors: ValidationError[]
warnings: ValidationWarning[]
}
```
## Implemented Operations
### Node Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addNode` | Add a new node to the graph | src/scripts/app.ts:1589-1593, src/lib/litegraph/src/LGraph.ts:823-893 |
| `removeNode` | Remove a node from the graph | src/lib/litegraph/src/LGraph.ts:899-986 |
| `updateNodeProperty` | Update a custom node property | src/lib/litegraph/src/LGraphNode.ts:974-984 |
| `updateNodeTitle` | Change the node's title | src/services/litegraphService.ts:369 (direct assignment) |
| `changeNodeMode` | Change execution mode (ALWAYS/ON_TRIGGER/NEVER/ON_REQUEST/ON_EVENT) | src/lib/litegraph/src/LGraphNode.ts:1295-1320 |
| `cloneNode` | Create a copy of a node | src/lib/litegraph/src/LGraphNode.ts:923-950 |
### Connection Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `connect` | Create a connection between nodes | src/lib/litegraph/src/LGraphNode.ts:2641-2743, :2753-2870 (connectSlots) |
| `disconnect` | Generic disconnect (auto-detects input/output) | Wrapper combining disconnectInput/disconnectOutput |
| `disconnectInput` | Disconnect a specific input slot | src/lib/litegraph/src/LGraphNode.ts:3050-3144 |
| `disconnectOutput` | Disconnect all connections from an output slot | src/lib/litegraph/src/LGraphNode.ts:2931-3043 |
| `disconnectOutputTo` | Disconnect output to a specific target node | src/lib/litegraph/src/LGraphNode.ts:2931 (with target_node param) |
| `disconnectLink` | Disconnect by link ID | src/lib/litegraph/src/LGraph.ts:1433-1441 |
### Group Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `createGroup` | Create a new node group | src/composables/useCoreCommands.ts:425-430, src/lib/litegraph/src/LGraph.ts:823-848 (add method for groups) |
| `removeGroup` | Delete a group (nodes remain) | src/lib/litegraph/src/LGraph.ts:899-913 (remove method for groups) |
| `updateGroupTitle` | Change group title | Direct assignment (group.title = value) |
| `moveGroup` | Move group and its contents | src/lib/litegraph/src/LGraphGroup.ts:230-240 |
| `addNodesToGroup` | Add nodes to group and auto-resize | src/lib/litegraph/src/LGraphGroup.ts:303-306 |
| `recomputeGroupNodes` | Recalculate which nodes are in group | src/lib/litegraph/src/LGraphGroup.ts:247-273 |
### Subgraph Node Slot Operations (4 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addSubgraphNodeInput` | Add an input slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1606-1627 |
| `addSubgraphNodeOutput` | Add an output slot to a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1551-1571 |
| `removeSubgraphNodeInput` | Remove an input slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1632-1652 |
| `removeSubgraphNodeOutput` | Remove an output slot from a subgraph node | src/lib/litegraph/src/LGraphNode.ts:1576-1599 |
### Batch Operations (3 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addNodes` | Add multiple nodes in one operation | Custom implementation based on single addNode logic |
| `removeNodes` | Remove multiple nodes in one operation | src/composables/useCoreCommands.ts:180 (forEach pattern) |
| `duplicateNodes` | Duplicate selected nodes with their connections | src/utils/vintageClipboard.ts:32 (node.clone pattern) |
### Clipboard Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `copyNodes` | Copy nodes to clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3602-3687 |
| `cutNodes` | Cut nodes to clipboard | Custom implementation (copy + mark for deletion) |
| `pasteNodes` | Paste nodes from clipboard | src/lib/litegraph/src/LGraphCanvas.ts:3693-3871 |
| `getClipboard` | Get current clipboard content | Custom implementation (returns internal clipboard) |
| `clearClipboard` | Clear clipboard content | Custom implementation (sets clipboard to null) |
| `hasClipboardContent` | Check if clipboard has content | Custom implementation (checks clipboard state) |
### Reroute Operations (2 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `addReroute` | Add a reroute point on a connection | src/lib/litegraph/src/LGraph.ts:1338-1361 (createReroute) |
| `removeReroute` | Remove a reroute point | src/lib/litegraph/src/LGraph.ts:1381-1407 |
### Subgraph Operations (6 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `createSubgraph` | Create a subgraph from selected nodes | src/lib/litegraph/src/LGraph.ts:1459-1566 (convertToSubgraph) |
| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | src/lib/litegraph/src/LGraph.ts:1672-1841 |
| `addSubgraphInput` | Add an input to a subgraph | src/lib/litegraph/src/LGraph.ts:2440-2456 |
| `addSubgraphOutput` | Add an output to a subgraph | src/lib/litegraph/src/LGraph.ts:2458-2474 |
| `removeSubgraphInput` | Remove a subgraph input | src/lib/litegraph/src/LGraph.ts:2520-2535 |
| `removeSubgraphOutput` | Remove a subgraph output | src/lib/litegraph/src/LGraph.ts:2541-2559 |
### Graph-level Operations (1 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `clearGraph` | Clear all nodes and connections | src/lib/litegraph/src/LGraph.ts:293-362 |
### Execution Control Operations (2 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `bypassNode` | Set node to bypass mode (never execute) | Direct mode assignment (node.mode = LGraphEventMode.BYPASS) |
| `unbypassNode` | Set node to normal mode (always execute) | Direct mode assignment (node.mode = LGraphEventMode.ALWAYS) |
### Transaction and History Operations (3 operations)
| Operation | Description | Original Implementation Reference |
|-----------|-------------|-----------------------------------|
| `transaction` | Execute multiple operations atomically | Custom implementation using beforeChange/afterChange |
| `undo` | Undo the last operation | src/scripts/changeTracker.ts (uses changeTracker.undo) |
| `redo` | Redo the previously undone operation | src/scripts/changeTracker.ts (uses changeTracker.redo) |
## Usage Examples
### Basic Node Operations
```typescript
import { useGraphMutationService } from '@/services/graphMutationService'
const service = useGraphMutationService()
// Add a node
const nodeId = await service.addNode({
type: 'LoadImage',
pos: [100, 100],
title: 'Image Loader'
})
// Update node properties
await service.updateNodeTitle(nodeId, 'My Image')
await service.updateNodeProperty(nodeId, 'seed', 12345)
// Clone a node
const clonedId = await service.cloneNode(nodeId, [300, 200])
```
### Connection Management
```typescript
// Create a connection
const linkId = await service.connect({
sourceNodeId: node1Id,
sourceSlot: 0,
targetNodeId: node2Id,
targetSlot: 0
})
// Various disconnect methods
await service.disconnectInput(node2Id, 0)
await service.disconnectOutput(node1Id, 0)
await service.disconnectLink(linkId)
```
### Group Management
```typescript
// Create a group
const groupId = await service.createGroup({
title: 'Image Processing',
pos: [100, 100],
size: [400, 300],
color: '#335577'
})
// Manage group content
await service.addNodesToGroup(groupId, [node1Id, node2Id])
await service.moveGroup(groupId, 50, 100) // deltaX, deltaY
await service.resizeGroup(groupId, [500, 400])
```
### Batch Operations
```typescript
// Add multiple nodes
const nodeIds = await service.addNodes([
{ type: 'LoadImage', pos: [100, 100] },
{ type: 'VAEEncode', pos: [300, 100] },
{ type: 'KSampler', pos: [500, 100] }
])
// Duplicate with connections preserved
const duplicatedIds = await service.duplicateNodes(
[node1Id, node2Id, node3Id],
[100, 100] // offset
)
// Batch delete
await service.removeNodes([node1Id, node2Id, node3Id])
```
### Clipboard Operations
```typescript
// Copy/Cut/Paste workflow
await service.copyNodes([node1Id, node2Id])
await service.cutNodes([node3Id, node4Id])
const pastedNodes = await service.pasteNodes([200, 200])
// Check clipboard
if (service.hasClipboardContent()) {
const clipboard = service.getClipboard()
console.log(`${clipboard.nodes.length} nodes in clipboard`)
}
```
### Transactions
```typescript
// Atomic operations
await service.transaction(async () => {
const node1 = await service.addNode({ type: 'LoadImage' })
const node2 = await service.addNode({ type: 'SaveImage' })
await service.connect({
sourceNodeId: node1,
sourceSlot: 0,
targetNodeId: node2,
targetSlot: 0
})
})
// Entire transaction can be undone as one operation
await service.undo()
```
### Graph-level Operations
```typescript
// Clear entire graph
await service.clearGraph()
// Distribute nodes evenly
await service.distributeNodes([node1Id, node2Id, node3Id], 'horizontal')
```
### Execution Control
```typescript
// Bypass node (set to never execute)
await service.bypassNode(nodeId)
// Re-enable node execution
await service.unbypassNode(nodeId)
```
### Subgraph Operations
```typescript
// Create subgraph from selected nodes
const subgraphId = await service.createSubgraph({
name: 'Image Processing',
nodeIds: [node1Id, node2Id, node3Id]
})
// Configure subgraph I/O
await service.addSubgraphInput(subgraphId, 'image', 'IMAGE')
await service.addSubgraphOutput(subgraphId, 'result', 'IMAGE')
// Add dynamic slots to subgraph nodes
await service.addSubgraphNodeInput({
nodeId: subgraphNodeId,
name: 'extra_input',
type: 'LATENT'
})
```
## Implementation Details
### Integration Points
1. **LiteGraph Integration**
- Uses `app.graph` for graph access
- Calls `beforeChange()`/`afterChange()` for transactions
- Integrates with existing LiteGraph node/connection APIs
2. **ChangeTracker Integration**
- Maintains compatibility with existing undo/redo system
- Calls `checkState()` after operations
- Provides undo/redo through existing tracker
## Validation System
### Current Validations (Placeholder)
- `validateAddNode()` - Check node type exists
- `validateRemoveNode()` - Check node can be removed
- `validateConnect()` - Check connection compatibility
- `validateUpdateNodePosition()` - Check position bounds
### Future Validations
- Type compatibility checking
- Circular dependency detection
- Resource limit enforcement
- Permission validation
- Business rule enforcement
## Technical Decisions
### Why Validation Layer?
- **Data Integrity**: Prevent invalid graph states
- **User Experience**: Early error detection
- **Security**: Prevent malicious operations
- **Extensibility**: Easy to add new rules
### Why Transaction Support?
- **Atomicity**: Multiple operations succeed or fail together
- **Consistency**: Graph remains valid throughout
- **User Experience**: Natural undo/redo boundaries
## Related Files
- **Interface Definition**: `src/services/IGraphMutationService.ts`
- **Implementation**: `src/services/GraphMutationService.ts`
- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts`
- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts`
- **Change Tracking**: `src/scripts/changeTracker.ts`
## Implementation Compatibility Notes
### Critical Implementation Details to Maintain:
1. **beforeChange/afterChange Pattern**
- All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()`
- This enables undo/redo functionality through ChangeTracker
- Reference: `src/scripts/changeTracker.ts:200-208`
2. **Node ID Management**
- Node IDs can be numbers or strings (for API compatibility)
- Reference: `src/scripts/app.ts:1591` - `node.id = isNaN(+id) ? id : +id`
3. **Clipboard Implementation**
- Current implementation uses localStorage for persistence
- Must maintain compatibility with existing clipboard format
- Reference: `src/lib/litegraph/src/LGraphCanvas.ts:3602-3857`
4. **Group Resizing**
- Groups should auto-resize when adding nodes using `recomputeInsideNodes()`
- Reference: `src/composables/useCoreCommands.ts:430` - `group.resizeTo()`
5. **Canvas Dirty Flag**
- Visual operations (groups, reroutes) must call `graph.setDirtyCanvas(true, false)`
- This triggers canvas redraw
6. **Error Handling**
- Node creation can return null (for invalid types)
- Connection operations return null/false on failure
- Must validate before operations
7. **Subgraph Support**
- Subgraph operations use specialized Subgraph and SubgraphNode classes
- Reference: `src/lib/litegraph/src/subgraph/`
## Migration Strategy
1. Start by replacing direct `app.graph.add()` calls with `graphMutationService.addNode()`
2. Replace `graph.remove()` calls with `graphMutationService.removeNode()`
3. Update connection operations to use service methods
4. Migrate clipboard operations to use centralized service
5. Ensure all operations maintain existing beforeChange/afterChange patterns
## Important Notes
1. **Always use GraphMutationService** - Never call graph methods directly
2. **Backward Compatibility** - Service maintains compatibility with existing code
3. **Gradual Migration** - Existing code can be migrated incrementally
4. **Performance** - Command recording has minimal overhead

View File

@@ -0,0 +1,161 @@
import { GroupId } from '@/lib/litegraph/src/LGraphGroup'
import { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { SubgraphId } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeId } from '@/schemas/comfyWorkflowSchema'
export interface AddNodeParams {
type: string
pos?: [number, number]
properties?: Record<string, any>
title?: string
}
export interface ConnectParams {
sourceNodeId: NodeId
sourceSlot: number | string
targetNodeId: NodeId
targetSlot: number | string
}
export interface CreateGroupParams {
title?: string
pos?: [number, number]
size?: [number, number]
color?: string
fontSize?: number
}
export interface AddRerouteParams {
pos: [number, number]
linkId?: LinkId
parentRerouteId?: RerouteId
}
export interface AddNodeInputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, any>
}
export interface AddNodeOutputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, any>
}
export interface CreateSubgraphParams {
selectedItems: Set<any>
}
export interface ClipboardData {
nodes: any[]
connections: any[]
isCut: boolean
}
export interface ValidationResult {
valid: boolean
errors?: ValidationError[]
warnings?: ValidationWarning[]
}
export interface ValidationError {
code: string
message: string
field?: string
}
export interface ValidationWarning {
code: string
message: string
}
export interface IGraphMutationService {
// Node operations
addNode(params: AddNodeParams): Promise<NodeId>
removeNode(nodeId: NodeId): Promise<void>
updateNodeProperty(
nodeId: NodeId,
property: string,
value: any
): Promise<void>
updateNodeTitle(nodeId: NodeId, title: string): Promise<void>
changeNodeMode(nodeId: NodeId, mode: number): Promise<void>
cloneNode(nodeId: NodeId, pos?: [number, number]): Promise<NodeId>
connect(params: ConnectParams): Promise<LinkId>
disconnect(
nodeId: NodeId,
slot: number | string,
slotType: 'input' | 'output',
targetNodeId?: NodeId
): Promise<boolean>
disconnectInput(nodeId: NodeId, slot: number | string): Promise<boolean>
disconnectOutput(nodeId: NodeId, slot: number | string): Promise<boolean>
disconnectOutputTo(
nodeId: NodeId,
slot: number | string,
targetNodeId: NodeId
): Promise<boolean>
disconnectLink(linkId: LinkId): Promise<void>
createGroup(params: CreateGroupParams): Promise<GroupId>
removeGroup(groupId: GroupId): Promise<void>
updateGroupTitle(groupId: GroupId, title: string): Promise<void>
moveGroup(groupId: GroupId, deltaX: number, deltaY: number): Promise<void>
addNodesToGroup(groupId: GroupId, nodeIds: NodeId[]): Promise<void>
recomputeGroupNodes(groupId: GroupId): Promise<void>
addReroute(params: AddRerouteParams): Promise<RerouteId>
removeReroute(rerouteId: RerouteId): Promise<void>
addNodes(nodes: AddNodeParams[]): Promise<NodeId[]>
removeNodes(nodeIds: NodeId[]): Promise<void>
duplicateNodes(
nodeIds: NodeId[],
offset?: [number, number]
): Promise<NodeId[]>
copyNodes(nodeIds: NodeId[]): Promise<void>
cutNodes(nodeIds: NodeId[]): Promise<void>
pasteNodes(position?: [number, number]): Promise<NodeId[]>
getClipboard(): ClipboardData | null
clearClipboard(): void
hasClipboardContent(): boolean
addSubgraphNodeInput(params: AddNodeInputParams): Promise<number>
addSubgraphNodeOutput(params: AddNodeOutputParams): Promise<number>
removeSubgraphNodeInput(nodeId: NodeId, slot: number): Promise<void>
removeSubgraphNodeOutput(nodeId: NodeId, slot: number): Promise<void>
createSubgraph(params: CreateSubgraphParams): Promise<{
subgraph: any
node: any
}>
unpackSubgraph(subgraphNodeId: NodeId): Promise<void>
addSubgraphInput(
subgraphId: SubgraphId,
name: string,
type: string
): Promise<void>
addSubgraphOutput(
subgraphId: SubgraphId,
name: string,
type: string
): Promise<void>
removeSubgraphInput(subgraphId: SubgraphId, index: number): Promise<void>
removeSubgraphOutput(subgraphId: SubgraphId, index: number): Promise<void>
clearGraph(): Promise<void>
bypassNode(nodeId: NodeId): Promise<void>
unbypassNode(nodeId: NodeId): Promise<void>
transaction<T>(fn: () => Promise<T>): Promise<T>
undo(): Promise<void>
redo(): Promise<void>
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import { toRaw } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'

View File

@@ -2,8 +2,8 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { useWorkflowThumbnail } from '@/composables/useWorkflowThumbnail'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useOutputExplorerSidebarTab } from '@/composables/sidebarTabs/outputExplorerSidebarTab'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
@@ -93,7 +92,6 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
registerSidebarTab(useOutputExplorerSidebarTab())
const menuStore = useMenuItemStore()

View File

@@ -3,39 +3,28 @@ import { nextTick } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const triggerRAF = async () => {
// Trigger all RAF callbacks
Object.values(rafCallbacks).forEach((cb) => cb?.())
await flushPromises()
}
const mockPause = vi.fn()
const mockResume = vi.fn()
const rafCallbacks: Record<string, () => void> = {}
let rafCallbackId = 0
vi.mock('@vueuse/core', () => {
const callbacks: Record<string, () => void> = {}
let callbackId = 0
return {
useRafFn: vi.fn((callback, options) => {
const id = rafCallbackId++
rafCallbacks[id] = callback
const id = callbackId++
callbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
const resumeFn = vi.fn(() => {
mockResume()
// Execute the RAF callback immediately when resumed
if (rafCallbacks[id]) {
rafCallbacks[id]()
}
})
return {
pause: mockPause,
resume: resumeFn
resume: vi.fn(() => {
mockResume()
void Promise.resolve().then(() => callbacks[id]?.())
})
}
}),
useThrottleFn: vi.fn((callback) => {
@@ -153,9 +142,7 @@ vi.mock('@/stores/workflowStore', () => ({
}))
}))
const { useMinimap } = await import(
'@/renderer/extensions/minimap/composables/useMinimap'
)
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
@@ -438,19 +425,7 @@ describe('useMinimap', () => {
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by triggering a graph change
mockGraph._nodes.push({
id: 'new-node',
pos: [150, 150],
size: [100, 50]
})
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
@@ -463,15 +438,7 @@ describe('useMinimap', () => {
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by modifying a node position
mockGraph._nodes[0].pos = [50, 50]
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 100))
const renderingOccurred =
mockContext2D.clearRect.mock.calls.length > 0 ||
@@ -482,15 +449,6 @@ describe('useMinimap', () => {
console.log('Minimap initialized:', minimap.initialized.value)
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
console.log(
'clearRect calls:',
mockContext2D.clearRect.mock.calls.length
)
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
console.log(
'getContext calls:',
mockCanvasElement.getContext.mock.calls.length
)
}
expect(renderingOccurred).toBe(true)
@@ -520,10 +478,6 @@ describe('useMinimap', () => {
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
// The renderer has a fast path for empty graphs, force it to execute
minimap.renderMinimap()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
@@ -963,7 +917,7 @@ describe('useMinimap', () => {
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = document.createElement('div')
const ref = { value: 'test-ref' }
minimap.setMinimapRef(ref)

View File

@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
vi.mock('@/renderer/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
vi.mock('@/composables/useMinimap', () => ({
useMinimap: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
@@ -19,14 +19,13 @@ vi.mock('@/scripts/api', () => ({
}))
const { useWorkflowThumbnail } = await import(
'@/renderer/thumbnail/composables/useWorkflowThumbnail'
)
const { createGraphThumbnail } = await import(
'@/renderer/thumbnail/graphThumbnailRenderer'
'@/composables/useWorkflowThumbnail'
)
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let mockMinimapInstance: any
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
@@ -40,23 +39,35 @@ describe('useWorkflowThumbnail', () => {
// Now set up mocks
vi.clearAllMocks()
const blob = new Blob()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test')
global.URL.revokeObjectURL = vi.fn()
// Mock API responses
vi.mocked(api.moveUserData).mockResolvedValue({ status: 200 } as Response)
// Default createGraphThumbnail to return test value
vi.mocked(createGraphThumbnail).mockReturnValue(
'data:image/png;base64,test'
)
mockMinimapInstance = {
renderMinimap: vi.fn(),
canvasRef: {
value: {
toBlob: vi.fn((cb) => cb(blob))
}
},
width: 250,
height: 200
}
vi.mocked(useMinimap).mockReturnValue(mockMinimapInstance)
})
it('should capture minimap thumbnail', async () => {
const { createMinimapPreview } = useWorkflowThumbnail()
const thumbnail = await createMinimapPreview()
expect(createGraphThumbnail).toHaveBeenCalledOnce()
expect(useMinimap).toHaveBeenCalledOnce()
expect(mockMinimapInstance.renderMinimap).toHaveBeenCalledOnce()
expect(thumbnail).toBe('data:image/png;base64,test')
})
@@ -150,9 +161,6 @@ describe('useWorkflowThumbnail', () => {
// Reset the mock to track new calls and create different URL
vi.clearAllMocks()
global.URL.createObjectURL = vi.fn(() => 'data:image/png;base64,test2')
vi.mocked(createGraphThumbnail).mockReturnValue(
'data:image/png;base64,test2'
)
// Store second thumbnail for same workflow - should revoke the first URL
await storeThumbnail(mockWorkflow)

View File

@@ -1,299 +0,0 @@
import { useThrottleFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
import { api } from '@/scripts/api'
vi.mock('@vueuse/core', () => ({
useThrottleFn: vi.fn((fn) => fn)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe('useMinimapGraph', () => {
let mockGraph: LGraph
let onGraphChangedMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockGraph = {
id: 'test-graph-123',
_nodes: [
{ id: '1', pos: [100, 100], size: [150, 80] },
{ id: '2', pos: [300, 200], size: [120, 60] }
],
links: { link1: { id: 'link1' } },
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
} as any
onGraphChangedMock = vi.fn()
})
it('should initialize with empty state', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(graphManager.updateFlags.value).toEqual({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
})
it('should setup event listeners on init', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should wrap graph callbacks on setup', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Should wrap the callbacks
expect(mockGraph.onNodeAdded).not.toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).not.toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).not.toBe(originalOnConnectionChange)
// Test wrapped callbacks
const testNode = { id: '3' } as LGraphNode
mockGraph.onNodeAdded!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should prevent duplicate event listener setup', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
onNodeRemoved: mockGraph.onNodeRemoved,
onConnectionChange: mockGraph.onConnectionChange
}
// Setup again - should not re-wrap
graphManager.setupEventListeners()
expect(mockGraph.onNodeAdded).toBe(wrappedCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(wrappedCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
wrappedCallbacks.onConnectionChange
)
})
it('should cleanup event listeners properly', () => {
const originalOnNodeAdded = vi.fn()
const originalOnNodeRemoved = vi.fn()
const originalOnConnectionChange = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
mockGraph.onNodeRemoved = originalOnNodeRemoved
mockGraph.onConnectionChange = originalOnConnectionChange
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
// Should restore original callbacks
expect(mockGraph.onNodeAdded).toBe(originalOnNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalOnNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})
it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.cleanupEventListeners()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Attempted to cleanup event listeners for graph that was never set up'
)
consoleErrorSpy.mockRestore()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// First check - cache initial state
let hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true) // Initial cache population
// No changes
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(false)
// Change node position
mockGraph._nodes[0].pos = [200, 150]
hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect node count changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Add a node
mockGraph._nodes.push({ id: '3', pos: [400, 300], size: [100, 50] } as any)
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect connection changes', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Change connections
mockGraph.links = new Map([
[1, { id: 1 }],
[2, { id: 2 }]
]) as any
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should handle node removal in callbacks', () => {
const originalOnNodeRemoved = vi.fn()
mockGraph.onNodeRemoved = originalOnNodeRemoved
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const removedNode = { id: '2' } as LGraphNode
mockGraph.onNodeRemoved!(removedNode)
expect(originalOnNodeRemoved).toHaveBeenCalledWith(removedNode)
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('should destroy properly', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.init()
graphManager.setupEventListeners()
graphManager.destroy()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
})
it('should clear cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Populate cache
graphManager.checkForChanges()
// Clear cache
graphManager.clearCache()
// Should detect changes again after clear
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
})
it('should handle null graph gracefully', () => {
const graphRef = ref(null as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
expect(() => graphManager.setupEventListeners()).not.toThrow()
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
expect(graphManager.checkForChanges()).toBe(false)
})
it('should clean up removed nodes from cache', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Remove a node
mockGraph._nodes = mockGraph._nodes.filter((n) => n.id !== '2')
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.bounds).toBe(true)
})
it('should throttle graph changed callback', () => {
const throttledFn = vi.fn()
vi.mocked(useThrottleFn).mockReturnValue(throttledFn)
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
// Trigger multiple changes rapidly
mockGraph.onNodeAdded!({ id: '3' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '4' } as LGraphNode)
mockGraph.onNodeAdded!({ id: '5' } as LGraphNode)
// Should be throttled
expect(throttledFn).toHaveBeenCalledTimes(3)
})
})

View File

@@ -1,328 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMinimapInteraction } from '@/renderer/extensions/minimap/composables/useMinimapInteraction'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
describe('useMinimapInteraction', () => {
let mockContainer: HTMLDivElement
let mockCanvas: MinimapCanvas
let centerViewOnMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockContainer = {
getBoundingClientRect: vi.fn().mockReturnValue({
left: 100,
top: 50,
width: 250,
height: 200
})
} as any
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
} as any
centerViewOnMock = vi.fn()
})
it('should initialize with default values', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
expect(interaction.isDragging.value).toBe(false)
expect(interaction.containerRect.value).toEqual({
left: 0,
top: 0,
width: 250,
height: 200
})
})
it('should update container rect', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
interaction.updateContainerRect()
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(interaction.containerRect.value).toEqual({
left: 100,
top: 50,
width: 250,
height: 200
})
})
it('should handle pointer down and start dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const event = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
interaction.handlePointerDown(event)
expect(interaction.isDragging.value).toBe(true)
expect(mockContainer.getBoundingClientRect).toHaveBeenCalled()
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should handle pointer move when dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
// Move pointer
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
// Should calculate world coordinates and center view
expect(centerViewOnMock).toHaveBeenCalledTimes(2) // Once on down, once on move
// Calculate expected world coordinates
const x = 200 - 100 // clientX - containerLeft
const y = 150 - 50 // clientY - containerTop
const offsetX = (250 - 500 * 0.5) / 2 // (width - bounds.width * scale) / 2
const offsetY = (200 - 400 * 0.5) / 2 // (height - bounds.height * scale) / 2
const worldX = (x - offsetX) / 0.5 + 0 // (x - offsetX) / scale + bounds.minX
const worldY = (y - offsetY) / 0.5 + 0 // (y - offsetY) / scale + bounds.minY
expect(centerViewOnMock).toHaveBeenLastCalledWith(worldX, worldY)
})
it('should not move when not dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const moveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 150
})
interaction.handlePointerMove(moveEvent)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Start dragging
interaction.handlePointerDown(
new PointerEvent('pointerdown', {
clientX: 150,
clientY: 100
})
)
expect(interaction.isDragging.value).toBe(true)
interaction.handlePointerUp()
expect(interaction.isDragging.value).toBe(false)
})
it('should handle wheel events for zooming', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should update canvas scale (zoom in)
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(centerViewOnMock).toHaveBeenCalled()
})
it('should respect zoom limits', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Set scale close to minimum
mockCanvas.ds.scale = 0.11
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100, // Zoom out
clientX: 200,
clientY: 150
})
wheelEvent.preventDefault = vi.fn()
interaction.handleWheel(wheelEvent)
// Should not go below minimum scale
expect(mockCanvas.ds.scale).toBe(0.11)
expect(centerViewOnMock).not.toHaveBeenCalled()
})
it('should handle null container gracefully', () => {
const containerRef = ref<HTMLDivElement | undefined>(undefined)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(mockCanvas as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() => interaction.updateContainerRect()).not.toThrow()
expect(() =>
interaction.handlePointerDown(new PointerEvent('pointerdown'))
).not.toThrow()
})
it('should handle null canvas gracefully', () => {
const containerRef = ref(mockContainer)
const boundsRef = ref({ minX: 0, minY: 0, width: 500, height: 400 })
const scaleRef = ref(0.5)
const canvasRef = ref(null as any)
const interaction = useMinimapInteraction(
containerRef,
boundsRef,
scaleRef,
250,
200,
centerViewOnMock,
canvasRef
)
// Should not throw
expect(() =>
interaction.handlePointerMove(new PointerEvent('pointermove'))
).not.toThrow()
expect(() => interaction.handleWheel(new WheelEvent('wheel'))).not.toThrow()
expect(centerViewOnMock).not.toHaveBeenCalled()
})
})

View File

@@ -1,267 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapRenderer } from '@/renderer/extensions/minimap/composables/useMinimapRenderer'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { UpdateFlags } from '@/renderer/extensions/minimap/types'
vi.mock('@/renderer/extensions/minimap/minimapCanvasRenderer', () => ({
renderMinimapToCanvas: vi.fn()
}))
describe('useMinimapRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn()
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [{ id: '1', pos: [0, 0], size: [100, 100] }]
} as any
})
it('should initialize with full redraw needed', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
expect(renderer.needsFullRedraw.value).toBe(true)
expect(renderer.needsBoundsUpdate.value).toBe(true)
})
it('should handle empty graph with fast path', () => {
const emptyGraph = { _nodes: [] } as any
const canvasRef = ref(mockCanvas)
const graphRef = ref(emptyGraph)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.renderMinimap()
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } = await import(
'@/renderer/extensions/minimap/minimapCanvasRenderer'
)
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// First render (needsFullRedraw is true by default)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Second render without changes (should not render)
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(1)
// Set update flag and render again
updateFlagsRef.value.nodes = true
renderer.renderMinimap()
expect(vi.mocked(renderMinimapToCanvas)).toHaveBeenCalledTimes(2)
})
it('should update minimap with bounds and viewport callbacks', () => {
const updateBounds = vi.fn()
const updateViewport = vi.fn()
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: true,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.updateMinimap(updateBounds, updateViewport)
expect(updateBounds).toHaveBeenCalled()
expect(updateViewport).toHaveBeenCalled()
expect(updateFlagsRef.value.bounds).toBe(false)
expect(renderer.needsFullRedraw.value).toBe(false) // After rendering, needsFullRedraw is reset to false
expect(updateFlagsRef.value.viewport).toBe(false) // After updating viewport, this is reset to false
})
it('should force full redraw when requested', () => {
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
renderer.forceFullRedraw()
expect(renderer.needsFullRedraw.value).toBe(true)
expect(updateFlagsRef.value.bounds).toBe(true)
expect(updateFlagsRef.value.nodes).toBe(true)
expect(updateFlagsRef.value.connections).toBe(true)
expect(updateFlagsRef.value.viewport).toBe(true)
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref<HTMLCanvasElement | undefined>(undefined)
const graphRef = ref(mockGraph as any)
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })
const scaleRef = ref(1)
const updateFlagsRef = ref<UpdateFlags>({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const settings = {
nodeColors: ref(true),
showLinks: ref(true),
showGroups: ref(true),
renderBypass: ref(false),
renderError: ref(false)
}
const renderer = useMinimapRenderer(
canvasRef,
graphRef,
boundsRef,
scaleRef,
updateFlagsRef,
settings,
250,
200
)
// Should not throw
expect(() => renderer.renderMinimap()).not.toThrow()
expect(mockCanvas.getContext).not.toHaveBeenCalled()
})
})

View File

@@ -1,122 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
vi.mock('@/stores/settingStore')
vi.mock('@/stores/workspace/colorPaletteStore')
describe('useMinimapSettings', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should return all minimap settings as computed refs', () => {
const mockSettingStore = {
get: vi.fn((key: string) => {
const settings: Record<string, any> = {
'Comfy.Minimap.NodeColors': true,
'Comfy.Minimap.ShowLinks': false,
'Comfy.Minimap.ShowGroups': true,
'Comfy.Minimap.RenderBypassState': false,
'Comfy.Minimap.RenderErrorState': true
}
return settings[key]
})
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
expect(settings.showGroups.value).toBe(true)
expect(settings.renderBypass.value).toBe(false)
expect(settings.renderError.value).toBe(true)
})
it('should generate container styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C') // dark theme color
expect(styles.border).toBe('1px solid #333')
})
it('should generate light theme container styles', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: true }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.containerStyles.value
expect(styles.backgroundColor).toBe('#FAF9F5') // light theme color
expect(styles.border).toBe('1px solid #ccc')
})
it('should generate panel styles based on theme', () => {
const mockColorPaletteStore = {
completedActivePalette: { light_theme: false }
}
vi.mocked(useSettingStore).mockReturnValue({ get: vi.fn() } as any)
vi.mocked(useColorPaletteStore).mockReturnValue(
mockColorPaletteStore as any
)
const settings = useMinimapSettings()
const styles = settings.panelStyles.value
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.borderRadius).toBe('8px')
})
it('should create computed properties that call the store getter', () => {
const mockGet = vi.fn((key: string) => {
if (key === 'Comfy.Minimap.NodeColors') return true
if (key === 'Comfy.Minimap.ShowLinks') return false
return true
})
const mockSettingStore = { get: mockGet }
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
const settings = useMinimapSettings()
// Access the computed properties
expect(settings.nodeColors.value).toBe(true)
expect(settings.showLinks.value).toBe(false)
// Verify the store getter was called with the correct keys
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.NodeColors')
expect(mockGet).toHaveBeenCalledWith('Comfy.Minimap.ShowLinks')
})
})

View File

@@ -1,289 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@/composables/canvas/useCanvasTransformSync')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),
enforceMinimumBounds: vi.fn()
}))
describe('useMinimapViewport', () => {
let mockCanvas: MinimapCanvas
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockCanvas = {
canvas: {
clientWidth: 800,
clientHeight: 600,
width: 1600,
height: 1200
} as HTMLCanvasElement,
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
mockGraph = {
_nodes: [
{ pos: [100, 100], size: [150, 80] },
{ pos: [300, 200], size: [120, 60] }
]
} as any
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: vi.fn(),
stopSync: vi.fn()
} as any)
})
it('should initialize with default bounds', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
maxX: 420,
maxY: 260,
width: 320,
height: 160
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateNodeBounds).toHaveBeenCalledWith(mockGraph._nodes)
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', async () => {
const { calculateNodeBounds } = await import(
'@/renderer/core/spatial/boundsCalculator'
)
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref({ _nodes: [] } as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(viewport.bounds.value).toEqual({
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
width: 100,
height: 100
})
})
it('should update canvas dimensions', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
expect(viewport.canvasDimensions.value).toEqual({
width: 800,
height: 600
})
})
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Set canvas transform
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
// Update bounds and viewport
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const transform = viewport.viewportTransform.value
// World coordinates
const worldX = -(-100) // -offset[0] = 100
const worldY = -(-50) // -offset[1] = 50
// Viewport size in world coordinates
const viewportWidth = 800 / 2 // canvasWidth / scale = 400
const viewportHeight = 600 / 2 // canvasHeight / scale = 300
// Center offsets
const centerOffsetX = (250 - 500 * 0.5) / 2 // (250 - 250) / 2 = 0
const centerOffsetY = (200 - 400 * 0.5) / 2 // (200 - 200) / 2 = 0
// Expected values based on implementation: (worldX - bounds.minX) * scale + centerOffsetX
expect(transform.x).toBeCloseTo((worldX - 0) * 0.5 + centerOffsetX) // (100 - 0) * 0.5 + 0 = 50
expect(transform.y).toBeCloseTo((worldY - 0) * 0.5 + centerOffsetY) // (50 - 0) * 0.5 + 0 = 25
expect(transform.width).toBeCloseTo(viewportWidth * 0.5) // 400 * 0.5 = 200
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
mockCanvas.ds.scale = 2
viewport.centerViewOn(300, 200)
// Should update canvas offset to center on the given world coordinates
const expectedOffsetX = -(300 - 800 / 2 / 2) // -(worldX - viewportWidth/2)
const expectedOffsetY = -(200 - 600 / 2 / 2) // -(worldY - viewportHeight/2)
expect(mockCanvas.ds.offset[0]).toBe(expectedOffsetX)
expect(mockCanvas.ds.offset[1]).toBe(expectedOffsetY)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should start and stop viewport sync', () => {
const startSyncMock = vi.fn()
const stopSyncMock = vi.fn()
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: startSyncMock,
stopSync: stopSyncMock
} as any)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.startViewportSync()
expect(startSyncMock).toHaveBeenCalled()
viewport.stopViewportSync()
expect(stopSyncMock).toHaveBeenCalled()
})
it('should handle null canvas gracefully', () => {
const canvasRef = ref(null as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
// Should not throw
expect(() => viewport.updateCanvasDimensions()).not.toThrow()
expect(() => viewport.updateViewport()).not.toThrow()
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
}
vi.mocked(calculateNodeBounds).mockReturnValue(testBounds)
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.4)
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateBounds()
expect(calculateMinimapScale).toHaveBeenCalledWith(testBounds, 250, 200)
expect(viewport.scale.value).toBe(0.4)
})
it('should handle device pixel ratio', () => {
const originalDPR = window.devicePixelRatio
Object.defineProperty(window, 'devicePixelRatio', {
value: 2,
configurable: true
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
viewport.updateCanvasDimensions()
// Should use client dimensions or calculate from canvas dimensions / dpr
expect(viewport.canvasDimensions.value.width).toBe(800)
expect(viewport.canvasDimensions.value.height).toBe(600)
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,
configurable: true
})
})
})

View File

@@ -1,324 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { renderMinimapToCanvas } from '@/renderer/extensions/minimap/minimapCanvasRenderer'
import type { MinimapRenderContext } from '@/renderer/extensions/minimap/types'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
vi.mock('@/stores/workspace/colorPaletteStore')
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_adjusted')
}))
describe('minimapCanvasRenderer', () => {
let mockCanvas: HTMLCanvasElement
let mockContext: CanvasRenderingContext2D
let mockGraph: LGraph
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 1
} as any
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext)
} as any
mockGraph = {
_nodes: [
{
id: '1',
pos: [100, 100],
size: [150, 80],
bgcolor: '#FF0000',
mode: LGraphEventMode.ALWAYS,
has_errors: false,
outputs: []
},
{
id: '2',
pos: [300, 200],
size: [120, 60],
bgcolor: '#00FF00',
mode: LGraphEventMode.BYPASS,
has_errors: true,
outputs: []
}
] as unknown as LGraphNode[],
_groups: [],
links: {},
getNodeById: vi.fn()
} as any
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: false }
} as any)
})
it('should clear canvas and render nodes', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should clear the canvas first
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
// Should render nodes (batch by color)
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should handle empty graph', () => {
mockGraph._nodes = []
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
expect(mockContext.clearRect).toHaveBeenCalledWith(0, 0, 250, 200)
expect(mockContext.fillRect).not.toHaveBeenCalled()
})
it('should batch render nodes by color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set fill style for each color group
const fillStyleCalls = []
let currentStyle = ''
mockContext.fillStyle = ''
Object.defineProperty(mockContext, 'fillStyle', {
get: () => currentStyle,
set: (value) => {
currentStyle = value
fillStyleCalls.push(value)
}
})
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Different colors for different nodes
expect(fillStyleCalls.length).toBeGreaterThan(0)
})
it('should render bypass nodes with special color', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: true,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Node 2 is in bypass mode, should be rendered
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render error outlines when enabled', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: true
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should set stroke style for errors
expect(mockContext.strokeStyle).toBe('#FF0000')
expect(mockContext.strokeRect).toHaveBeenCalled()
})
it('should render groups when enabled', () => {
mockGraph._groups = [
{
pos: [50, 50],
size: [400, 300],
color: '#0000FF'
}
] as any
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: true,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Groups should be rendered before nodes
expect(mockContext.fillRect).toHaveBeenCalled()
})
it('should render connections when enabled', () => {
const targetNode = {
id: '2',
pos: [300, 200],
size: [120, 60]
}
mockGraph._nodes[0].outputs = [
{
links: [1]
}
] as any
// Create a hybrid Map/Object for links as LiteGraph expects
const linksMap = new Map([[1, { id: 1, target_id: 2 }]])
const links = Object.assign(linksMap, {
1: { id: 1, target_id: 2 }
})
mockGraph.links = links as any
mockGraph.getNodeById = vi.fn().mockReturnValue(targetNode)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: true,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Should draw connection lines
expect(mockContext.beginPath).toHaveBeenCalled()
expect(mockContext.moveTo).toHaveBeenCalled()
expect(mockContext.lineTo).toHaveBeenCalled()
expect(mockContext.stroke).toHaveBeenCalled()
// Should draw connection slots
expect(mockContext.arc).toHaveBeenCalled()
expect(mockContext.fill).toHaveBeenCalled()
})
it('should handle light theme colors', () => {
vi.mocked(useColorPaletteStore).mockReturnValue({
completedActivePalette: { light_theme: true }
} as any)
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 500, height: 400 },
scale: 0.5,
settings: {
nodeColors: true,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// Color adjustment should be called for light theme
expect(adjustColor).toHaveBeenCalled()
})
it('should calculate correct offsets for centering', () => {
const context: MinimapRenderContext = {
bounds: { minX: 0, minY: 0, width: 200, height: 100 },
scale: 0.5,
settings: {
nodeColors: false,
showLinks: false,
showGroups: false,
renderBypass: false,
renderError: false
},
width: 250,
height: 200
}
renderMinimapToCanvas(mockCanvas, mockGraph, context)
// With bounds 200x100 at scale 0.5 = 100x50
// Canvas is 250x200, so offset should be (250-100)/2 = 75, (200-50)/2 = 75
// This affects node positioning
expect(mockContext.fillRect).toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff