mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 07:00:23 +00:00
* 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>
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
import { toRaw } from 'vue'
|
|
|
|
import { t } from '@/i18n'
|
|
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
|
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
|
|
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
|
|
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
|
import { app } from '@/scripts/app'
|
|
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
|
import { downloadBlob } from '@/scripts/utils'
|
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
|
import { useSettingStore } from '@/stores/settingStore'
|
|
import { useToastStore } from '@/stores/toastStore'
|
|
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
|
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
|
|
|
|
import { useDialogService } from './dialogService'
|
|
|
|
export const useWorkflowService = () => {
|
|
const settingStore = useSettingStore()
|
|
const workflowStore = useWorkflowStore()
|
|
const toastStore = useToastStore()
|
|
const dialogService = useDialogService()
|
|
const workflowThumbnail = useWorkflowThumbnail()
|
|
const domWidgetStore = useDomWidgetStore()
|
|
|
|
async function getFilename(defaultName: string): Promise<string | null> {
|
|
if (settingStore.get('Comfy.PromptFilename')) {
|
|
let filename = await dialogService.prompt({
|
|
title: t('workflowService.exportWorkflow'),
|
|
message: t('workflowService.enterFilename') + ':',
|
|
defaultValue: defaultName
|
|
})
|
|
if (!filename) return null
|
|
if (!filename.toLowerCase().endsWith('.json')) {
|
|
filename += '.json'
|
|
}
|
|
return filename
|
|
}
|
|
return defaultName
|
|
}
|
|
|
|
/**
|
|
* Adds scale and offset from litegraph canvas to the workflow JSON.
|
|
* @param workflow The workflow to add the view restore data to
|
|
*/
|
|
function addViewRestore(workflow: ComfyWorkflowJSON) {
|
|
if (!settingStore.get('Comfy.EnableWorkflowViewRestore')) return
|
|
|
|
const { offset, scale } = app.canvas.ds
|
|
const [x, y] = offset
|
|
|
|
workflow.extra ??= {}
|
|
workflow.extra.ds = { scale, offset: [x, y] }
|
|
}
|
|
|
|
/**
|
|
* Export the current workflow as a JSON file
|
|
* @param filename The filename to save the workflow as
|
|
* @param promptProperty The property of the prompt to export
|
|
*/
|
|
const exportWorkflow = async (
|
|
filename: string,
|
|
promptProperty: 'workflow' | 'output'
|
|
): Promise<void> => {
|
|
const workflow = workflowStore.activeWorkflow
|
|
if (workflow?.path) {
|
|
filename = workflow.filename
|
|
}
|
|
const p = await app.graphToPrompt()
|
|
|
|
addViewRestore(p.workflow)
|
|
const json = JSON.stringify(p[promptProperty], null, 2)
|
|
const blob = new Blob([json], { type: 'application/json' })
|
|
const file = await getFilename(filename)
|
|
if (!file) return
|
|
downloadBlob(file, blob)
|
|
}
|
|
/**
|
|
* Save a workflow as a new file
|
|
* @param workflow The workflow to save
|
|
*/
|
|
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
|
|
const newFilename = await workflow.promptSave()
|
|
if (!newFilename) return
|
|
|
|
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
|
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
|
|
|
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
|
const res = await dialogService.confirm({
|
|
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
|
|
type: 'overwrite',
|
|
message: t('sideToolbar.workflowTab.confirmOverwrite'),
|
|
itemList: [newPath]
|
|
})
|
|
|
|
if (res !== true) return
|
|
|
|
if (existingWorkflow.path === workflow.path) {
|
|
await saveWorkflow(workflow)
|
|
return
|
|
}
|
|
const deleted = await deleteWorkflow(existingWorkflow, true)
|
|
if (!deleted) return
|
|
}
|
|
|
|
if (workflow.isTemporary) {
|
|
await renameWorkflow(workflow, newPath)
|
|
await workflowStore.saveWorkflow(workflow)
|
|
} else {
|
|
// Generate new id when saving existing workflow as a new file
|
|
const id = generateUUID()
|
|
const state = JSON.parse(
|
|
JSON.stringify(workflow.activeState)
|
|
) as ComfyWorkflowJSON
|
|
state.id = id
|
|
|
|
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
|
|
await openWorkflow(tempWorkflow)
|
|
await workflowStore.saveWorkflow(tempWorkflow)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save a workflow
|
|
* @param workflow The workflow to save
|
|
*/
|
|
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
|
if (workflow.isTemporary) {
|
|
await saveWorkflowAs(workflow)
|
|
} else {
|
|
await workflowStore.saveWorkflow(workflow)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the default workflow
|
|
*/
|
|
const loadDefaultWorkflow = async () => {
|
|
await app.loadGraphData(defaultGraph)
|
|
}
|
|
|
|
/**
|
|
* Load a blank workflow
|
|
*/
|
|
const loadBlankWorkflow = async () => {
|
|
await app.loadGraphData(blankGraph)
|
|
}
|
|
|
|
/**
|
|
* Reload the current workflow
|
|
* This is used to refresh the node definitions update, e.g. when the locale changes.
|
|
*/
|
|
const reloadCurrentWorkflow = async () => {
|
|
const workflow = workflowStore.activeWorkflow
|
|
if (workflow) {
|
|
await openWorkflow(workflow, { force: true })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a workflow in the current workspace
|
|
* @param workflow The workflow to open
|
|
* @param options The options for opening the workflow
|
|
*/
|
|
const openWorkflow = async (
|
|
workflow: ComfyWorkflow,
|
|
options: { force: boolean } = { force: false }
|
|
) => {
|
|
if (workflowStore.isActive(workflow) && !options.force) return
|
|
|
|
const loadFromRemote = !workflow.isLoaded
|
|
if (loadFromRemote) {
|
|
await workflow.load()
|
|
}
|
|
|
|
await app.loadGraphData(
|
|
toRaw(workflow.activeState) as ComfyWorkflowJSON,
|
|
/* clean=*/ true,
|
|
/* restore_view=*/ true,
|
|
workflow,
|
|
{
|
|
showMissingModelsDialog: loadFromRemote,
|
|
showMissingNodesDialog: loadFromRemote,
|
|
checkForRerouteMigration: false
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Close a workflow with confirmation if there are unsaved changes
|
|
* @param workflow The workflow to close
|
|
* @returns true if the workflow was closed, false if the user cancelled
|
|
*/
|
|
const closeWorkflow = async (
|
|
workflow: ComfyWorkflow,
|
|
options: { warnIfUnsaved: boolean; hint?: string } = {
|
|
warnIfUnsaved: true
|
|
}
|
|
): Promise<boolean> => {
|
|
if (workflow.isModified && options.warnIfUnsaved) {
|
|
const confirmed = await dialogService.confirm({
|
|
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
|
|
type: 'dirtyClose',
|
|
message: t('sideToolbar.workflowTab.dirtyClose'),
|
|
itemList: [workflow.path],
|
|
hint: options.hint
|
|
})
|
|
// Cancel
|
|
if (confirmed === null) return false
|
|
|
|
if (confirmed === true) {
|
|
await saveWorkflow(workflow)
|
|
}
|
|
}
|
|
|
|
// If this is the last workflow, create a new default temporary workflow
|
|
if (workflowStore.openWorkflows.length === 1) {
|
|
await loadDefaultWorkflow()
|
|
}
|
|
// If this is the active workflow, load the next workflow
|
|
if (workflowStore.isActive(workflow)) {
|
|
await loadNextOpenedWorkflow()
|
|
}
|
|
|
|
await workflowStore.closeWorkflow(workflow)
|
|
return true
|
|
}
|
|
|
|
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
|
await workflowStore.renameWorkflow(workflow, newPath)
|
|
}
|
|
|
|
/**
|
|
* Delete a workflow
|
|
* @param workflow The workflow to delete
|
|
* @returns `true` if the workflow was deleted, `false` if the user cancelled
|
|
*/
|
|
const deleteWorkflow = async (
|
|
workflow: ComfyWorkflow,
|
|
silent = false
|
|
): Promise<boolean> => {
|
|
const bypassConfirm = !settingStore.get('Comfy.Workflow.ConfirmDelete')
|
|
let confirmed: boolean | null = bypassConfirm || silent
|
|
|
|
if (!confirmed) {
|
|
confirmed = await dialogService.confirm({
|
|
title: t('sideToolbar.workflowTab.confirmDeleteTitle'),
|
|
type: 'delete',
|
|
message: t('sideToolbar.workflowTab.confirmDelete'),
|
|
itemList: [workflow.path]
|
|
})
|
|
if (!confirmed) return false
|
|
}
|
|
|
|
if (workflowStore.isOpen(workflow)) {
|
|
const closed = await closeWorkflow(workflow, {
|
|
warnIfUnsaved: !confirmed
|
|
})
|
|
if (!closed) return false
|
|
}
|
|
await workflowStore.deleteWorkflow(workflow)
|
|
if (!silent) {
|
|
toastStore.add({
|
|
severity: 'info',
|
|
summary: t('sideToolbar.workflowTab.deleted'),
|
|
life: 1000
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* This method is called before loading a new graph.
|
|
* There are 3 major functions that loads a new graph to the graph editor:
|
|
* 1. loadGraphData
|
|
* 2. loadApiJson
|
|
* 3. importA1111
|
|
*
|
|
* This function is used to save the current workflow states before loading
|
|
* a new graph.
|
|
*/
|
|
const beforeLoadNewGraph = () => {
|
|
// Use workspaceStore here as it is patched in unit tests.
|
|
const workflowStore = useWorkspaceStore().workflow
|
|
const activeWorkflow = workflowStore.activeWorkflow
|
|
if (activeWorkflow) {
|
|
activeWorkflow.changeTracker.store()
|
|
// Capture thumbnail before loading new graph
|
|
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
|
domWidgetStore.clear()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the active workflow after the new graph is loaded.
|
|
*
|
|
* The call relationship is
|
|
* useWorkflowService().openWorkflow -> app.loadGraphData -> useWorkflowService().afterLoadNewGraph
|
|
* app.loadApiJson -> useWorkflowService().afterLoadNewGraph
|
|
* app.importA1111 -> useWorkflowService().afterLoadNewGraph
|
|
*
|
|
* @param value The value to set as the active workflow.
|
|
* @param workflowData The initial workflow data loaded to the graph editor.
|
|
*/
|
|
const afterLoadNewGraph = async (
|
|
value: string | ComfyWorkflow | null,
|
|
workflowData: ComfyWorkflowJSON
|
|
) => {
|
|
// Use workspaceStore here as it is patched in unit tests.
|
|
const workflowStore = useWorkspaceStore().workflow
|
|
if (typeof value === 'string') {
|
|
const workflow = workflowStore.getWorkflowByPath(
|
|
ComfyWorkflow.basePath + appendJsonExt(value)
|
|
)
|
|
if (workflow?.isPersisted) {
|
|
const loadedWorkflow = await workflowStore.openWorkflow(workflow)
|
|
loadedWorkflow.changeTracker.restore()
|
|
loadedWorkflow.changeTracker.reset(workflowData)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (value === null || typeof value === 'string') {
|
|
const path = value as string | null
|
|
const tempWorkflow = workflowStore.createTemporary(
|
|
path ? appendJsonExt(path) : undefined,
|
|
workflowData
|
|
)
|
|
await workflowStore.openWorkflow(tempWorkflow)
|
|
return
|
|
}
|
|
|
|
// value is a ComfyWorkflow.
|
|
const loadedWorkflow = await workflowStore.openWorkflow(value)
|
|
loadedWorkflow.changeTracker.reset(workflowData)
|
|
loadedWorkflow.changeTracker.restore()
|
|
}
|
|
|
|
/**
|
|
* Insert the given workflow into the current graph editor.
|
|
*/
|
|
const insertWorkflow = async (
|
|
workflow: ComfyWorkflow,
|
|
options: { position?: Vector2 } = {}
|
|
) => {
|
|
const loadedWorkflow = await workflow.load()
|
|
const workflowJSON = toRaw(loadedWorkflow.initialState)
|
|
const old = localStorage.getItem('litegrapheditor_clipboard')
|
|
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
|
|
// serialisation schema.
|
|
const graph = new LGraph(workflowJSON as unknown as SerialisableGraph)
|
|
const canvasElement = document.createElement('canvas')
|
|
const canvas = new LGraphCanvas(canvasElement, graph, {
|
|
skip_events: true,
|
|
skip_render: true
|
|
})
|
|
canvas.selectItems()
|
|
canvas.copyToClipboard()
|
|
app.canvas.pasteFromClipboard(options)
|
|
if (old !== null) {
|
|
localStorage.setItem('litegrapheditor_clipboard', old)
|
|
}
|
|
}
|
|
|
|
const loadNextOpenedWorkflow = async () => {
|
|
const nextWorkflow = workflowStore.openedWorkflowIndexShift(1)
|
|
if (nextWorkflow) {
|
|
await openWorkflow(nextWorkflow)
|
|
}
|
|
}
|
|
|
|
const loadPreviousOpenedWorkflow = async () => {
|
|
const previousWorkflow = workflowStore.openedWorkflowIndexShift(-1)
|
|
if (previousWorkflow) {
|
|
await openWorkflow(previousWorkflow)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes an existing workflow and duplicates it with a new name
|
|
*/
|
|
const duplicateWorkflow = async (workflow: ComfyWorkflow) => {
|
|
const state = JSON.parse(JSON.stringify(workflow.activeState))
|
|
const suffix = workflow.isPersisted ? ' (Copy)' : ''
|
|
// Remove the suffix `(2)` or similar
|
|
const filename = workflow.filename.replace(/\s*\(\d+\)$/, '') + suffix
|
|
|
|
await app.loadGraphData(state, true, true, filename)
|
|
}
|
|
|
|
return {
|
|
exportWorkflow,
|
|
saveWorkflowAs,
|
|
saveWorkflow,
|
|
loadDefaultWorkflow,
|
|
loadBlankWorkflow,
|
|
reloadCurrentWorkflow,
|
|
openWorkflow,
|
|
closeWorkflow,
|
|
renameWorkflow,
|
|
deleteWorkflow,
|
|
insertWorkflow,
|
|
loadNextOpenedWorkflow,
|
|
loadPreviousOpenedWorkflow,
|
|
duplicateWorkflow,
|
|
afterLoadNewGraph,
|
|
beforeLoadNewGraph
|
|
}
|
|
}
|