mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Implement subgraph publishing (#5139)
* Implement subgraph publishing * Add missing null check * Fix subgraph blueprint display in workflows tab * Fix demotion of subgraph blueprints on reload * Update locales [skip ci] * Update blueprint def on save, cleanup * Fix skipped tracking on subgraph publish When a subgraph is first published, it previously was not added to the subgraphCache. This would cause deletion to fail until a reload occurred. * Fix failing vite tests A couple of tests that were mocking classes broke SubgraphBlueprint inheritance. Since they aren't testing anythign related to subgraph blueprints, the subgraph store is mocked as well. * Make blueprint breadcrumb badge clickable * Add confirmation for overwrite on publish * Simplify blueprint badge naming * Swap to promise.allSettled when fetching subgraphs * Navigate into subgraph on blueprint edit * Revert mission of value in blueprint breadcrumb This was causing the blueprint badge to always display * Misc code quality fixes * Set subgraphNode title on blueprint add. When a subgraph blueprint is added to the graph, the title of the subgraphNode is now set to be the title of the blueprint. NOTE: The name of the subgraph node when a blueprint is edited is left unchanged. This may cause minor user confusion. * Add "Delete Blueprint" option to breadcrumb When editing a blueprint, the options provided for the root graph of the breadcrumb included a Delete Workflow option. This still functioned for deleting the current blueprint when selected, but didn't make sense. It has been updated to instead describe that it deletes the current blueprint * Extract subgraph load code as function * Fix subgraphs appearing in library after refresh Subgraph nodes were hidden from the node library and context menu by setting skip_list to true. Unfortunately, this causes them to be mistakenly be caught and registered as vue nodes when a refresh is performed. This is fixed by adding a check for skip_list. * Add delete button and confirmation for deletion * Use more specific warning for blueprint deletion * At success toast on subgraph publish Will return later to potentially add a node library link to the toast * Don't apply subgraph context menu to normal nodes Subgraph blueprints have a right click -> delete option in the node library. This was incorrectly being dislplayed on non blueprint nodes. * Remove hardcoded subgraphs path Rather happy with this change. Rather than trying to introduce a recursive import to pass a magic string, this solution is both sufficient AND allows potential future extensions with less breakage. * Fix nodeDef update on save Wait to update the node def cache until after a blueprint has been saved. Before, changes to links weren't actually being made visisble. * Fix SaveAs with subgraph blueprints * Remove ugly serialize/deserialize Thought I had already tested this, and found that the mere existence of proxies was causing issues, but simply adding a correct annotation is sufficient now. * Improve error specificity * Framework for user defined blueprint descriptions BlueprintDescription can be added to a workflows extra field to provide more useful information about a blueprint's purpose Actually hooking this up in a way that is user accessible is out of scope for right now, but this will simplify future implementation. * Cleanup breadcrumb dropdown options Removes Dupliate for blueprints, adds a publish subgraph option. The publish subgraph button currently routes through the save as logic. Unforunately, this results in the prompt for name referencing workflows. The cleanest way to resolve this is still being considered * Move blueprint renaming into blueprint load Blueprints should automatically set the name of the added node to the filename when added. This mostly worked, but created uglier edgecases: The subgraph itself wasn't renamed, and it would need to be reimplemented to apply when editing a blueprint. Instead, this is now applied when a subgraphBlueprint is first loaded. This keeps all the logic routed through a single point * Move saveAs prompt into workflow class Ensures that the correct publish text is displayed when editing blueprints without making an awful mess of imports * Fix tests by making subgraphBlueprint internal This has the added benefit of forcing better organization. Reverts the useWorkflowThumbnail patch as it is no longer required. * Add tests for subgraph blueprints * Rewrite confirmation dialog * Fix overwrite on publish new subgraph 1 is used as a placeholder size as -1 indicates the baking userFile is temporary, not persisted, and therefore, not able to overwrite when saved. * When editing blueprint, tint background blue * Fix blueprint tint at low LOD * Set node source for blueprints to Blueprint * Fix publish test Making subgraph blueprints non temporary on publish made it so the following load actually occurs. A mock has been added for this load. * Fix multiple nits * Further cleanup: error handling, and comments * Fixing failing test cases This also moves the bg tinting to a property of the workflow, which makes things more extensible in the future. * Fix temporary marking on publish. The prior fix to allow overwrite of an existing blueprint on publish was misguided. By marking a not-yet-loaded file as non-temporary, the load performed prior to saving was actually fetching the file off disk and discarding the existing changes. This additionally entirely prevented publishing when a blueprint did not already exist with the current name. To fix this, the blueprint is not marked as non-temporary until after the load occurs. Note that this load is still required as it initializes the change tracker state required for saving. * Block unloading subgraph blueprints Will need to be revisited if lazy loading is implemented, but this requires solving some ugly sync/async issues. --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -40,6 +40,7 @@ import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbIt
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
@@ -52,6 +53,9 @@ const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const isBlueprint = computed(() =>
|
||||
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
|
||||
)
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
@@ -89,6 +93,7 @@ const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
isBlueprint: isBlueprint.value,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
@@ -48,6 +49,7 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -121,7 +123,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: isRoot
|
||||
visible: isRoot && !props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
@@ -153,12 +155,26 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root' && props.item.isBlueprint
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
label: props.item.isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
|
||||
@@ -16,6 +16,21 @@
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex gap-4 justify-start"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex gap-4 justify-start"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
<label for="doNotAskAgain" severity="secondary">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="$t('g.cancel')"
|
||||
icon="pi pi-undo"
|
||||
@@ -38,7 +53,7 @@
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="type === 'overwrite'"
|
||||
v-else-if="type === 'overwrite' || type === 'overwriteBlueprint'"
|
||||
:label="$t('g.overwrite')"
|
||||
severity="warn"
|
||||
icon="pi pi-save"
|
||||
@@ -74,10 +89,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
@@ -87,14 +106,20 @@ const props = defineProps<{
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const onCancel = () => useDialogStore().closeDialog()
|
||||
|
||||
const doNotAskAgain = ref(false)
|
||||
|
||||
const onDeny = () => {
|
||||
props.onConfirm(false)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
|
||||
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
|
||||
props.onConfirm(true)
|
||||
useDialogStore().closeDialog()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<Load3DViewerButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<PublishSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshSelectionButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -49,6 +50,7 @@ import Load3DViewerButton from '@/components/graph/selectionToolbox/Load3DViewer
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
|
||||
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_PublishSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.PublishSubgraph')"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:book-open />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.selectedItems?.length === 1 &&
|
||||
canvasStore.selectedItems[0] instanceof SubgraphNode
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="container" class="node-lib-node-container">
|
||||
<TreeExplorerTreeNode :node="node">
|
||||
<TreeExplorerTreeNode :node="node" @contextmenu="handleContextMenu">
|
||||
<template #before-label>
|
||||
<Tag
|
||||
v-if="nodeDef.experimental"
|
||||
@@ -13,7 +13,30 @@
|
||||
severity="danger"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template
|
||||
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
|
||||
#actions
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="danger"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="editBlueprint"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:square-pen />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
<Button
|
||||
class="bookmark-button"
|
||||
size="small"
|
||||
@@ -40,10 +63,13 @@
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import Tag from 'primevue/tag'
|
||||
import {
|
||||
CSSProperties,
|
||||
@@ -53,14 +79,18 @@ import {
|
||||
onUnmounted,
|
||||
ref
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
@@ -80,6 +110,33 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
const toggleBookmark = async () => {
|
||||
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
const editBlueprint = async () => {
|
||||
if (!props.node.data)
|
||||
throw new Error(
|
||||
'Failed to edit subgraph blueprint lacking backing node data'
|
||||
)
|
||||
await useSubgraphStore().editBlueprint(props.node.data.name)
|
||||
}
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
severity: 'error',
|
||||
command: deleteBlueprint
|
||||
}
|
||||
]
|
||||
return items
|
||||
})
|
||||
function handleContextMenu(event: Event) {
|
||||
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
|
||||
menu.value?.show(event)
|
||||
}
|
||||
function deleteBlueprint() {
|
||||
if (!props.node.data) return
|
||||
void useSubgraphStore().deleteBlueprint(props.node.data.name)
|
||||
}
|
||||
|
||||
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
|
||||
const nodePreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { type ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
@@ -111,6 +112,15 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.PublishSubgraph',
|
||||
icon: 'pi pi-save',
|
||||
label: 'Publish Subgraph',
|
||||
menubarLabel: 'Publish',
|
||||
function: async () => {
|
||||
await useSubgraphStore().publishSubgraph()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.SaveWorkflowAs',
|
||||
icon: 'pi pi-save',
|
||||
|
||||
@@ -186,6 +186,12 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Workflow.WarnBlueprintOverwrite',
|
||||
name: 'Require confirmation to overwrite an existing subgraph blueprint',
|
||||
type: 'boolean',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ZoomSpeed',
|
||||
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],
|
||||
|
||||
@@ -667,6 +667,7 @@ export class LGraphCanvas
|
||||
_bg_img?: HTMLImageElement
|
||||
_pattern?: CanvasPattern
|
||||
_pattern_img?: HTMLImageElement
|
||||
bg_tint?: string | CanvasGradient | CanvasPattern
|
||||
// TODO: This looks like another panel thing
|
||||
prompt_box?: PromptDialog | null
|
||||
search_box?: HTMLDivElement
|
||||
@@ -3754,13 +3755,7 @@ export class LGraphCanvas
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies canvas items to an internal, app-specific clipboard backed by local storage.
|
||||
* When called without parameters, it copies {@link selectedItems}.
|
||||
* @param items The items to copy. If nullish, all selected items are copied.
|
||||
*/
|
||||
copyToClipboard(items?: Iterable<Positionable>): void {
|
||||
_serializeItems(items?: Iterable<Positionable>): ClipboardItems {
|
||||
const serialisable: Required<ClipboardItems> = {
|
||||
nodes: [],
|
||||
groups: [],
|
||||
@@ -3818,10 +3813,18 @@ export class LGraphCanvas
|
||||
const cloned = subgraph.clone(true).asSerialisable()
|
||||
serialisable.subgraphs.push(cloned)
|
||||
}
|
||||
return serialisable
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies canvas items to an internal, app-specific clipboard backed by local storage.
|
||||
* When called without parameters, it copies {@link selectedItems}.
|
||||
* @param items The items to copy. If nullish, all selected items are copied.
|
||||
*/
|
||||
copyToClipboard(items?: Iterable<Positionable>): void {
|
||||
localStorage.setItem(
|
||||
'litegrapheditor_clipboard',
|
||||
JSON.stringify(serialisable)
|
||||
JSON.stringify(this._serializeItems(items))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3853,6 +3856,14 @@ export class LGraphCanvas
|
||||
*/
|
||||
_pasteFromClipboard(
|
||||
options: IPasteFromClipboardOptions = {}
|
||||
): ClipboardPasteResult | undefined {
|
||||
const data = localStorage.getItem('litegrapheditor_clipboard')
|
||||
if (!data) return
|
||||
return this._deserializeItems(JSON.parse(data), options)
|
||||
}
|
||||
_deserializeItems(
|
||||
parsed: ClipboardItems,
|
||||
options: IPasteFromClipboardOptions
|
||||
): ClipboardPasteResult | undefined {
|
||||
const { connectInputs = false, position = this.graph_mouse } = options
|
||||
|
||||
@@ -3863,15 +3874,11 @@ export class LGraphCanvas
|
||||
)
|
||||
return
|
||||
|
||||
const data = localStorage.getItem('litegrapheditor_clipboard')
|
||||
if (!data) return
|
||||
|
||||
const { graph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
graph.beforeChange()
|
||||
|
||||
// Parse & initialise
|
||||
const parsed: ClipboardItems = JSON.parse(data)
|
||||
parsed.nodes ??= []
|
||||
parsed.groups ??= []
|
||||
parsed.reroutes ??= []
|
||||
@@ -5093,6 +5100,16 @@ export class LGraphCanvas
|
||||
ctx.globalAlpha = 1.0
|
||||
ctx.imageSmoothingEnabled = true
|
||||
}
|
||||
if (this.bg_tint) {
|
||||
ctx.fillStyle = this.bg_tint
|
||||
ctx.fillRect(
|
||||
this.visible_area[0],
|
||||
this.visible_area[1],
|
||||
this.visible_area[2],
|
||||
this.visible_area[3]
|
||||
)
|
||||
ctx.fillStyle = 'transparent'
|
||||
}
|
||||
|
||||
// groups
|
||||
if (this.graph._groups.length) {
|
||||
|
||||
@@ -197,6 +197,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "فتح سير عمل"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "نشر الرسم البياني الفرعي"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "إضافة الأمر إلى قائمة الانتظار"
|
||||
},
|
||||
|
||||
@@ -813,6 +813,7 @@
|
||||
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
|
||||
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
|
||||
"Previous Opened Workflow": "سير العمل السابق المفتوح",
|
||||
"Publish": "نشر",
|
||||
"Queue Panel": "لوحة الانتظار",
|
||||
"Queue Prompt": "قائمة انتظار التعليمات",
|
||||
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
|
||||
@@ -1255,6 +1256,10 @@
|
||||
},
|
||||
"workflows": "سير العمل"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "اسم المخطط الفرعي",
|
||||
"saveBlueprint": "احفظ المخطط الفرعي في المكتبة"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "إضافة إلى العلامات",
|
||||
"closeOtherTabs": "إغلاق التبويبات الأخرى",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Open Workflow"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Publish Subgraph"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Queue Prompt"
|
||||
},
|
||||
|
||||
@@ -949,6 +949,18 @@
|
||||
"enterFilename": "Enter the filename",
|
||||
"saveWorkflow": "Save workflow"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"confirmDeleteTitle": "Delete blueprint?",
|
||||
"confirmDelete": "This action will permanently remove the blueprint from your library",
|
||||
"saveBlueprint": "Save Subgraph to Library",
|
||||
"overwriteBlueprintTitle": "Overwrite existing blueprint?",
|
||||
"overwriteBlueprint": "Saving will overwrite the current blueprint with your changes",
|
||||
"blueprintName": "Subgraph name",
|
||||
"publish": "Publish Subgraph",
|
||||
"publishSuccess": "Saved to Nodes Library",
|
||||
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
|
||||
"loadFailure": "Failed to load subgraph blueprints"
|
||||
},
|
||||
"electronFileDownload": {
|
||||
"inProgress": "In Progress",
|
||||
"pause": "Pause Download",
|
||||
@@ -1078,6 +1090,7 @@
|
||||
"Clipspace": "Clipspace",
|
||||
"Manager": "Manager",
|
||||
"Open": "Open",
|
||||
"Publish": "Publish",
|
||||
"Queue Prompt": "Queue Prompt",
|
||||
"Queue Prompt (Front)": "Queue Prompt (Front)",
|
||||
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
|
||||
@@ -1760,6 +1773,7 @@
|
||||
"duplicate": "Duplicate",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
"enterNewName": "Enter new name"
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -1783,4 +1797,4 @@
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Abrir Flujo de Trabajo"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Publicar subgrafo"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Prompt de Cola"
|
||||
},
|
||||
|
||||
@@ -1223,6 +1223,10 @@
|
||||
},
|
||||
"workflows": "Flujos de trabajo"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "Nombre del subgrafo",
|
||||
"saveBlueprint": "Guardar subgrafo en la biblioteca"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "Agregar a marcadores",
|
||||
"closeOtherTabs": "Cerrar otras pestañas",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Ouvrir le flux de travail"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Publier le sous-graphe"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Invite de file d'attente"
|
||||
},
|
||||
|
||||
@@ -1224,6 +1224,10 @@
|
||||
},
|
||||
"workflows": "Flux de travail"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "Nom du sous-graphe",
|
||||
"saveBlueprint": "Enregistrer le sous-graphe dans la bibliothèque"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "Ajouter aux Favoris",
|
||||
"closeOtherTabs": "Fermer les autres onglets",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "ワークフローを開く"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "サブグラフを公開"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "キュープロンプト"
|
||||
},
|
||||
|
||||
@@ -1222,6 +1222,10 @@
|
||||
},
|
||||
"workflows": "ワークフロー"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "サブグラフ名",
|
||||
"saveBlueprint": "サブグラフをライブラリに保存"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "ブックマークに追加",
|
||||
"closeOtherTabs": "他のタブを閉じる",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "워크플로 열기"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "서브그래프 게시"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "실행 큐에 프롬프트 추가"
|
||||
},
|
||||
|
||||
@@ -1225,6 +1225,10 @@
|
||||
},
|
||||
"workflows": "워크플로"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "서브그래프 이름",
|
||||
"saveBlueprint": "서브그래프를 라이브러리에 저장"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "북마크에 추가",
|
||||
"closeOtherTabs": "다른 탭 닫기",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "Открыть рабочий процесс"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "Опубликовать подграф"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "Очередь запросов"
|
||||
},
|
||||
|
||||
@@ -1223,6 +1223,10 @@
|
||||
},
|
||||
"workflows": "Рабочие процессы"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "Имя подграфа",
|
||||
"saveBlueprint": "Сохранить подграф в библиотеку"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "Добавить в закладки",
|
||||
"closeOtherTabs": "Закрыть другие вкладки",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "開啟工作流程"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "發布子圖"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "將提示詞加入佇列"
|
||||
},
|
||||
|
||||
@@ -1211,6 +1211,10 @@
|
||||
},
|
||||
"workflows": "工作流程"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "子圖名稱",
|
||||
"saveBlueprint": "將子圖儲存到資料庫"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "加入書籤",
|
||||
"closeOtherTabs": "關閉其他分頁",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"Comfy_OpenWorkflow": {
|
||||
"label": "打开工作流"
|
||||
},
|
||||
"Comfy_PublishSubgraph": {
|
||||
"label": "发布子图"
|
||||
},
|
||||
"Comfy_QueuePrompt": {
|
||||
"label": "执行提示词"
|
||||
},
|
||||
|
||||
@@ -814,6 +814,7 @@
|
||||
"Pin/Unpin Selected Items": "固定/取消固定选定项目",
|
||||
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
|
||||
"Previous Opened Workflow": "上一个打开的工作流",
|
||||
"Publish": "发布",
|
||||
"Queue Panel": "队列面板",
|
||||
"Queue Prompt": "执行提示词",
|
||||
"Queue Prompt (Front)": "执行提示词 (优先执行)",
|
||||
@@ -1264,6 +1265,10 @@
|
||||
},
|
||||
"workflows": "工作流"
|
||||
},
|
||||
"subgraphStore": {
|
||||
"blueprintName": "子图名称",
|
||||
"saveBlueprint": "保存子图到库"
|
||||
},
|
||||
"tabMenu": {
|
||||
"addToBookmarks": "添加到书签",
|
||||
"closeOtherTabs": "关闭其他标签",
|
||||
|
||||
@@ -367,6 +367,7 @@ const zSettings = z.object({
|
||||
'Comfy.DevMode': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
|
||||
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
|
||||
'Comfy.DisableFloatRounding': z.boolean(),
|
||||
'Comfy.DisableSliders': z.boolean(),
|
||||
'Comfy.DOMClippingEnabled': z.boolean(),
|
||||
|
||||
@@ -53,6 +53,7 @@ import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useModelStore } from '@/stores/modelStore'
|
||||
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
@@ -797,6 +798,7 @@ export class ComfyApp {
|
||||
this.resizeCanvas()
|
||||
|
||||
await useWorkspaceStore().workflow.syncWorkflows()
|
||||
await useSubgraphStore().fetchSubgraphs()
|
||||
await useExtensionService().loadExtensions()
|
||||
|
||||
this.#addProcessKeyHandler()
|
||||
@@ -905,7 +907,7 @@ export class ComfyApp {
|
||||
LiteGraph.registered_node_types
|
||||
)) {
|
||||
// Skip if we already have a backend definition or system definition
|
||||
if (name in defs || name in SYSTEM_NODE_DEFS) {
|
||||
if (name in defs || name in SYSTEM_NODE_DEFS || node.skip_list) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
| 'overwrite'
|
||||
| 'overwriteBlueprint'
|
||||
| 'delete'
|
||||
| 'dirtyClose'
|
||||
| 'reinstall'
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -949,6 +950,25 @@ export const useLitegraphService = () => {
|
||||
): LGraphNode {
|
||||
options.pos ??= getCanvasCenter()
|
||||
|
||||
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const bp = useSubgraphStore().getBlueprint(nodeDef.name)
|
||||
const items: object = {
|
||||
nodes: bp.nodes,
|
||||
subgraphs: bp.definitions?.subgraphs
|
||||
}
|
||||
const results = canvas._deserializeItems(items, {
|
||||
position: options.pos
|
||||
})
|
||||
if (!results) throw new Error('Failed to add subgraph blueprint')
|
||||
const node = results.nodes.values().next().value
|
||||
if (!node)
|
||||
throw new Error(
|
||||
'Subgraph blueprint was added, but failed to resolve a subgraph Node'
|
||||
)
|
||||
return node
|
||||
}
|
||||
|
||||
const node = LiteGraph.createNode(
|
||||
nodeDef.name,
|
||||
nodeDef.display_name,
|
||||
|
||||
@@ -34,7 +34,7 @@ export class NodeSearchService {
|
||||
name: 'Input Type',
|
||||
invokeSequence: 'i',
|
||||
getItemOptions: (node) =>
|
||||
Object.values(node.inputs).map((input) => input.type),
|
||||
Object.values(node.inputs ?? []).map((input) => input.type),
|
||||
fuseOptions
|
||||
})
|
||||
|
||||
|
||||
@@ -82,15 +82,10 @@ export const useWorkflowService = () => {
|
||||
* @param workflow The workflow to save
|
||||
*/
|
||||
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
|
||||
const newFilename = await dialogService.prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: workflow.filename
|
||||
})
|
||||
const newFilename = await workflow.promptSave()
|
||||
if (!newFilename) return
|
||||
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
||||
const newKey = newPath.substring(ComfyWorkflow.basePath.length)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
|
||||
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
||||
@@ -122,7 +117,7 @@ export const useWorkflowService = () => {
|
||||
) as ComfyWorkflowJSON
|
||||
state.id = id
|
||||
|
||||
const tempWorkflow = workflowStore.createTemporary(newKey, state)
|
||||
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
||||
await openWorkflow(tempWorkflow)
|
||||
await workflowStore.saveWorkflow(tempWorkflow)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ComfyOutputTypesSpec as ComfyOutputSpecV1
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import {
|
||||
type NodeSource,
|
||||
NodeSourceType,
|
||||
@@ -291,8 +292,14 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
const showDeprecated = ref(false)
|
||||
const showExperimental = ref(false)
|
||||
const nodeDefFilters = ref<NodeDefFilter[]>([])
|
||||
const subgraphStore = useSubgraphStore()
|
||||
|
||||
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
|
||||
const nodeDefs = computed(() => {
|
||||
return [
|
||||
...Object.values(nodeDefsByName.value),
|
||||
...subgraphStore.subgraphBlueprints
|
||||
]
|
||||
})
|
||||
const nodeDataTypes = computed(() => {
|
||||
const types = new Set<string>()
|
||||
for (const nodeDef of nodeDefs.value) {
|
||||
@@ -383,7 +390,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
})
|
||||
|
||||
// Subgraph nodes filter
|
||||
// @todo Remove this filter when subgraph v2 is released
|
||||
// Filter out litegraph typed subgraphs, saved blueprints are added in separately
|
||||
registerNodeDefFilter({
|
||||
id: 'core.subgraph',
|
||||
name: 'Hide Subgraph Nodes',
|
||||
|
||||
321
src/stores/subgraphStore.ts
Normal file
321
src/stores/subgraphStore.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
ComfyNode,
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type {
|
||||
ComfyNodeDef as ComfyNodeDefV1,
|
||||
InputSpec
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
LoadedComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/stores/workflowStore'
|
||||
|
||||
async function confirmOverwrite(name: string): Promise<boolean | null> {
|
||||
return await useDialogService().confirm({
|
||||
title: t('subgraphStore.overwriteBlueprintTitle'),
|
||||
type: 'overwriteBlueprint',
|
||||
message: t('subgraphStore.overwriteBlueprint'),
|
||||
itemList: [name]
|
||||
})
|
||||
}
|
||||
|
||||
export const useSubgraphStore = defineStore('subgraph', () => {
|
||||
class SubgraphBlueprint extends ComfyWorkflow {
|
||||
static override readonly basePath = 'subgraphs/'
|
||||
override readonly tintCanvasBg = '#22227740'
|
||||
|
||||
hasPromptedSave: boolean = false
|
||||
|
||||
constructor(
|
||||
options: { path: string; modified: number; size: number },
|
||||
confirmFirstSave: boolean = false
|
||||
) {
|
||||
super(options)
|
||||
this.hasPromptedSave = !confirmFirstSave
|
||||
}
|
||||
|
||||
validateSubgraph() {
|
||||
if (!this.activeState?.definitions)
|
||||
throw new Error(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
const { subgraphs } = this.activeState.definitions
|
||||
const { nodes } = this.activeState
|
||||
//Instanceof doesn't function as nodes are serialized
|
||||
function isSubgraphNode(node: ComfyNode) {
|
||||
return node && subgraphs.some((s) => s.id === node.type)
|
||||
}
|
||||
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
|
||||
const errors: Record<NodeId, NodeError> = {}
|
||||
//mark errors for all but first subgraph node
|
||||
let firstSubgraphFound = false
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (!firstSubgraphFound && isSubgraphNode(nodes[i])) {
|
||||
firstSubgraphFound = true
|
||||
continue
|
||||
}
|
||||
errors[nodes[i].id] = {
|
||||
errors: [],
|
||||
class_type: nodes[i].type,
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
useExecutionStore().lastNodeErrors = errors
|
||||
useCanvasStore().getCanvas().draw(true, true)
|
||||
throw new Error(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
}
|
||||
|
||||
override async save(): Promise<UserFile> {
|
||||
this.validateSubgraph()
|
||||
if (
|
||||
!this.hasPromptedSave &&
|
||||
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
|
||||
) {
|
||||
if (!(await confirmOverwrite(this.filename))) return this
|
||||
this.hasPromptedSave = true
|
||||
}
|
||||
const ret = await super.save()
|
||||
useSubgraphStore().updateDef(await this.load())
|
||||
return ret
|
||||
}
|
||||
|
||||
override async saveAs(path: string) {
|
||||
this.validateSubgraph()
|
||||
this.hasPromptedSave = true
|
||||
const ret = await super.saveAs(path)
|
||||
useSubgraphStore().updateDef(await this.load())
|
||||
return ret
|
||||
}
|
||||
override async load({
|
||||
force = false
|
||||
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
|
||||
if (!force && this.isLoaded) return await super.load({ force })
|
||||
const loaded = await super.load({ force })
|
||||
const st = loaded.activeState
|
||||
const sg = (st.definitions?.subgraphs ?? []).find(
|
||||
(sg) => sg.id == st.nodes[0].type
|
||||
)
|
||||
if (!sg)
|
||||
throw new Error(
|
||||
'Loaded subgraph blueprint does not contain valid subgraph'
|
||||
)
|
||||
sg.name = st.nodes[0].title = this.filename
|
||||
return loaded
|
||||
}
|
||||
override async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('subgraphStore.saveBlueprint'),
|
||||
message: t('subgraphStore.blueprintName') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
override unload(): void {
|
||||
//Skip unloading. Even if a workflow is closed after editing,
|
||||
//it must remain loaded in order to be added to the graph
|
||||
}
|
||||
}
|
||||
const subgraphCache: Record<string, LoadedComfyWorkflow> = {}
|
||||
const typePrefix = 'SubgraphBlueprint.'
|
||||
const subgraphDefCache = ref<Map<string, ComfyNodeDefImpl>>(new Map())
|
||||
const canvasStore = useCanvasStore()
|
||||
const subgraphBlueprints = computed(() => [
|
||||
...subgraphDefCache.value.values()
|
||||
])
|
||||
async function fetchSubgraphs() {
|
||||
async function loadBlueprint(options: {
|
||||
path: string
|
||||
modified: number
|
||||
size: number
|
||||
}): Promise<void> {
|
||||
const name = options.path.slice(0, -'.json'.length)
|
||||
options.path = SubgraphBlueprint.basePath + options.path
|
||||
const bp = await new SubgraphBlueprint(options, true).load()
|
||||
useWorkflowStore().attachWorkflow(bp)
|
||||
const nodeDef = convertToNodeDef(bp)
|
||||
|
||||
subgraphDefCache.value.set(name, nodeDef)
|
||||
subgraphCache[name] = bp
|
||||
}
|
||||
|
||||
const res = (
|
||||
await api.listUserDataFullInfo(SubgraphBlueprint.basePath)
|
||||
).filter((f) => f.path.endsWith('.json'))
|
||||
const settled = await Promise.allSettled(res.map(loadBlueprint))
|
||||
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
|
||||
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
|
||||
if (errors.length > 0) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('subgraphStore.loadFailure'),
|
||||
detail: errors.length > 3 ? `x${errors.length}` : `${errors}`,
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
}
|
||||
function convertToNodeDef(workflow: LoadedComfyWorkflow): ComfyNodeDefImpl {
|
||||
const name = workflow.filename
|
||||
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
|
||||
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
|
||||
subgraphNode.inputs ??= []
|
||||
subgraphNode.outputs ??= []
|
||||
//NOTE: Types are cast to string. This is only used for input coloring on previews
|
||||
const inputs = Object.fromEntries(
|
||||
subgraphNode.inputs.map((i) => [
|
||||
i.name,
|
||||
[`${i.type}`, undefined] satisfies InputSpec
|
||||
])
|
||||
)
|
||||
let description = 'User generated subgraph blueprint'
|
||||
if (workflow.initialState.extra?.BlueprintDescription)
|
||||
description = `${workflow.initialState.extra.BlueprintDescription}`
|
||||
const nodedefv1: ComfyNodeDefV1 = {
|
||||
input: { required: inputs },
|
||||
output: subgraphNode.outputs.map((o) => `${o.type}`),
|
||||
output_name: subgraphNode.outputs.map((o) => o.name),
|
||||
name: typePrefix + name,
|
||||
display_name: name,
|
||||
description,
|
||||
category: 'Subgraph Blueprints',
|
||||
output_node: false,
|
||||
python_module: 'blueprint'
|
||||
}
|
||||
const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1)
|
||||
return nodeDefImpl
|
||||
}
|
||||
async function publishSubgraph() {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const subgraphNode = [...canvas.selectedItems][0]
|
||||
if (
|
||||
canvas.selectedItems.size !== 1 ||
|
||||
!(subgraphNode instanceof SubgraphNode)
|
||||
)
|
||||
throw new TypeError('Must have single SubgraphNode selected to publish')
|
||||
|
||||
const { nodes = [], subgraphs = [] } = canvas._serializeItems([
|
||||
subgraphNode
|
||||
])
|
||||
if (nodes.length != 1) {
|
||||
throw new TypeError('Must have single SubgraphNode selected to publish')
|
||||
}
|
||||
//create minimal workflow
|
||||
const workflowData = {
|
||||
revision: 0,
|
||||
last_node_id: subgraphNode.id,
|
||||
last_link_id: 0,
|
||||
nodes,
|
||||
links: [],
|
||||
version: 0.4,
|
||||
definitions: { subgraphs }
|
||||
}
|
||||
//prompt name
|
||||
const name = await useDialogService().prompt({
|
||||
title: t('subgraphStore.saveBlueprint'),
|
||||
message: t('subgraphStore.blueprintName') + ':',
|
||||
defaultValue: subgraphNode.title
|
||||
})
|
||||
if (!name) return
|
||||
if (subgraphDefCache.value.has(name) && !(await confirmOverwrite(name)))
|
||||
//User has chosen not to overwrite.
|
||||
return
|
||||
|
||||
//upload file
|
||||
const path = SubgraphBlueprint.basePath + name + '.json'
|
||||
const workflow = new SubgraphBlueprint({
|
||||
path,
|
||||
size: -1,
|
||||
modified: Date.now()
|
||||
})
|
||||
workflow.originalContent = JSON.stringify(workflowData)
|
||||
const loadedWorkflow = await workflow.load()
|
||||
//Mark non-temporary
|
||||
workflow.size = 1
|
||||
await workflow.save()
|
||||
//add to files list?
|
||||
useWorkflowStore().attachWorkflow(loadedWorkflow)
|
||||
subgraphDefCache.value.set(name, convertToNodeDef(loadedWorkflow))
|
||||
subgraphCache[name] = loadedWorkflow
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('subgraphStore.publishSuccess'),
|
||||
detail: t('subgraphStore.publishSuccessMessage'),
|
||||
life: 4000
|
||||
})
|
||||
}
|
||||
function updateDef(blueprint: LoadedComfyWorkflow) {
|
||||
subgraphDefCache.value.set(blueprint.filename, convertToNodeDef(blueprint))
|
||||
}
|
||||
async function editBlueprint(nodeType: string) {
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
if (!(name in subgraphCache))
|
||||
//As loading is blocked on in startup, this can likely be changed to invalid type
|
||||
throw new Error('not yet loaded')
|
||||
useWorkflowStore().attachWorkflow(subgraphCache[name])
|
||||
await useWorkflowService().openWorkflow(subgraphCache[name])
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (canvas.graph && 'subgraph' in canvas.graph.nodes[0])
|
||||
canvas.setGraph(canvas.graph.nodes[0].subgraph)
|
||||
}
|
||||
function getBlueprint(nodeType: string): ComfyWorkflowJSON {
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
if (!(name in subgraphCache))
|
||||
//As loading is blocked on in startup, this can likely be changed to invalid type
|
||||
throw new Error('not yet loaded')
|
||||
return subgraphCache[name].changeTracker.initialState
|
||||
}
|
||||
async function deleteBlueprint(nodeType: string) {
|
||||
const name = nodeType.slice(typePrefix.length)
|
||||
if (!(name in subgraphCache))
|
||||
//As loading is blocked on in startup, this can likely be changed to invalid type
|
||||
throw new Error('not yet loaded')
|
||||
if (
|
||||
!(await useDialogService().confirm({
|
||||
title: t('subgraphStore.confirmDeleteTitle'),
|
||||
type: 'delete',
|
||||
message: t('subgraphStore.confirmDelete'),
|
||||
itemList: [name]
|
||||
}))
|
||||
)
|
||||
return
|
||||
|
||||
await subgraphCache[name].delete()
|
||||
delete subgraphCache[name]
|
||||
subgraphDefCache.value.delete(name)
|
||||
}
|
||||
function isSubgraphBlueprint(
|
||||
workflow: unknown
|
||||
): workflow is SubgraphBlueprint {
|
||||
return workflow instanceof SubgraphBlueprint
|
||||
}
|
||||
|
||||
return {
|
||||
deleteBlueprint,
|
||||
editBlueprint,
|
||||
fetchSubgraphs,
|
||||
getBlueprint,
|
||||
isSubgraphBlueprint,
|
||||
publishSubgraph,
|
||||
subgraphBlueprints,
|
||||
typePrefix,
|
||||
updateDef
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
@@ -10,6 +11,7 @@ import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
@@ -24,7 +26,8 @@ import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
import { UserFile } from './userFileStore'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath = 'workflows/'
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
@@ -120,6 +123,14 @@ export class ComfyWorkflow extends UserFile {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
return await super.saveAs(path)
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
@@ -139,6 +150,7 @@ export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
*/
|
||||
export interface WorkflowStore {
|
||||
activeWorkflow: LoadedComfyWorkflow | null
|
||||
attachWorkflow: (workflow: ComfyWorkflow, openIndex?: number) => void
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||
@@ -290,6 +302,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
const loadedWorkflow = await workflow.load()
|
||||
activeWorkflow.value = loadedWorkflow
|
||||
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
|
||||
console.debug('[workflowStore] open workflow', workflow.path)
|
||||
return loadedWorkflow
|
||||
}
|
||||
@@ -304,11 +317,37 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
const saveAs = (
|
||||
existingWorkflow: ComfyWorkflow,
|
||||
path: string
|
||||
): ComfyWorkflow => {
|
||||
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
workflow.originalContent = workflow.content = existingWorkflow.content
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
|
||||
const fullPath = getUnconflictedPath(
|
||||
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
|
||||
)
|
||||
const existingWorkflow = workflows.value.find((w) => w.fullFilename == path)
|
||||
if (
|
||||
path &&
|
||||
workflowData &&
|
||||
existingWorkflow?.changeTracker &&
|
||||
!existingWorkflow.directory.startsWith(
|
||||
ComfyWorkflow.basePath.slice(0, -1)
|
||||
)
|
||||
) {
|
||||
existingWorkflow.changeTracker.reset(workflowData)
|
||||
return existingWorkflow
|
||||
}
|
||||
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: fullPath,
|
||||
modified: Date.now(),
|
||||
@@ -357,7 +396,10 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
|
||||
const persistedWorkflows = computed(() =>
|
||||
Array.from(workflows.value).filter((workflow) => workflow.isPersisted)
|
||||
Array.from(workflows.value).filter(
|
||||
(workflow) =>
|
||||
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
|
||||
)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
@@ -647,6 +689,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
attachWorkflow,
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openedWorkflowIndexShift,
|
||||
@@ -658,6 +701,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
createTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
saveAs,
|
||||
saveWorkflow,
|
||||
reorderWorkflows,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum NodeSourceType {
|
||||
Core = 'core',
|
||||
CustomNodes = 'custom_nodes',
|
||||
Blueprint = 'blueprint',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
|
||||
@@ -36,6 +37,13 @@ export const getNodeSource = (python_module?: string): NodeSource => {
|
||||
displayText: 'Comfy Core',
|
||||
badgeText: '🦊'
|
||||
}
|
||||
} else if (modules[0] === 'blueprint') {
|
||||
return {
|
||||
type: NodeSourceType.Blueprint,
|
||||
className: 'blueprint',
|
||||
displayText: 'Blueprint',
|
||||
badgeText: 'bp'
|
||||
}
|
||||
} else if (modules[0] === 'custom_nodes') {
|
||||
const moduleName = modules[1]
|
||||
// Custom nodes installed via ComfyNodeRegistry will be in the format of
|
||||
|
||||
@@ -73,6 +73,10 @@ vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
128
tests-ui/tests/store/subgraphStore.test.ts
Normal file
128
tests-ui/tests/store/subgraphStore.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/test/subgraph/fixtures/subgraphHelpers'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
prompt: () => 'testname',
|
||||
confirm: () => true
|
||||
}))
|
||||
}))
|
||||
vi.mock('@/stores/graphStore', () => ({
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas: () => comfyApp.canvas
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock comfyApp globally for the store setup
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
_deserializeItems: vi.fn((i) => i),
|
||||
ds: { visible_area: [0, 0, 0, 0] },
|
||||
selected_nodes: null
|
||||
},
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const mockGraph = {
|
||||
nodes: [{ type: '123' }],
|
||||
definitions: { subgraphs: [{ id: '123' }] }
|
||||
}
|
||||
|
||||
describe('useSubgraphStore', () => {
|
||||
let store: ReturnType<typeof useSubgraphStore>
|
||||
const mockFetch = async (filenames: Record<string, unknown>) => {
|
||||
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
|
||||
Object.keys(filenames).map((filename) => ({
|
||||
path: filename,
|
||||
modified: new Date().getTime(),
|
||||
size: 1 // size !== -1 for remote workflows
|
||||
}))
|
||||
)
|
||||
vi.mocked(api).getUserData = vi.fn(
|
||||
(f) =>
|
||||
({
|
||||
status: 200,
|
||||
text: () => JSON.stringify(filenames[f.slice(10)])
|
||||
}) as any
|
||||
)
|
||||
return await store.fetchSubgraphs()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useSubgraphStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should allow publishing of a subgraph', async () => {
|
||||
//mock canvas to provide a minimal subgraphNode
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph
|
||||
graph.add(subgraphNode)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [subgraph.serialize() as any]
|
||||
}))
|
||||
//mock saving of file
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
path: 'subgraphs/testname.json',
|
||||
modified: Date.now(),
|
||||
size: 2
|
||||
})
|
||||
} as Response)
|
||||
await mockFetch({ 'testname.json': mockGraph })
|
||||
//Dialogue service already mocked
|
||||
await store.publishSubgraph()
|
||||
expect(api.storeUserData).toHaveBeenCalled()
|
||||
})
|
||||
it('should display published nodes in the node library', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(
|
||||
useNodeDefStore().nodeDefs.filter(
|
||||
(d) => d.category == 'Subgraph Blueprints'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
it('should allow subgraphs to be edited', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
store.editBlueprint(store.typePrefix + 'test')
|
||||
//check active graph
|
||||
expect(comfyApp.loadGraphData).toHaveBeenCalled()
|
||||
})
|
||||
it('should allow subgraphs to be added to graph', async () => {
|
||||
//mock
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const res = useLitegraphService().addNodeOnGraph({
|
||||
name: 'SubgraphBlueprint.test'
|
||||
} as ComfyNodeDefV1)
|
||||
expect(res).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user