Merge branch 'main' into feat/new-workflow-templates

This commit is contained in:
Johnpaul
2025-08-20 17:39:06 +01:00
30 changed files with 171 additions and 843 deletions

View File

@@ -93,6 +93,44 @@ export const WithVariant: Story = {
## Development Tips
## ComfyUI Storybook Guidelines
### Scope When to Create Stories
- **PrimeVue components**:
No need to create stories. Just refer to the official PrimeVue documentation.
- **Custom shared components (design system components)**:
Always create stories. These components are built in collaboration with designers, and Storybook serves as both documentation and a communication tool.
- **Container components (logic-heavy)**:
Do not create stories. Only the underlying pure UI components should be included in Storybook.
### Maintenance Philosophy
- Stories are lightweight and generally stable.
Once created, they rarely need updates unless:
- The design changes
- New props (e.g. size, color variants) are introduced
- For existing usage patterns, simply copy real code examples into Storybook to create stories.
### File Placement
- Keep `*.stories.ts` files at the **same level as the component** (similar to test files).
- This makes it easier to check usage examples without navigating to another directory.
### Developer/Designer Workflow
- **UI vs Container**: Separate pure UI components from container components.
Only UI components should live in Storybook.
- **Communication Tool**: Storybook is not just about code quality—it enables designers and developers to see:
- Which props exist
- What cases are covered
- How variants (e.g. size, colors) look in isolation
- **Example**:
`PackActionButton.vue` wraps a PrimeVue button with additional logic.
→ Only create a story for the base UI button, not for the wrapper.
### Suggested Workflow
1. Use PrimeVue docs for standard components
2. Use Storybook for **shared/custom components** that define our design system
3. Keep story files alongside components
4. When in doubt, focus on components reused across the app or those that need to be showcased to designers
### Best Practices
1. **Keep Stories Simple**: Each story should demonstrate one specific use case
@@ -135,6 +173,7 @@ export const WithLongText: Story = {
- **`main.ts`**: Core Storybook configuration and Vite integration
- **`preview.ts`**: Global decorators, parameters, and Vue app setup
- **`manager.ts`**: Storybook UI customization (if needed)
- **`preview-head.html`**: Injects custom HTML into the `<head>` of every Storybook iframe (used for global styles, fonts, or fixes for iframe-specific issues)
## Chromatic Visual Testing
@@ -170,4 +209,4 @@ This Storybook setup includes:
- PrimeVue component library integration
- Proper alias resolution for `@/` imports
For component-specific examples, see the NodePreview stories in `src/components/node/`.
For component-specific examples, see the NodePreview stories in `src/components/node/`.

View File

@@ -392,16 +392,6 @@ Option 2 - Generate local baselines for comparison:
npx playwright test --update-snapshots
```
### Getting Test Artifacts from GitHub Actions
When tests fail in CI, you can download screenshots and traces:
1. Go to the failed workflow run in GitHub Actions
2. Scroll to "Artifacts" section at the bottom
3. Download `playwright-report` or `test-results`
4. Extract and open the HTML report locally
5. View actual vs expected screenshots and execution traces
### Creating New Screenshot Baselines
For PRs from `Comfy-Org/ComfyUI_frontend` branches:
@@ -412,17 +402,19 @@ For PRs from `Comfy-Org/ComfyUI_frontend` branches:
> **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it).
## CI/CD Integration
## Viewing Test Reports
### Automated Test Deployment
The project automatically deploys Playwright test reports to Cloudflare Pages for every PR and push to main branches. This provides:
The project automatically deploys Playwright test reports to Cloudflare Pages for every PR and push to main branches.
- **Live test reports** with interactive HTML views
- **Cross-browser testing** across chromium, mobile-chrome, and different viewport sizes
- **Real-time PR comments** with test status and links to detailed reports
### Accessing Test Reports
#### How it works:
- **From PR comments**: Click the "View Report" links for each browser
- **Direct URLs**: Reports are available at `https://[branch].comfyui-playwright-[browser].pages.dev` (branch-specific deployments)
- **From GitHub Actions**: Download artifacts from failed runs
### How It Works
1. **Test execution**: All browser tests run in parallel across multiple browsers
2. **Report generation**: HTML reports are generated for each browser configuration
@@ -437,21 +429,6 @@ The project automatically deploys Playwright test reports to Cloudflare Pages fo
- Direct links to interactive test reports
- Real-time progress updates as tests complete
#### Accessing test reports:
- **From PR comments**: Click the "View Report" links for each browser
- **From GitHub Actions**: Download artifacts from failed runs
- **Direct URLs**: Reports are available at `https://[branch].comfyui-playwright-[browser].pages.dev` (branch-specific deployments)
#### Report features:
- **Interactive HTML reports** with test results, screenshots, and traces
- **Detailed failure analysis** with before/after screenshots
- **Test execution videos** for failed tests
- **Network logs** and console output for debugging
This integration ensures that test results are easily accessible to reviewers and maintainers, making it simple to verify that changes don't break existing functionality across different browsers and viewport sizes.
## Resources
- [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging

View File

@@ -1,7 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 5V3C14 2.44772 13.5523 2 13 2H11C10.4477 2 10 2.44772 10 3V5C10 5.55228 10.4477 6 11 6H13C13.5523 6 14 5.55228 14 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M6 5V3C6 2.44772 5.55228 2 5 2H3C2.44772 2 2 2.44772 2 3V5C2 5.55228 2.44772 6 3 6H5C5.55228 6 6 5.55228 6 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M14 13V11C14 10.4477 13.5523 10 13 10H11C10.4477 10 10 10.4477 10 11V13C10 13.5523 10.4477 14 11 14H13C13.5523 14 14 13.5523 14 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 4H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 12H8C5.79086 12 4 10.2091 4 8V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.99999 4H6.99999M8.99999 12H7.6231C5.02081 12 3.11138 9.55445 3.74252 7.02986L3.99999 6M13.6894 3.24254L13.1894 5.24254C13.0781 5.6877 12.6781 6 12.2192 6H10.2808C9.63019 6 9.15284 5.38861 9.31062 4.75746L9.81062 2.75746C9.92192 2.3123 10.3219 2 10.7808 2H12.7192C13.3698 2 13.8471 2.61139 13.6894 3.24254ZM6.68936 3.24254L6.18936 5.24254C6.07806 5.6877 5.67808 6 5.21921 6H3.28077C2.63019 6 2.15284 5.38861 2.31062 4.75746L2.81062 2.75746C2.92191 2.3123 3.3219 2 3.78077 2H5.71921C6.36978 2 6.84714 2.61139 6.68936 3.24254ZM13.6894 11.2425L13.1894 13.2425C13.0781 13.6877 12.6781 14 12.2192 14H10.2808C9.63019 14 9.15284 13.3886 9.31062 12.7575L9.81062 10.7575C9.92192 10.3123 10.3219 10 10.7808 10H12.7192C13.3698 10 13.8471 10.6114 13.6894 11.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 910 B

View File

@@ -12,7 +12,6 @@
<ColorPickerButton />
<BypassButton />
<PinButton />
<EditModelButton />
<Load3DViewerButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
@@ -35,7 +34,6 @@ import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'

View File

@@ -1,37 +0,0 @@
<template>
<Button
v-show="isImageOutputSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_AddEditModelStep.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-pen-to-square"
@click="() => commandStore.execute('Comfy.Canvas.AddEditModelStep')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isImageOutputOrEditModelNode = (node: unknown) =>
isLGraphNode(node) &&
(isImageNode(node) || node.type === 'workflow>FLUX.1 Kontext Image Edit')
const isImageOutputSelected = computed(
() =>
canvasStore.selectedItems.length === 1 &&
isImageOutputOrEditModelNode(canvasStore.selectedItems[0])
)
</script>

View File

@@ -9,6 +9,41 @@
:max-selected-labels="0"
:pt="pt"
>
<template
v-if="hasSearchBox || showSelectedCount || hasClearButton"
#header
>
<div class="p-2 flex flex-col gap-y-4 pb-0">
<SearchBox
v-if="hasSearchBox"
v-model="searchQuery"
:has-border="true"
:place-holder="searchPlaceholder"
/>
<div class="flex items-center justify-between">
<span
v-if="showSelectedCount"
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
v-if="hasClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
@@ -42,7 +77,7 @@
</template>
</MultiSelect>
<!-- Selected count badge (unchanged) -->
<!-- Selected count badge -->
<div
v-if="selectedCount > 0"
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
@@ -58,22 +93,41 @@ import MultiSelect, {
} from 'primevue/multiselect'
import { computed } from 'vue'
const { label, options } = defineProps<{
label?: string
options: { name: string; value: string }[]
}>()
import SearchBox from '@/components/input/SearchBox.vue'
const selectedItems = defineModel<{ name: string; value: string }[]>({
import TextButton from '../button/TextButton.vue'
type Option = { name: string; value: string }
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Static options for the multiselect (when not using async search) */
options: Option[]
/** Show search box in the panel header */
hasSearchBox?: boolean
/** Show selected count text in the panel header */
showSelectedCount?: boolean
/** Show "Clear all" action in the panel header */
hasClearButton?: boolean
/** Placeholder for the search input */
searchPlaceholder?: string
}
const {
label,
options,
hasSearchBox = false,
showSelectedCount = false,
hasClearButton = false,
searchPlaceholder = 'Search...'
} = defineProps<Props>()
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery')
const selectedCount = computed(() => selectedItems.value.length)
/**
* Pure unstyled mode using only the PrimeVue PT API.
* All PrimeVue built-in checkboxes/headers are hidden via PT (no :deep hacks).
* Visual output matches the previous version exactly.
*/
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: [
@@ -97,19 +151,19 @@ const pt = computed(() => ({
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: { class: 'hidden' },
header: () => ({
class:
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
overlay:
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100',
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
list: {
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
},
// Option row hover tone identical
option:
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },

View File

@@ -1,8 +1,6 @@
<template>
<div
class="flex w-full items-center rounded-lg px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800"
>
<i-lucide:search class="text-neutral" />
<div :class="wrapperStyle">
<i-lucide:search :class="iconColorStyle" />
<InputText
v-model="searchQuery"
:placeholder="placeHolder || 'Search...'"
@@ -15,10 +13,21 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { defineModel } from 'vue'
import { computed, defineModel } from 'vue'
const { placeHolder } = defineProps<{
const { placeHolder, hasBorder = false } = defineProps<{
placeHolder?: string
hasBorder?: boolean
}>()
const searchQuery = defineModel<string>('')
const wrapperStyle = computed(() => {
return hasBorder
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
})
const iconColorStyle = computed(() => {
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
})
</script>

View File

@@ -99,7 +99,7 @@ const pt = computed(() => ({
overlay: {
class: [
// dropdown panel
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg'
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700'
]
},
list: {

View File

@@ -59,6 +59,10 @@
<div class="relative px-6 pt-2 pb-4 flex gap-2">
<MultiSelect
v-model="selectedFrameworks"
class="w-[250px]"
:has-search-box="true"
:show-selected-count="true"
:has-clear-button="true"
label="Select Frameworks"
:options="frameworkOptions"
/>

View File

@@ -16,7 +16,6 @@ import {
import { Point } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
@@ -775,17 +774,6 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
},
{
id: 'Comfy.Canvas.AddEditModelStep',
icon: 'pi pi-pen-to-square',
label: 'Add Edit Model Step',
versionAdded: '1.23.3',
function: async () => {
const node = app.canvas.selectedItems.values().next().value
if (!(node instanceof LGraphNode)) return
await addFluxKontextGroupNode(node)
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "إضافة خطوة تحرير النموذج"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "حذف العناصر المحددة"
},

View File

@@ -272,6 +272,7 @@
"category": "الفئة",
"choose_file_to_upload": "اختر ملفاً للرفع",
"clear": "مسح",
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"color": "اللون",
@@ -327,6 +328,8 @@
"installed": "مثبت",
"installing": "جارٍ التثبيت",
"interrupted": "تمت المقاطعة",
"itemSelected": "تم تحديد عنصر واحد",
"itemsSelected": "تم تحديد {selectedCount} عناصر",
"keybinding": "اختصار لوحة المفاتيح",
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
"learnMore": "اعرف المزيد",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "حول ComfyUI",
"Add Edit Model Step": "إضافة خطوة تعديل النموذج",
"Bottom Panel": "لوحة سفلية",
"Browse Templates": "تصفح القوالب",
"Bypass/Unbypass Selected Nodes": "تجاوز/إلغاء تجاوز العقد المحددة",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Browse Templates"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Add Edit Model Step"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Delete Selected Items"
},

View File

@@ -137,8 +137,11 @@
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"startRecording": "Start Recording",
@@ -973,7 +976,6 @@
"Restart": "Restart",
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Browse Templates": "Browse Templates",
"Add Edit Model Step": "Add Edit Model Step",
"Delete Selected Items": "Delete Selected Items",
"Zoom to fit": "Zoom to fit",
"Move Selected Nodes Down": "Move Selected Nodes Down",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Explorar plantillas"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Agregar paso de edición de modelo"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Eliminar elementos seleccionados"
},

View File

@@ -272,6 +272,7 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
@@ -327,6 +328,8 @@
"installed": "Instalado",
"installing": "Instalando",
"interrupted": "Interrumpido",
"itemSelected": "{selectedCount} elemento seleccionado",
"itemsSelected": "{selectedCount} elementos seleccionados",
"keybinding": "Combinación de teclas",
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
"learnMore": "Aprende más",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "Acerca de ComfyUI",
"Add Edit Model Step": "Agregar paso de edición de modelo",
"Bottom Panel": "Panel inferior",
"Browse Templates": "Explorar plantillas",
"Bypass/Unbypass Selected Nodes": "Evitar/No evitar nodos seleccionados",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Ajouter/Modifier une étape de modèle"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Supprimer les éléments sélectionnés"
},

View File

@@ -272,6 +272,7 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"clearAll": "Tout effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
@@ -327,6 +328,8 @@
"installed": "Installé",
"installing": "Installation",
"interrupted": "Interrompu",
"itemSelected": "{selectedCount} élément sélectionné",
"itemsSelected": "{selectedCount} éléments sélectionnés",
"keybinding": "Raccourci clavier",
"keybindingAlreadyExists": "Le raccourci clavier existe déjà",
"learnMore": "En savoir plus",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "À propos de ComfyUI",
"Add Edit Model Step": "Ajouter une étape dédition de modèle",
"Bottom Panel": "Panneau inférieur",
"Browse Templates": "Parcourir les modèles",
"Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nœuds sélectionnés",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "編集モデルステップを追加"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "選択したアイテムを削除"
},

View File

@@ -272,6 +272,7 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"clearAll": "すべてクリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
@@ -327,6 +328,8 @@
"installed": "インストール済み",
"installing": "インストール中",
"interrupted": "中断されました",
"itemSelected": "{selectedCount}件選択済み",
"itemsSelected": "{selectedCount}件選択済み",
"keybinding": "キーバインディング",
"keybindingAlreadyExists": "このキー割り当てはすでに存在します",
"learnMore": "詳細を学ぶ",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "ComfyUIについて",
"Add Edit Model Step": "モデル編集ステップを追加",
"Bottom Panel": "下部パネル",
"Browse Templates": "テンプレートを参照",
"Bypass/Unbypass Selected Nodes": "選択したノードのバイパス/バイパス解除",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "모델 편집 단계 추가"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "선택한 항목 삭제"
},

View File

@@ -272,6 +272,7 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"clearAll": "모두 지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
@@ -327,6 +328,8 @@
"installed": "설치됨",
"installing": "설치 중",
"interrupted": "중단됨",
"itemSelected": "{selectedCount}개 선택됨",
"itemsSelected": "{selectedCount}개 선택됨",
"keybinding": "키 바인딩",
"keybindingAlreadyExists": "단축키가 이미 존재합니다",
"learnMore": "더 알아보기",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "ComfyUI에 대하여",
"Add Edit Model Step": "모델 편집 단계 추가",
"Bottom Panel": "하단 패널",
"Browse Templates": "템플릿 탐색",
"Bypass/Unbypass Selected Nodes": "선택한 노드 우회/우회 해제",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "Добавить или изменить шаг модели"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "Удалить выбранные элементы"
},

View File

@@ -272,6 +272,7 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"clearAll": "Очистить всё",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
@@ -327,6 +328,8 @@
"installed": "Установлено",
"installing": "Установка",
"interrupted": "Прервано",
"itemSelected": "Выбран {selectedCount} элемент",
"itemsSelected": "Выбрано {selectedCount} элементов",
"keybinding": "Привязка клавиш",
"keybindingAlreadyExists": "Горячая клавиша уже существует",
"learnMore": "Узнать больше",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "О ComfyUI",
"Add Edit Model Step": "Добавить или изменить шаг модели",
"Bottom Panel": "Нижняя панель",
"Browse Templates": "Просмотреть шаблоны",
"Bypass/Unbypass Selected Nodes": "Обойти/восстановить выбранные ноды",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "新增編輯模型步驟"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "刪除選取項目"
},

View File

@@ -272,6 +272,7 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
@@ -327,6 +328,8 @@
"installed": "已安裝",
"installing": "安裝中",
"interrupted": "已中斷",
"itemSelected": "已選取 {selectedCount} 項",
"itemsSelected": "已選取 {selectedCount} 項",
"keybinding": "快捷鍵",
"keybindingAlreadyExists": "快捷鍵已存在於",
"learnMore": "了解更多",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "關於 ComfyUI",
"Add Edit Model Step": "新增編輯模型步驟",
"Bottom Panel": "底部面板",
"Browse Templates": "瀏覽範本",
"Bypass/Unbypass Selected Nodes": "繞過/取消繞過選取節點",

View File

@@ -41,9 +41,6 @@
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "添加编辑模型步骤"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "删除选定的项目"
},

View File

@@ -272,6 +272,7 @@
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"clearAll": "全部清除",
"clearFilters": "清除筛选",
"close": "关闭",
"color": "颜色",
@@ -327,6 +328,8 @@
"installed": "已安装",
"installing": "正在安装",
"interrupted": "已中断",
"itemSelected": "已选择 {selectedCount} 项",
"itemsSelected": "已选择 {selectedCount} 项",
"keybinding": "按键绑定",
"keybindingAlreadyExists": "快捷键已存在",
"learnMore": "了解更多",
@@ -762,7 +765,6 @@
},
"menuLabels": {
"About ComfyUI": "关于ComfyUI",
"Add Edit Model Step": "添加编辑模型步骤",
"Bottom Panel": "底部面板",
"Browse Templates": "浏览模板",
"Bypass/Unbypass Selected Nodes": "忽略/取消忽略选定节点",

View File

@@ -1,693 +0,0 @@
import _ from 'es-toolkit/compat'
import {
type INodeOutputSlot,
type LGraph,
type LGraphNode,
LLink,
LiteGraph,
type Point
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { parseFilePath } from '@/utils/formatUtil'
import { app } from './app'
const fluxKontextGroupNode = {
nodes: [
{
id: -1,
type: 'Reroute',
pos: [2354.87890625, -127.23468780517578],
size: [75, 26],
flags: {},
order: 20,
mode: 0,
inputs: [{ name: '', type: '*', link: null }],
outputs: [{ name: '', type: '*', links: null }],
properties: { showOutputText: false, horizontal: false },
index: 0
},
{
id: -1,
type: 'ReferenceLatent',
pos: [2730, -220],
size: [197.712890625, 46],
flags: {},
order: 22,
mode: 0,
inputs: [
{
localized_name: 'conditioning',
name: 'conditioning',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'latent',
name: 'latent',
shape: 7,
type: 'LATENT',
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
links: []
}
],
properties: {
'Node name for S&R': 'ReferenceLatent',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
index: 1
},
{
id: -1,
type: 'VAEDecode',
pos: [3270, -110],
size: [210, 46],
flags: {},
order: 25,
mode: 0,
inputs: [
{
localized_name: 'samples',
name: 'samples',
type: 'LATENT',
link: null
},
{
localized_name: 'vae',
name: 'vae',
type: 'VAE',
link: null
}
],
outputs: [
{
localized_name: 'IMAGE',
name: 'IMAGE',
type: 'IMAGE',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'VAEDecode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
index: 2
},
{
id: -1,
type: 'KSampler',
pos: [2930, -110],
size: [315, 262],
flags: {},
order: 24,
mode: 0,
inputs: [
{
localized_name: 'model',
name: 'model',
type: 'MODEL',
link: null
},
{
localized_name: 'positive',
name: 'positive',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'negative',
name: 'negative',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'latent_image',
name: 'latent_image',
type: 'LATENT',
link: null
},
{
localized_name: 'seed',
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null
},
{
localized_name: 'steps',
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: null
},
{
localized_name: 'cfg',
name: 'cfg',
type: 'FLOAT',
widget: { name: 'cfg' },
link: null
},
{
localized_name: 'sampler_name',
name: 'sampler_name',
type: 'COMBO',
widget: { name: 'sampler_name' },
link: null
},
{
localized_name: 'scheduler',
name: 'scheduler',
type: 'COMBO',
widget: { name: 'scheduler' },
link: null
},
{
localized_name: 'denoise',
name: 'denoise',
type: 'FLOAT',
widget: { name: 'denoise' },
link: null
}
],
outputs: [
{
localized_name: 'LATENT',
name: 'LATENT',
type: 'LATENT',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'KSampler',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [972054013131369, 'fixed', 20, 1, 'euler', 'simple', 1],
index: 3
},
{
id: -1,
type: 'FluxGuidance',
pos: [2940, -220],
size: [211.60000610351562, 58],
flags: {},
order: 23,
mode: 0,
inputs: [
{
localized_name: 'conditioning',
name: 'conditioning',
type: 'CONDITIONING',
link: null
},
{
localized_name: 'guidance',
name: 'guidance',
type: 'FLOAT',
widget: { name: 'guidance' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
properties: {
'Node name for S&R': 'FluxGuidance',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [2.5],
index: 4
},
{
id: -1,
type: 'SaveImage',
pos: [3490, -110],
size: [985.3012084960938, 1060.3828125],
flags: {},
order: 26,
mode: 0,
inputs: [
{
localized_name: 'images',
name: 'images',
type: 'IMAGE',
link: null
},
{
localized_name: 'filename_prefix',
name: 'filename_prefix',
type: 'STRING',
widget: { name: 'filename_prefix' },
link: null
}
],
outputs: [],
properties: { cnr_id: 'comfy-core', ver: '0.3.38' },
widgets_values: ['ComfyUI'],
index: 5
},
{
id: -1,
type: 'CLIPTextEncode',
pos: [2500, -110],
size: [422.84503173828125, 164.31304931640625],
flags: {},
order: 12,
mode: 0,
inputs: [
{
localized_name: 'clip',
name: 'clip',
type: 'CLIP',
link: null
},
{
localized_name: 'text',
name: 'text',
type: 'STRING',
widget: { name: 'text' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
title: 'CLIP Text Encode (Positive Prompt)',
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['there is a bright light'],
color: '#232',
bgcolor: '#353',
index: 6
},
{
id: -1,
type: 'CLIPTextEncode',
pos: [2504.1435546875, 97.9598617553711],
size: [422.84503173828125, 164.31304931640625],
flags: { collapsed: true },
order: 13,
mode: 0,
inputs: [
{
localized_name: 'clip',
name: 'clip',
type: 'CLIP',
link: null
},
{
localized_name: 'text',
name: 'text',
type: 'STRING',
widget: { name: 'text' },
link: null
}
],
outputs: [
{
localized_name: 'CONDITIONING',
name: 'CONDITIONING',
type: 'CONDITIONING',
slot_index: 0,
links: []
}
],
title: 'CLIP Text Encode (Negative Prompt)',
properties: {
'Node name for S&R': 'CLIPTextEncode',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [''],
color: '#322',
bgcolor: '#533',
index: 7
},
{
id: -1,
type: 'UNETLoader',
pos: [2630, -370],
size: [270, 82],
flags: {},
order: 6,
mode: 0,
inputs: [
{
localized_name: 'unet_name',
name: 'unet_name',
type: 'COMBO',
widget: { name: 'unet_name' },
link: null
},
{
localized_name: 'weight_dtype',
name: 'weight_dtype',
type: 'COMBO',
widget: { name: 'weight_dtype' },
link: null
}
],
outputs: [
{
localized_name: 'MODEL',
name: 'MODEL',
type: 'MODEL',
links: []
}
],
properties: {
'Node name for S&R': 'UNETLoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['flux1-kontext-dev.safetensors', 'default'],
color: '#223',
bgcolor: '#335',
index: 8
},
{
id: -1,
type: 'DualCLIPLoader',
pos: [2100, -290],
size: [337.76861572265625, 130],
flags: {},
order: 8,
mode: 0,
inputs: [
{
localized_name: 'clip_name1',
name: 'clip_name1',
type: 'COMBO',
widget: { name: 'clip_name1' },
link: null
},
{
localized_name: 'clip_name2',
name: 'clip_name2',
type: 'COMBO',
widget: { name: 'clip_name2' },
link: null
},
{
localized_name: 'type',
name: 'type',
type: 'COMBO',
widget: { name: 'type' },
link: null
},
{
localized_name: 'device',
name: 'device',
shape: 7,
type: 'COMBO',
widget: { name: 'device' },
link: null
}
],
outputs: [
{
localized_name: 'CLIP',
name: 'CLIP',
type: 'CLIP',
links: []
}
],
properties: {
'Node name for S&R': 'DualCLIPLoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: [
'clip_l.safetensors',
't5xxl_fp8_e4m3fn_scaled.safetensors',
'flux',
'default'
],
color: '#223',
bgcolor: '#335',
index: 9
},
{
id: -1,
type: 'VAELoader',
pos: [2960, -370],
size: [270, 58],
flags: {},
order: 7,
mode: 0,
inputs: [
{
localized_name: 'vae_name',
name: 'vae_name',
type: 'COMBO',
widget: { name: 'vae_name' },
link: null
}
],
outputs: [
{
localized_name: 'VAE',
name: 'VAE',
type: 'VAE',
links: []
}
],
properties: {
'Node name for S&R': 'VAELoader',
cnr_id: 'comfy-core',
ver: '0.3.38'
},
widgets_values: ['ae.safetensors'],
color: '#223',
bgcolor: '#335',
index: 10
}
],
links: [
[6, 0, 1, 0, 72, 'CONDITIONING'],
[0, 0, 1, 1, 66, '*'],
[3, 0, 2, 0, 69, 'LATENT'],
[10, 0, 2, 1, 76, 'VAE'],
[8, 0, 3, 0, 74, 'MODEL'],
[4, 0, 3, 1, 70, 'CONDITIONING'],
[7, 0, 3, 2, 73, 'CONDITIONING'],
[0, 0, 3, 3, 66, '*'],
[1, 0, 4, 0, 67, 'CONDITIONING'],
[2, 0, 5, 0, 68, 'IMAGE'],
[9, 0, 6, 0, 75, 'CLIP'],
[9, 0, 7, 0, 75, 'CLIP']
],
external: [],
config: {
'0': {},
'1': {},
'2': { output: { '0': { visible: true } } },
'3': {
output: { '0': { visible: true } },
input: {
denoise: { visible: false },
cfg: { visible: false }
}
},
'4': {},
'5': {},
'6': {},
'7': { input: { text: { visible: false } } },
'8': { input: { weight_dtype: { visible: false } } },
'9': { input: { type: { visible: false }, device: { visible: false } } },
'10': {}
}
}
export async function ensureGraphHasFluxKontextGroupNode(
graph: LGraph & { extra: { groupNodes?: Record<string, any> } }
) {
graph.extra ??= {}
graph.extra.groupNodes ??= {}
if (graph.extra.groupNodes['FLUX.1 Kontext Image Edit']) return
graph.extra.groupNodes['FLUX.1 Kontext Image Edit'] =
structuredClone(fluxKontextGroupNode)
// Lazy import to avoid circular dependency issues
const { GroupNodeConfig } = await import('@/extensions/core/groupNode')
await GroupNodeConfig.registerFromWorkflow(
{
'FLUX.1 Kontext Image Edit':
graph.extra.groupNodes['FLUX.1 Kontext Image Edit']
},
[]
)
}
export async function addFluxKontextGroupNode(fromNode: LGraphNode) {
const { canvas } = app
const { graph } = canvas
if (!graph) throw new TypeError('Graph is not initialized')
await ensureGraphHasFluxKontextGroupNode(graph)
const node = LiteGraph.createNode('workflow>FLUX.1 Kontext Image Edit')
if (!node) throw new TypeError('Failed to create node')
const pos = getPosToRightOfNode(fromNode)
graph.add(node)
node.pos = pos
app.canvas.processSelect(node, undefined)
connectPreviousLatent(fromNode, node)
const symb = Object.getOwnPropertySymbols(node)[0]
// @ts-expect-error It's there -- promise.
node[symb].populateWidgets()
setWidgetValues(node)
}
function setWidgetValues(node: LGraphNode) {
const seedInput = node.widgets?.find((x) => x.name === 'seed')
if (!seedInput) throw new TypeError('Seed input not found')
seedInput.value = Math.floor(Math.random() * 1_125_899_906_842_624)
const firstClip = node.widgets?.find((x) => x.name === 'clip_name1')
setPreferredValue('t5xxl_fp8_e4m3fn_scaled.safetensors', 't5xxl', firstClip)
const secondClip = node.widgets?.find((x) => x.name === 'clip_name2')
setPreferredValue('clip_l.safetensors', 'clip_l', secondClip)
const unet = node.widgets?.find((x) => x.name === 'unet_name')
setPreferredValue('flux1-dev-kontext_fp8_scaled.safetensors', 'kontext', unet)
const vae = node.widgets?.find((x) => x.name === 'vae_name')
setPreferredValue('ae.safetensors', 'ae.s', vae)
}
function setPreferredValue(
preferred: string,
match: string,
widget: IBaseWidget | undefined
): void {
if (!widget) throw new TypeError('Widget not found')
const { values } = widget.options
if (!Array.isArray(values)) return
// Match against filename portion only
const mapped = values.map((x) => parseFilePath(x).filename)
const value =
mapped.find((x) => x === preferred) ??
mapped.find((x) => x.includes?.(match))
widget.value = value ?? preferred
}
function getPosToRightOfNode(fromNode: LGraphNode) {
const nodes = app.canvas.graph?.nodes
if (!nodes) throw new TypeError('Could not get graph nodes')
const pos = [
fromNode.pos[0] + fromNode.size[0] + 100,
fromNode.pos[1]
] satisfies Point
while (nodes.find((x) => isPointTooClose(x.pos, pos))) {
pos[0] += 20
pos[1] += 20
}
return pos
}
function connectPreviousLatent(fromNode: LGraphNode, toEditNode: LGraphNode) {
const { canvas } = app
const { graph } = canvas
if (!graph) throw new TypeError('Graph is not initialized')
const l = findNearestOutputOfType([fromNode], 'LATENT')
if (!l) {
const imageOutput = findNearestOutputOfType([fromNode], 'IMAGE')
if (!imageOutput) throw new TypeError('No image output found')
const vaeEncode = LiteGraph.createNode('VAEEncode')
if (!vaeEncode) throw new TypeError('Failed to create node')
const { node: imageNode, index: imageIndex } = imageOutput
graph.add(vaeEncode)
vaeEncode.pos = getPosToRightOfNode(fromNode)
vaeEncode.pos[1] -= 200
vaeEncode.connect(0, toEditNode, 0)
imageNode.connect(imageIndex, vaeEncode, 0)
return
}
const { node, index } = l
node.connect(index, toEditNode, 0)
}
function getInputNodes(node: LGraphNode): LGraphNode[] {
return node.inputs
.map((x) => LLink.resolve(x.link, app.graph)?.outputNode)
.filter((x) => !!x)
}
function getOutputOfType(
node: LGraphNode,
type: string
): {
output: INodeOutputSlot
index: number
} {
const index = node.outputs.findIndex((x) => x.type === type)
const output = node.outputs[index]
return { output, index }
}
function findNearestOutputOfType(
nodes: Iterable<LGraphNode>,
type: string = 'LATENT',
depth: number = 0
): { node: LGraphNode; index: number } | undefined {
for (const node of nodes) {
const { output, index } = getOutputOfType(node, type)
if (output) return { node, index }
}
if (depth < 3) {
const closestNodes = new Set([...nodes].flatMap((x) => getInputNodes(x)))
const res = findNearestOutputOfType(closestNodes, type, depth + 1)
if (res) return res
}
}
function isPointTooClose(a: Point, b: Point, precision: number = 5) {
return Math.abs(a[0] - b[0]) < precision && Math.abs(a[1] - b[1]) < precision
}

View File

@@ -1,13 +1,14 @@
import type { HTMLAttributes } from 'vue'
export interface BaseButtonProps {
size?: 'sm' | 'md'
size?: 'fit-content' | 'sm' | 'md'
type?: 'primary' | 'secondary' | 'transparent'
class?: HTMLAttributes['class']
}
export const getButtonSizeClasses = (size: BaseButtonProps['size'] = 'md') => {
const sizeClasses = {
'fit-content': '',
sm: 'px-2 py-1.5 text-xs',
md: 'px-2.5 py-2 text-sm'
}
@@ -31,6 +32,7 @@ export const getIconButtonSizeClasses = (
size: BaseButtonProps['size'] = 'md'
) => {
const sizeClasses = {
'fit-content': 'w-auto h-auto',
sm: 'w-6 h-6 text-xs !rounded-md',
md: 'w-8 h-8 text-sm'
}