mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
[backport core/1.32] Add linear mode (#6926)
Backport of #6670 to `core/1.32` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6926-backport-core-1-32-Add-linear-mode-2b66d73d365081e89a25c097285dc494) by [Unito](https://www.unito.io) Co-authored-by: AustinMroz <austin@comfy.org>
This commit is contained in:
@@ -79,10 +79,64 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
|
||||
@@ -148,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
linked: input.link != null
|
||||
})
|
||||
})
|
||||
return (
|
||||
node.widgets?.map((widget) => {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
label: widget.label,
|
||||
options: widget.options ? { ...widget.options } : undefined,
|
||||
callback: widget.callback,
|
||||
spec,
|
||||
slotMetadata: slotInfo,
|
||||
isDOMWidget: isDOMWidget(widget)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
|
||||
@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'toggle linear mode',
|
||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -329,6 +330,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
tabActivationHistory.value.shift()
|
||||
}
|
||||
|
||||
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
const appScalePercentage = ref(100)
|
||||
|
||||
const linearMode = ref(false)
|
||||
|
||||
// Set up scale synchronization when canvas is available
|
||||
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
|
||||
undefined
|
||||
@@ -138,6 +140,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
appScalePercentage,
|
||||
linearMode,
|
||||
updateSelectedItems,
|
||||
getCanvas,
|
||||
setAppZoomFromPercentage,
|
||||
|
||||
@@ -40,6 +40,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
||||
const { executionIdToNodeLocatorId } = useExecutionStore()
|
||||
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
||||
const latestOutput = ref<string[]>([])
|
||||
|
||||
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
||||
scheduledRevoke[locator]?.stop()
|
||||
@@ -146,6 +147,13 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
}
|
||||
|
||||
//TODO:Preview params and deduplication
|
||||
latestOutput.value =
|
||||
(outputs as ExecutedWsMessage['output'])?.images?.map((image) => {
|
||||
const imgUrlPart = new URLSearchParams(image)
|
||||
const rand = app.getRandParam()
|
||||
return api.apiURL(`/view?${imgUrlPart}${rand}`)
|
||||
}) ?? []
|
||||
app.nodeOutputs[nodeLocatorId] = outputs
|
||||
nodeOutputs.value[nodeLocatorId] = outputs
|
||||
}
|
||||
@@ -213,6 +221,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
scheduledRevoke[nodeLocatorId].stop()
|
||||
delete scheduledRevoke[nodeLocatorId]
|
||||
}
|
||||
latestOutput.value = previewImages
|
||||
app.nodePreviewImages[nodeLocatorId] = previewImages
|
||||
nodePreviewImages.value[nodeLocatorId] = previewImages
|
||||
}
|
||||
@@ -381,6 +390,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
// State
|
||||
nodeOutputs,
|
||||
nodePreviewImages
|
||||
nodePreviewImages,
|
||||
latestOutput
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
<div id="comfyui-body-left" class="comfyui-body-left" />
|
||||
<div id="comfyui-body-right" class="comfyui-body-right" />
|
||||
<div
|
||||
v-show="!linearMode"
|
||||
id="graph-canvas-container"
|
||||
ref="graphCanvasContainerRef"
|
||||
class="graph-canvas-container"
|
||||
>
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
</div>
|
||||
<LinearView v-if="linearMode" />
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
@@ -22,6 +24,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
@@ -53,6 +56,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
|
||||
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -75,6 +79,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
|
||||
setupAutoQueueHandler()
|
||||
useProgressFavicon()
|
||||
@@ -89,6 +94,7 @@ const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
|
||||
189
src/views/LinearView.vue
Normal file
189
src/views/LinearView.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
isValidWidgetValue,
|
||||
safeWidgetMapper
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
//import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
//const queueStore = useQueueStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const commandStore = useCommandStore()
|
||||
const nodeDatas = computed(() => {
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const mapper = safeWidgetMapper(node, new Map())
|
||||
const widgets =
|
||||
node.widgets?.map((widget) => {
|
||||
const safeWidget = mapper(widget)
|
||||
safeWidget.callback = function (value) {
|
||||
if (!isValidWidgetValue(value)) return
|
||||
widget.value = value ?? undefined
|
||||
return widget.callback?.(widget.value)
|
||||
}
|
||||
return safeWidget
|
||||
}) ?? []
|
||||
//Only widgets is actually used
|
||||
return {
|
||||
id: `${node.id}`,
|
||||
title: node.title,
|
||||
type: node.type,
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets
|
||||
}
|
||||
}
|
||||
return app.graph.nodes
|
||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||
.map(nodeToNodeData)
|
||||
})
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
|
||||
const batchCountWidget = {
|
||||
options: { step2: 1, precision: 1, min: 1, max: 100 },
|
||||
value: 1,
|
||||
name: t('Number of generations'),
|
||||
type: 'number'
|
||||
}
|
||||
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_linear'
|
||||
})
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
}
|
||||
function openFeedback() {
|
||||
//TODO: Does not link to a linear specific feedback section
|
||||
window.open(
|
||||
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=40026345549204',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="absolute w-full h-full">
|
||||
<div class="workflow-tabs-container pointer-events-auto h-9.5 w-full">
|
||||
<div class="flex h-full items-center">
|
||||
<WorkflowTabs />
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
||||
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }"
|
||||
>
|
||||
<SplitterPanel :size="1" class="min-w-min bg-comfy-menu-bg">
|
||||
<div
|
||||
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto border-r-1 border-node-component-border"
|
||||
>
|
||||
<ExtensionSlot :extension="useQueueSidebarTab()" />
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
:size="98"
|
||||
class="flex flex-row overflow-y-auto flex-wrap min-w-min gap-4"
|
||||
>
|
||||
<img
|
||||
v-for="previewUrl in nodeOutputStore.latestOutput"
|
||||
:key="previewUrl"
|
||||
class="pointer-events-none object-contain flex-1 max-h-full"
|
||||
:src="previewUrl"
|
||||
/>
|
||||
<img
|
||||
v-if="nodeOutputStore.latestOutput.length === 0"
|
||||
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel :size="1" class="flex flex-col gap-1 p-1 min-w-min">
|
||||
<div
|
||||
class="actionbar-container flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] p-2 gap-2 bg-comfy-menu-bg justify-center"
|
||||
>
|
||||
<Button label="Feedback" severity="secondary" @click="openFeedback" />
|
||||
<Button
|
||||
label="Open Workflow"
|
||||
severity="secondary"
|
||||
class="min-w-max"
|
||||
icon="icon-[comfy--workflow]"
|
||||
icon-pos="right"
|
||||
@click="useCanvasStore().linearMode = false"
|
||||
/>
|
||||
<!--<Button label="Share" severity="contrast" /> Temporarily disabled-->
|
||||
<CurrentUserButton v-if="isLoggedIn" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border p-2 gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="grow-1 flex justify-start flex-col overflow-y-auto contain-size *:max-h-100"
|
||||
>
|
||||
<NodeWidgets
|
||||
v-for="nodeData of nodeDatas"
|
||||
:key="nodeData.id"
|
||||
:node-data
|
||||
class="border-b-1 border-node-component-border pt-1 pb-2 last:border-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4 pb-0 border-t border-node-component-border">
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-56]:basis-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('menu.run')"
|
||||
class="w-full mt-4"
|
||||
icon="icon-[lucide--play]"
|
||||
@click="runButtonClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
/**
|
||||
* Utility functions for handling workbench events
|
||||
*/
|
||||
@@ -25,6 +26,7 @@ export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
|
||||
'reset',
|
||||
'search',
|
||||
'submit'
|
||||
].includes(target.type))
|
||||
].includes(target.type)) ||
|
||||
useCanvasStore().linearMode
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user