mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 07:19:41 +00:00
Compare commits
5 Commits
fix/codera
...
backport-8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d5f041ed | ||
|
|
cd6047fa89 | ||
|
|
3c99e75fe0 | ||
|
|
5ec29f64b6 | ||
|
|
c77f0cba45 |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
|
|||||||
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
const comboEntry = comfyPage.page.getByRole('menuitem', {
|
||||||
name: 'image32x32.webp'
|
name: 'image32x32.webp'
|
||||||
})
|
})
|
||||||
await comboEntry.click({ noWaitAfter: true })
|
await comboEntry.click()
|
||||||
|
|
||||||
|
// Stabilization for the image swap
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
// Expect the image preview to change automatically
|
// Expect the image preview to change automatically
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 46 KiB |
@@ -247,6 +247,7 @@
|
|||||||
--inverted-background-hover: var(--color-charcoal-600);
|
--inverted-background-hover: var(--color-charcoal-600);
|
||||||
--warning-background: var(--color-gold-400);
|
--warning-background: var(--color-gold-400);
|
||||||
--warning-background-hover: var(--color-gold-500);
|
--warning-background-hover: var(--color-gold-500);
|
||||||
|
--success-background: var(--color-jade-600);
|
||||||
--border-default: var(--color-smoke-600);
|
--border-default: var(--color-smoke-600);
|
||||||
--border-subtle: var(--color-smoke-400);
|
--border-subtle: var(--color-smoke-400);
|
||||||
--muted-background: var(--color-smoke-700);
|
--muted-background: var(--color-smoke-700);
|
||||||
@@ -372,6 +373,7 @@
|
|||||||
--inverted-background-hover: var(--color-smoke-200);
|
--inverted-background-hover: var(--color-smoke-200);
|
||||||
--warning-background: var(--color-gold-600);
|
--warning-background: var(--color-gold-600);
|
||||||
--warning-background-hover: var(--color-gold-500);
|
--warning-background-hover: var(--color-gold-500);
|
||||||
|
--success-background: var(--color-jade-600);
|
||||||
--border-default: var(--color-charcoal-200);
|
--border-default: var(--color-charcoal-200);
|
||||||
--border-subtle: var(--color-charcoal-300);
|
--border-subtle: var(--color-charcoal-300);
|
||||||
--muted-background: var(--color-charcoal-100);
|
--muted-background: var(--color-charcoal-100);
|
||||||
@@ -516,6 +518,7 @@
|
|||||||
--color-inverted-background-hover: var(--inverted-background-hover);
|
--color-inverted-background-hover: var(--inverted-background-hover);
|
||||||
--color-warning-background: var(--warning-background);
|
--color-warning-background: var(--warning-background);
|
||||||
--color-warning-background-hover: var(--warning-background-hover);
|
--color-warning-background-hover: var(--warning-background-hover);
|
||||||
|
--color-success-background: var(--success-background);
|
||||||
--color-border-default: var(--border-default);
|
--color-border-default: var(--border-default);
|
||||||
--color-border-subtle: var(--border-subtle);
|
--color-border-subtle: var(--border-subtle);
|
||||||
--color-muted-background: var(--muted-background);
|
--color-muted-background: var(--muted-background);
|
||||||
|
|||||||
28
src/components/sidebar/ModeToggle.vue
Normal file
28
src/components/sidebar/ModeToggle.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
:title="t('linearMode.linearMode')"
|
||||||
|
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||||
|
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--panels-top-left]" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
:title="t('linearMode.graphMode')"
|
||||||
|
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||||
|
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||||
|
>
|
||||||
|
<i class="icon-[comfy--workflow]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||||
<SidebarSettingsButton :is-small="isSmall" />
|
<SidebarSettingsButton :is-small="isSmall" />
|
||||||
|
<ModeToggle v-if="showLinearToggle" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HelpCenterPopups :is-small="isSmall" />
|
<HelpCenterPopups :is-small="isSmall" />
|
||||||
@@ -51,15 +52,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useResizeObserver } from '@vueuse/core'
|
import { useResizeObserver, whenever } from '@vueuse/core'
|
||||||
import { debounce } from 'es-toolkit/compat'
|
import { debounce } from 'es-toolkit/compat'
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||||
|
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
@@ -84,6 +87,12 @@ const sideToolbarRef = ref<HTMLElement>()
|
|||||||
const topToolbarRef = ref<HTMLElement>()
|
const topToolbarRef = ref<HTMLElement>()
|
||||||
const bottomToolbarRef = ref<HTMLElement>()
|
const bottomToolbarRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
|
||||||
|
whenever(
|
||||||
|
() => canvasStore.linearMode,
|
||||||
|
() => (showLinearToggle.value = true)
|
||||||
|
)
|
||||||
|
|
||||||
const isSmall = computed(
|
const isSmall = computed(
|
||||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<SidebarTabTemplate
|
<SidebarTabTemplate
|
||||||
:title="$t('sideToolbar.workflows')"
|
:title="$t('sideToolbar.workflows')"
|
||||||
|
v-bind="$attrs"
|
||||||
class="workflows-sidebar-tab"
|
class="workflows-sidebar-tab"
|
||||||
>
|
>
|
||||||
<template #tool-buttons>
|
<template #tool-buttons>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
>
|
>
|
||||||
<i class="pi pi-bars" />
|
<i class="pi pi-bars" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<i
|
||||||
|
v-else-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||||
|
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||||
|
/>
|
||||||
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
|
||||||
{{ workflowOption.workflow.filename }}
|
{{ workflowOption.workflow.filename }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
76
src/components/ui/Popover.vue
Normal file
76
src/components/ui/Popover.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
PopoverArrow,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverPortal,
|
||||||
|
PopoverRoot,
|
||||||
|
PopoverTrigger
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entries?: { label: string; action?: () => void; icon?: string }[][]
|
||||||
|
icon?: string
|
||||||
|
to?: string | HTMLElement
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PopoverRoot v-slot="{ close }">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<slot name="button">
|
||||||
|
<Button size="icon">
|
||||||
|
<i :class="icon ?? 'icon-[lucide--ellipsis]'" />
|
||||||
|
</Button>
|
||||||
|
</slot>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverPortal :to>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
:side-offset="5"
|
||||||
|
:collision-padding="10"
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<div class="flex flex-col p-1">
|
||||||
|
<section
|
||||||
|
v-for="(entryGroup, index) in entries ?? []"
|
||||||
|
:key="index"
|
||||||
|
class="flex flex-col border-b-2 last:border-none border-border-subtle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="{ label, action, icon } in entryGroup"
|
||||||
|
:key="label"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-row gap-4 p-2 rounded-sm my-1',
|
||||||
|
action &&
|
||||||
|
'cursor-pointer hover:bg-secondary-background-hover'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
if (!action) return
|
||||||
|
action()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i v-if="icon" :class="icon" />
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverPortal>
|
||||||
|
</PopoverRoot>
|
||||||
|
</template>
|
||||||
29
src/components/ui/TypeformPopoverButton.vue
Normal file
29
src/components/ui/TypeformPopoverButton.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { whenever } from '@vueuse/core'
|
||||||
|
import { useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
dataTfWidget: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const feedbackRef = useTemplateRef('feedbackRef')
|
||||||
|
|
||||||
|
whenever(feedbackRef, () => {
|
||||||
|
const scriptEl = document.createElement('script')
|
||||||
|
scriptEl.src = '//embed.typeform.com/next/embed.js'
|
||||||
|
feedbackRef.value?.appendChild(scriptEl)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Popover>
|
||||||
|
<template #button>
|
||||||
|
<Button variant="inverted" class="rounded-full size-12">
|
||||||
|
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
59
src/components/ui/ZoomPane.vue
Normal file
59
src/components/ui/ZoomPane.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
const zoomPane = useTemplateRef('zoomPane')
|
||||||
|
|
||||||
|
const zoom = ref(1.0)
|
||||||
|
const panX = ref(0.0)
|
||||||
|
const panY = ref(0.0)
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
const zoomPaneEl = zoomPane.value
|
||||||
|
if (!zoomPaneEl) return
|
||||||
|
|
||||||
|
zoom.value -= e.deltaY
|
||||||
|
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()
|
||||||
|
const offsetX = e.clientX - x - width / 2
|
||||||
|
const offsetY = e.clientY - y - height / 2
|
||||||
|
const scaler = 1.1 ** (e.deltaY / -30)
|
||||||
|
|
||||||
|
panY.value = panY.value * scaler - offsetY * (scaler - 1)
|
||||||
|
panX.value = panX.value * scaler - offsetX * (scaler - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragging = false
|
||||||
|
function handleDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
|
||||||
|
const zoomPaneEl = zoomPane.value
|
||||||
|
if (!zoomPaneEl) return
|
||||||
|
zoomPaneEl.parentElement?.focus()
|
||||||
|
|
||||||
|
zoomPaneEl.setPointerCapture(e.pointerId)
|
||||||
|
dragging = true
|
||||||
|
}
|
||||||
|
function handleMove(e: PointerEvent) {
|
||||||
|
if (!dragging) return
|
||||||
|
panX.value += e.movementX
|
||||||
|
panY.value += e.movementY
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = computed(() => {
|
||||||
|
const scale = 1.1 ** (zoom.value / 30)
|
||||||
|
const matrix = [scale, 0, 0, scale, panX.value, panY.value]
|
||||||
|
return `matrix(${matrix.join(',')})`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="zoomPane"
|
||||||
|
class="contain-size flex place-content-center"
|
||||||
|
@wheel="handleWheel"
|
||||||
|
@pointerdown.prevent="handleDown"
|
||||||
|
@pointermove="handleMove"
|
||||||
|
@pointerup="dragging = false"
|
||||||
|
@pointercancel="dragging = false"
|
||||||
|
>
|
||||||
|
<slot :style="{ transform }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -157,7 +157,7 @@ const normalizeWidgetValue = (value: unknown): WidgetValue => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeWidgetMapper(
|
function safeWidgetMapper(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||||
): (widget: IBaseWidget) => SafeWidgetData {
|
): (widget: IBaseWidget) => SafeWidgetData {
|
||||||
@@ -207,15 +207,77 @@ export function safeWidgetMapper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
// Extract safe data from LiteGraph node for Vue consumption
|
||||||
return (
|
export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||||
value === null ||
|
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||||
value === undefined ||
|
const subgraphId =
|
||||||
typeof value === 'string' ||
|
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||||
typeof value === 'number' ||
|
? String(node.graph.id)
|
||||||
typeof value === 'boolean' ||
|
: null
|
||||||
typeof value === 'object'
|
// Extract safe widget data
|
||||||
)
|
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||||
|
|
||||||
|
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||||
|
Object.defineProperty(node, 'widgets', {
|
||||||
|
get() {
|
||||||
|
return reactiveWidgets
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||||
|
Object.defineProperty(node, 'inputs', {
|
||||||
|
get() {
|
||||||
|
return reactiveInputs
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||||
|
node.inputs?.forEach((input, index) => {
|
||||||
|
if (!input?.widget?.name) return
|
||||||
|
slotMetadata.set(input.widget.name, {
|
||||||
|
index,
|
||||||
|
linked: input.link != null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeType =
|
||||||
|
node.type ||
|
||||||
|
node.constructor?.comfyClass ||
|
||||||
|
node.constructor?.title ||
|
||||||
|
node.constructor?.name ||
|
||||||
|
'Unknown'
|
||||||
|
|
||||||
|
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
||||||
|
const badges = node.badges
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(node.id),
|
||||||
|
title: typeof node.title === 'string' ? node.title : '',
|
||||||
|
type: nodeType,
|
||||||
|
mode: node.mode || 0,
|
||||||
|
titleMode: node.title_mode,
|
||||||
|
selected: node.selected || false,
|
||||||
|
executing: false, // Will be updated separately based on execution state
|
||||||
|
subgraphId,
|
||||||
|
apiNode,
|
||||||
|
badges,
|
||||||
|
hasErrors: !!node.has_errors,
|
||||||
|
widgets: safeWidgets,
|
||||||
|
inputs: reactiveInputs,
|
||||||
|
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||||
|
flags: node.flags ? { ...node.flags } : undefined,
|
||||||
|
color: node.color || undefined,
|
||||||
|
bgcolor: node.bgcolor || undefined,
|
||||||
|
resizable: node.resizable,
|
||||||
|
shape: node.shape
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||||
@@ -251,79 +313,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract safe data from LiteGraph node for Vue consumption
|
|
||||||
function extractVueNodeData(node: LGraphNode): VueNodeData {
|
|
||||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
|
||||||
const subgraphId =
|
|
||||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
|
||||||
? String(node.graph.id)
|
|
||||||
: null
|
|
||||||
// Extract safe widget data
|
|
||||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
|
||||||
|
|
||||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
|
||||||
Object.defineProperty(node, 'widgets', {
|
|
||||||
get() {
|
|
||||||
return reactiveWidgets
|
|
||||||
},
|
|
||||||
set(v) {
|
|
||||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
|
||||||
Object.defineProperty(node, 'inputs', {
|
|
||||||
get() {
|
|
||||||
return reactiveInputs
|
|
||||||
},
|
|
||||||
set(v) {
|
|
||||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
|
||||||
node.inputs?.forEach((input, index) => {
|
|
||||||
if (!input?.widget?.name) return
|
|
||||||
slotMetadata.set(input.widget.name, {
|
|
||||||
index,
|
|
||||||
linked: input.link != null
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodeType =
|
|
||||||
node.type ||
|
|
||||||
node.constructor?.comfyClass ||
|
|
||||||
node.constructor?.title ||
|
|
||||||
node.constructor?.name ||
|
|
||||||
'Unknown'
|
|
||||||
|
|
||||||
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
|
||||||
const badges = node.badges
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(node.id),
|
|
||||||
title: typeof node.title === 'string' ? node.title : '',
|
|
||||||
type: nodeType,
|
|
||||||
mode: node.mode || 0,
|
|
||||||
titleMode: node.title_mode,
|
|
||||||
selected: node.selected || false,
|
|
||||||
executing: false, // Will be updated separately based on execution state
|
|
||||||
subgraphId,
|
|
||||||
apiNode,
|
|
||||||
badges,
|
|
||||||
hasErrors: !!node.has_errors,
|
|
||||||
widgets: safeWidgets,
|
|
||||||
inputs: reactiveInputs,
|
|
||||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
|
||||||
flags: node.flags ? { ...node.flags } : undefined,
|
|
||||||
color: node.color || undefined,
|
|
||||||
bgcolor: node.bgcolor || undefined,
|
|
||||||
resizable: node.resizable,
|
|
||||||
shape: node.shape
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get access to original LiteGraph node (non-reactive)
|
// Get access to original LiteGraph node (non-reactive)
|
||||||
const getNode = (id: string): LGraphNode | undefined => {
|
const getNode = (id: string): LGraphNode | undefined => {
|
||||||
return nodeRefs.get(id)
|
return nodeRefs.get(id)
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
|
|||||||
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meshy credit pricing constant.
|
||||||
|
* 1 Meshy credit = $0.04 USD
|
||||||
|
* Change this value to update all Meshy node prices.
|
||||||
|
*/
|
||||||
|
const MESHY_CREDIT_PRICE_USD = 0.04
|
||||||
|
|
||||||
|
/** Convert Meshy credits to USD */
|
||||||
|
const meshyCreditsToUsd = (credits: number): number =>
|
||||||
|
credits * MESHY_CREDIT_PRICE_USD
|
||||||
|
|
||||||
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
|
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
@@ -525,6 +536,54 @@ const calculateTripo3DGenerationPrice = (
|
|||||||
return formatCreditsLabel(dollars)
|
return formatCreditsLabel(dollars)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meshy Image to 3D pricing calculator.
|
||||||
|
* Pricing based on should_texture widget:
|
||||||
|
* - Without texture: 20 credits
|
||||||
|
* - With texture: 30 credits
|
||||||
|
*/
|
||||||
|
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
|
||||||
|
const shouldTextureWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'should_texture'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!shouldTextureWidget) {
|
||||||
|
return formatCreditsRangeLabel(
|
||||||
|
meshyCreditsToUsd(20),
|
||||||
|
meshyCreditsToUsd(30),
|
||||||
|
{ note: '(varies with texture)' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||||
|
const credits = shouldTexture === 'true' ? 30 : 20
|
||||||
|
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meshy Multi-Image to 3D pricing calculator.
|
||||||
|
* Pricing based on should_texture widget:
|
||||||
|
* - Without texture: 5 credits
|
||||||
|
* - With texture: 15 credits
|
||||||
|
*/
|
||||||
|
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
|
||||||
|
const shouldTextureWidget = node.widgets?.find(
|
||||||
|
(w) => w.name === 'should_texture'
|
||||||
|
) as IComboWidget
|
||||||
|
|
||||||
|
if (!shouldTextureWidget) {
|
||||||
|
return formatCreditsRangeLabel(
|
||||||
|
meshyCreditsToUsd(5),
|
||||||
|
meshyCreditsToUsd(15),
|
||||||
|
{ note: '(varies with texture)' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
|
||||||
|
const credits = shouldTexture === 'true' ? 15 : 5
|
||||||
|
return formatCreditsLabel(meshyCreditsToUsd(credits))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static pricing data for API nodes, now supporting both strings and functions
|
* Static pricing data for API nodes, now supporting both strings and functions
|
||||||
*/
|
*/
|
||||||
@@ -1812,6 +1871,27 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
|||||||
TripoRefineNode: {
|
TripoRefineNode: {
|
||||||
displayPrice: formatCreditsLabel(0.3)
|
displayPrice: formatCreditsLabel(0.3)
|
||||||
},
|
},
|
||||||
|
MeshyTextToModelNode: {
|
||||||
|
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
|
||||||
|
},
|
||||||
|
MeshyRefineNode: {
|
||||||
|
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||||
|
},
|
||||||
|
MeshyImageToModelNode: {
|
||||||
|
displayPrice: calculateMeshyImageToModelPrice
|
||||||
|
},
|
||||||
|
MeshyMultiImageToModelNode: {
|
||||||
|
displayPrice: calculateMeshyMultiImageToModelPrice
|
||||||
|
},
|
||||||
|
MeshyRigModelNode: {
|
||||||
|
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
|
||||||
|
},
|
||||||
|
MeshyAnimateModelNode: {
|
||||||
|
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
|
||||||
|
},
|
||||||
|
MeshyTextureNode: {
|
||||||
|
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
|
||||||
|
},
|
||||||
// Google/Gemini nodes
|
// Google/Gemini nodes
|
||||||
GeminiNode: {
|
GeminiNode: {
|
||||||
displayPrice: (node: LGraphNode): string => {
|
displayPrice: (node: LGraphNode): string => {
|
||||||
@@ -2527,6 +2607,9 @@ export const useNodePricing = () => {
|
|||||||
'animate_in_place'
|
'animate_in_place'
|
||||||
],
|
],
|
||||||
TripoTextureNode: ['texture_quality'],
|
TripoTextureNode: ['texture_quality'],
|
||||||
|
// Meshy nodes
|
||||||
|
MeshyImageToModelNode: ['should_texture'],
|
||||||
|
MeshyMultiImageToModelNode: ['should_texture'],
|
||||||
// Google/Gemini nodes
|
// Google/Gemini nodes
|
||||||
GeminiNode: ['model'],
|
GeminiNode: ['model'],
|
||||||
GeminiImage2Node: ['resolution'],
|
GeminiImage2Node: ['resolution'],
|
||||||
|
|||||||
@@ -1235,7 +1235,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.ToggleLinear',
|
id: 'Comfy.ToggleLinear',
|
||||||
icon: 'pi pi-database',
|
icon: 'pi pi-database',
|
||||||
label: 'toggle linear mode',
|
label: 'toggle linear mode',
|
||||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
function: () => {
|
||||||
|
const newMode = !canvasStore.linearMode
|
||||||
|
app.rootGraph.extra.linearMode = newMode
|
||||||
|
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||||
|
canvasStore.linearMode = newMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export enum ServerFeatureFlag {
|
|||||||
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
|
||||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||||
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
|
||||||
|
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||||
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,12 @@ export function useFeatureFlags() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
get linearToggleEnabled() {
|
||||||
|
return (
|
||||||
|
remoteConfig.value.linear_toggle_enabled ??
|
||||||
|
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||||
|
)
|
||||||
|
},
|
||||||
get asyncModelUploadEnabled() {
|
get asyncModelUploadEnabled() {
|
||||||
return (
|
return (
|
||||||
remoteConfig.value.async_model_upload_enabled ??
|
remoteConfig.value.async_model_upload_enabled ??
|
||||||
|
|||||||
@@ -190,6 +190,7 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"cancelled": "Cancelled",
|
"cancelled": "Cancelled",
|
||||||
"job": "Job",
|
"job": "Job",
|
||||||
|
"asset": "{count} assets | {count} asset | {count} assets",
|
||||||
"untitled": "Untitled",
|
"untitled": "Untitled",
|
||||||
"emDash": "—",
|
"emDash": "—",
|
||||||
"enabling": "Enabling {id}",
|
"enabling": "Enabling {id}",
|
||||||
@@ -677,7 +678,8 @@
|
|||||||
"filterImage": "Image",
|
"filterImage": "Image",
|
||||||
"filterVideo": "Video",
|
"filterVideo": "Video",
|
||||||
"filterAudio": "Audio",
|
"filterAudio": "Audio",
|
||||||
"filter3D": "3D"
|
"filter3D": "3D",
|
||||||
|
"filterText": "Text"
|
||||||
},
|
},
|
||||||
"backToAssets": "Back to all assets",
|
"backToAssets": "Back to all assets",
|
||||||
"searchAssets": "Search Assets",
|
"searchAssets": "Search Assets",
|
||||||
@@ -1182,7 +1184,7 @@
|
|||||||
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
|
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
|
||||||
"Canvas Performance": "Canvas Performance",
|
"Canvas Performance": "Canvas Performance",
|
||||||
"Help Center": "Help Center",
|
"Help Center": "Help Center",
|
||||||
"toggle linear mode": "toggle linear mode",
|
"toggle linear mode": "toggle simple mode",
|
||||||
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
|
||||||
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
|
||||||
"Undo": "Undo",
|
"Undo": "Undo",
|
||||||
@@ -2472,8 +2474,14 @@
|
|||||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||||
},
|
},
|
||||||
"linearMode": {
|
"linearMode": {
|
||||||
"share": "Share",
|
"linearMode": "Simple Mode",
|
||||||
"openWorkflow": "Open Workflow"
|
"beta": "Beta - Give Feedback",
|
||||||
|
"graphMode": "Graph Mode",
|
||||||
|
"dragAndDropImage": "Drag and drop an image",
|
||||||
|
"runCount": "Run count:",
|
||||||
|
"rerun": "Rerun",
|
||||||
|
"reuseParameters": "Reuse Parameters",
|
||||||
|
"downloadAll": "Download All"
|
||||||
},
|
},
|
||||||
"missingNodes": {
|
"missingNodes": {
|
||||||
"cloud": {
|
"cloud": {
|
||||||
|
|||||||
@@ -40,5 +40,6 @@ export type RemoteConfig = {
|
|||||||
private_models_enabled?: boolean
|
private_models_enabled?: boolean
|
||||||
onboarding_survey_enabled?: boolean
|
onboarding_survey_enabled?: boolean
|
||||||
huggingface_model_import_enabled?: boolean
|
huggingface_model_import_enabled?: boolean
|
||||||
|
linear_toggle_enabled?: boolean
|
||||||
async_model_upload_enabled?: boolean
|
async_model_upload_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { whenever } from '@vueuse/core'
|
import { whenever } from '@vueuse/core'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, nextTick, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useToastStore } from './toastStore'
|
import { useToastStore } from './toastStore'
|
||||||
@@ -65,9 +65,12 @@ export function useFrontendVersionMismatchWarning(
|
|||||||
versionCompatibilityStore.dismissWarning()
|
versionCompatibilityStore.dismissWarning()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// Only set up the watcher if immediate is true
|
// Only set up the watcher if immediate is true
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
|
// Wait for next tick to ensure reactive updates from settings load have propagated
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => versionCompatibilityStore.shouldShowWarning,
|
() => versionCompatibilityStore.shouldShowWarning,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -88,11 +88,16 @@ export const useVersionCompatibilityStore = defineStore(
|
|||||||
return Date.now() < dismissedUntil
|
return Date.now() < dismissedUntil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const warningsDisabled = computed(() =>
|
||||||
|
settingStore.get('Comfy.VersionCompatibility.DisableWarnings')
|
||||||
|
)
|
||||||
|
|
||||||
const shouldShowWarning = computed(() => {
|
const shouldShowWarning = computed(() => {
|
||||||
const warningsDisabled = settingStore.get(
|
return (
|
||||||
'Comfy.VersionCompatibility.DisableWarnings'
|
hasVersionMismatch.value &&
|
||||||
|
!isDismissed.value &&
|
||||||
|
!warningsDisabled.value
|
||||||
)
|
)
|
||||||
return hasVersionMismatch.value && !isDismissed.value && !warningsDisabled
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const warningMessage = computed(() => {
|
const warningMessage = computed(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useWorkflowStore
|
useWorkflowStore
|
||||||
} from '@/platform/workflow/management/stores/workflowStore'
|
} from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||||
@@ -311,6 +312,11 @@ export const useWorkflowService = () => {
|
|||||||
workflowData: ComfyWorkflowJSON
|
workflowData: ComfyWorkflowJSON
|
||||||
) => {
|
) => {
|
||||||
const workflowStore = useWorkspaceStore().workflow
|
const workflowStore = useWorkspaceStore().workflow
|
||||||
|
if (
|
||||||
|
workflowData.extra?.linearMode !== undefined ||
|
||||||
|
!workflowData.nodes.length
|
||||||
|
)
|
||||||
|
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
|
||||||
|
|
||||||
if (value === null || typeof value === 'string') {
|
if (value === null || typeof value === 'string') {
|
||||||
const path = value as string | null
|
const path = value as string | null
|
||||||
@@ -332,6 +338,11 @@ export const useWorkflowService = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useCanvasStore().linearMode) {
|
||||||
|
app.rootGraph.extra ??= {}
|
||||||
|
app.rootGraph.extra.linearMode = true
|
||||||
|
}
|
||||||
|
|
||||||
const tempWorkflow = workflowStore.createNewTemporary(
|
const tempWorkflow = workflowStore.createNewTemporary(
|
||||||
path ? appendJsonExt(path) : undefined,
|
path ? appendJsonExt(path) : undefined,
|
||||||
workflowData
|
workflowData
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
ComfyWorkflowJSON,
|
ComfyWorkflowJSON,
|
||||||
NodeId
|
NodeId
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
||||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app as comfyApp } from '@/scripts/app'
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
@@ -334,7 +333,6 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
tabActivationHistory.value.shift()
|
tabActivationHistory.value.shift()
|
||||||
}
|
}
|
||||||
|
|
||||||
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
|
|
||||||
return loadedWorkflow
|
return loadedWorkflow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,28 +121,7 @@ export function useTemplateWorkflows() {
|
|||||||
if (!template || !template.sourceModule) return false
|
if (!template || !template.sourceModule) return false
|
||||||
|
|
||||||
// Use the stored source module for loading
|
// Use the stored source module for loading
|
||||||
const actualSourceModule = template.sourceModule
|
sourceModule = template.sourceModule
|
||||||
json = await fetchTemplateJson(id, actualSourceModule)
|
|
||||||
|
|
||||||
// Use source module for name
|
|
||||||
const workflowName =
|
|
||||||
actualSourceModule === 'default'
|
|
||||||
? t(`templateWorkflows.template.${id}`, id)
|
|
||||||
: id
|
|
||||||
|
|
||||||
if (isCloud) {
|
|
||||||
useTelemetry()?.trackTemplate({
|
|
||||||
workflow_name: id,
|
|
||||||
template_source: actualSourceModule
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogStore.closeDialog()
|
|
||||||
await app.loadGraphData(json, true, true, workflowName, {
|
|
||||||
openSource: 'template'
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular case for normal categories
|
// Regular case for normal categories
|
||||||
|
|||||||
52
src/renderer/extensions/linearMode/DropZone.vue
Normal file
52
src/renderer/extensions/linearMode/DropZone.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
onDragOver?: (e: DragEvent) => boolean
|
||||||
|
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||||
|
dropIndicator?: {
|
||||||
|
label?: string
|
||||||
|
iconClass?: string
|
||||||
|
onClick?: (e: MouseEvent) => void
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canAcceptDrop = ref(false)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="onDragOver && onDragDrop"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-lg ring-inset ring-primary-500',
|
||||||
|
canAcceptDrop && 'ring-4 bg-primary-500/10'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@dragover.prevent="canAcceptDrop = onDragOver?.($event)"
|
||||||
|
@dragleave="canAcceptDrop = false"
|
||||||
|
@drop.stop.prevent="
|
||||||
|
(e: DragEvent) => {
|
||||||
|
onDragDrop!(e)
|
||||||
|
canAcceptDrop = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="dropIndicator"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col items-center justify-center gap-2 border-dashed rounded-lg border h-25 border-border-subtle m-3 py-2',
|
||||||
|
dropIndicator?.onClick && 'cursor-pointer'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||||
|
>
|
||||||
|
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||||
|
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot v-else />
|
||||||
|
</template>
|
||||||
44
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
44
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||||
|
|
||||||
|
const { src } = defineProps<{
|
||||||
|
src: string
|
||||||
|
mobile?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imageRef = useTemplateRef('imageRef')
|
||||||
|
const width = ref('')
|
||||||
|
const height = ref('')
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ZoomPane v-if="!mobile" v-slot="slotProps" class="flex-1 w-full">
|
||||||
|
<img
|
||||||
|
ref="imageRef"
|
||||||
|
:src
|
||||||
|
v-bind="slotProps"
|
||||||
|
@load="
|
||||||
|
() => {
|
||||||
|
if (!imageRef) return
|
||||||
|
width = `${imageRef.naturalWidth}`
|
||||||
|
height = `${imageRef.naturalHeight}`
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</ZoomPane>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
ref="imageRef"
|
||||||
|
class="w-full"
|
||||||
|
:src
|
||||||
|
@load="
|
||||||
|
() => {
|
||||||
|
if (!imageRef) return
|
||||||
|
width = `${imageRef.naturalWidth}`
|
||||||
|
height = `${imageRef.naturalHeight}`
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||||
|
</template>
|
||||||
284
src/renderer/extensions/linearMode/LinearControls.vue
Normal file
284
src/renderer/extensions/linearMode/LinearControls.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||||
|
import { partition } from 'es-toolkit'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
|
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||||
|
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||||
|
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||||
|
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||||
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
|
const commandStore = useCommandStore()
|
||||||
|
const executionStore = useExecutionStore()
|
||||||
|
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||||
|
const { isActiveSubscription } = useSubscription()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
toastTo?: string | HTMLElement
|
||||||
|
notesTo?: string | HTMLElement
|
||||||
|
mobile?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const jobFinishedQueue = ref(true)
|
||||||
|
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||||
|
5000,
|
||||||
|
{ controls: true, immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||||
|
useEventListener(
|
||||||
|
app.rootGraph.events,
|
||||||
|
'configured',
|
||||||
|
() => (graphNodes.value = app.rootGraph.nodes)
|
||||||
|
)
|
||||||
|
|
||||||
|
function nodeToNodeData(node: LGraphNode) {
|
||||||
|
const dropIndicator =
|
||||||
|
node.type !== 'LoadImage'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
iconClass: 'icon-[lucide--image]',
|
||||||
|
label: t('linearMode.dragAndDropImage')
|
||||||
|
}
|
||||||
|
const nodeData = extractVueNodeData(node)
|
||||||
|
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
...nodeData,
|
||||||
|
|
||||||
|
dropIndicator,
|
||||||
|
onDragDrop: node.onDragDrop,
|
||||||
|
onDragOver: node.onDragOver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partitionedNodes = computed(() => {
|
||||||
|
return partition(
|
||||||
|
graphNodes.value
|
||||||
|
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||||
|
.map(nodeToNodeData)
|
||||||
|
.reverse(),
|
||||||
|
(node) => ['MarkdownNote', 'Note'].includes(node.type)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchCountWidget: SimplifiedWidget<number> = {
|
||||||
|
options: { precision: 0, min: 1, max: isCloud ? 4 : 99 },
|
||||||
|
value: 1,
|
||||||
|
name: t('linearMode.runCount'),
|
||||||
|
type: 'number'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
//TODO: refactor out of this file.
|
||||||
|
//code length is small, but changes should propagate
|
||||||
|
async function runButtonClick(e: Event) {
|
||||||
|
if (!jobFinishedQueue.value) return
|
||||||
|
try {
|
||||||
|
jobFinishedQueue.value = false
|
||||||
|
resetJobToastTimeout()
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
//TODO: Error state indicator for failed queue?
|
||||||
|
jobFinishedQueue.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ runButtonClick })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-w-80 md:h-full">
|
||||||
|
<section
|
||||||
|
v-if="mobile"
|
||||||
|
data-testid="linear-run-button"
|
||||||
|
class="p-4 pb-6 border-t border-node-component-border"
|
||||||
|
>
|
||||||
|
<WidgetInputNumberInput
|
||||||
|
v-model="batchCount"
|
||||||
|
:widget="batchCountWidget"
|
||||||
|
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||||
|
/>
|
||||||
|
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
|
||||||
|
<div v-else class="flex mt-4 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
class="grow-1"
|
||||||
|
size="lg"
|
||||||
|
@click="runButtonClick"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--play]" />
|
||||||
|
{{ t('menu.run') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!executionStore.isIdle"
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
class="w-10 p-2"
|
||||||
|
@click="commandStore.execute('Comfy.Interrupt')"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--x]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
data-testid="linear-workflow-info"
|
||||||
|
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-bold truncate"
|
||||||
|
v-text="workflowStore.activeWorkflow?.filename"
|
||||||
|
/>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<Popover
|
||||||
|
v-if="partitionedNodes[0].length"
|
||||||
|
align="start"
|
||||||
|
class="overflow-y-auto overflow-x-clip max-h-(--reka-popover-content-available-height)"
|
||||||
|
:reference="notesTo"
|
||||||
|
side="left"
|
||||||
|
:to="notesTo"
|
||||||
|
>
|
||||||
|
<template #button>
|
||||||
|
<Button variant="muted-textonly">
|
||||||
|
<i class="icon-[lucide--info]" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<template
|
||||||
|
v-for="(nodeData, index) in partitionedNodes[0]"
|
||||||
|
:key="nodeData.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="index !== 0"
|
||||||
|
class="w-full border-t border-border-subtle"
|
||||||
|
/>
|
||||||
|
<NodeWidgets
|
||||||
|
:node-data
|
||||||
|
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||||
|
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||||
|
</section>
|
||||||
|
<div
|
||||||
|
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
data-testid="linear-widgets"
|
||||||
|
class="grow-1 md:overflow-y-auto md:contain-size"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(nodeData, index) of partitionedNodes[1]"
|
||||||
|
:key="nodeData.id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="index !== 0"
|
||||||
|
class="w-full border-t-1 border-node-component-border"
|
||||||
|
/>
|
||||||
|
<DropZone
|
||||||
|
:on-drag-over="nodeData.onDragOver"
|
||||||
|
:on-drag-drop="nodeData.onDragDrop"
|
||||||
|
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
|
||||||
|
class="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<NodeWidgets
|
||||||
|
:node-data
|
||||||
|
class="py-3 gap-y-4 **:[.col-span-2]:grid-cols-1 text-sm **:[.p-floatlabel]:h-35 rounded-lg"
|
||||||
|
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||||
|
/>
|
||||||
|
</DropZone>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-if="!mobile"
|
||||||
|
data-testid="linear-run-button"
|
||||||
|
class="p-4 pb-6 border-t border-node-component-border"
|
||||||
|
>
|
||||||
|
<WidgetInputNumberInput
|
||||||
|
v-model="batchCount"
|
||||||
|
:widget="batchCountWidget"
|
||||||
|
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||||
|
/>
|
||||||
|
<SubscribeToRunButton
|
||||||
|
v-if="!isActiveSubscription"
|
||||||
|
class="w-full mt-4"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex mt-4 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
class="grow-1"
|
||||||
|
size="lg"
|
||||||
|
@click="runButtonClick"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--play]" />
|
||||||
|
{{ t('menu.run') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!executionStore.isIdle"
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
class="w-10 p-2"
|
||||||
|
@click="commandStore.execute('Comfy.Interrupt')"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--x]" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Teleport
|
||||||
|
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
|
||||||
|
defer
|
||||||
|
:to="toastTo"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="jobFinishedQueue"
|
||||||
|
class="icon-[lucide--check] size-5 bg-success-background"
|
||||||
|
/>
|
||||||
|
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||||
|
<span v-text="t('queue.jobAddedToQueue')" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
<Teleport v-if="false" defer :to="notesTo">
|
||||||
|
<div
|
||||||
|
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
|
||||||
|
></div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
184
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
184
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { downloadFile } from '@/base/common/downloadUtil'
|
||||||
|
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||||
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { d, t } from '@/i18n'
|
||||||
|
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||||
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||||
|
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||||
|
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||||
|
import {
|
||||||
|
getMediaType,
|
||||||
|
mediaTypes
|
||||||
|
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||||
|
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
import { formatDuration } from '@/utils/dateTimeUtil'
|
||||||
|
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||||
|
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||||
|
|
||||||
|
const mediaActions = useMediaAssetActions()
|
||||||
|
|
||||||
|
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
|
||||||
|
latentPreview?: string
|
||||||
|
runButtonClick?: (e: Event) => void
|
||||||
|
selectedItem?: AssetItem
|
||||||
|
selectedOutput?: ResultItemImpl
|
||||||
|
mobile?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dateOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
} as const
|
||||||
|
const timeOptions = {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
second: 'numeric'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function formatTime(time: string) {
|
||||||
|
if (!time) return ''
|
||||||
|
const date = new Date(time)
|
||||||
|
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemStats = computed<StatItem[]>(() => {
|
||||||
|
if (!selectedItem) return []
|
||||||
|
const user_metadata = getOutputAssetMetadata(selectedItem.user_metadata)
|
||||||
|
if (!user_metadata) return []
|
||||||
|
|
||||||
|
const { allOutputs } = user_metadata
|
||||||
|
return [
|
||||||
|
{ content: formatTime(selectedItem.created_at) },
|
||||||
|
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
|
||||||
|
allOutputs && { content: t('g.asset', allOutputs.length) },
|
||||||
|
(selectedOutput && mediaTypes[getMediaType(selectedOutput)]) ?? {}
|
||||||
|
].filter((i) => !!i)
|
||||||
|
})
|
||||||
|
|
||||||
|
function downloadAsset(item?: AssetItem) {
|
||||||
|
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||||
|
for (const output of user_metadata?.allOutputs ?? [])
|
||||||
|
downloadFile(output.url, output.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkflow(item: AssetItem | undefined) {
|
||||||
|
if (!item) return
|
||||||
|
const { workflow } = await extractWorkflowFromAsset(item)
|
||||||
|
if (!workflow) return
|
||||||
|
|
||||||
|
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
|
||||||
|
//update graph to new version, set old to top of undo queue
|
||||||
|
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||||
|
if (!changeTracker) return app.loadGraphData(workflow)
|
||||||
|
changeTracker.redoQueue = []
|
||||||
|
changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rerun(e: Event) {
|
||||||
|
if (!runButtonClick) return
|
||||||
|
await loadWorkflow(selectedItem)
|
||||||
|
//FIXME don't use timeouts here
|
||||||
|
//Currently seeds fail to properly update even with timeouts?
|
||||||
|
await new Promise((r) => setTimeout(r, 500))
|
||||||
|
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
|
||||||
|
|
||||||
|
runButtonClick(e)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="selectedItem"
|
||||||
|
data-testid="linear-output-info"
|
||||||
|
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3 text-nowrap">
|
||||||
|
<div
|
||||||
|
v-for="({ content, iconClass }, index) in itemStats"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-items-center gap-1 tabular-nums"
|
||||||
|
>
|
||||||
|
<i v-if="iconClass" :class="iconClass" />
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-self-end">
|
||||||
|
<Button size="md" @click="rerun">
|
||||||
|
{{ t('linearMode.rerun') }}
|
||||||
|
<i class="icon-[lucide--refresh-cw]" />
|
||||||
|
</Button>
|
||||||
|
<Button size="md" @click="() => loadWorkflow(selectedItem)">
|
||||||
|
{{ t('linearMode.reuseParameters') }}
|
||||||
|
<i class="icon-[lucide--list-restart]" />
|
||||||
|
</Button>
|
||||||
|
<div class="border-r border-border-subtle mx-1" />
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--download]" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
:entries="[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
icon: 'icon-[lucide--download]',
|
||||||
|
label: t('linearMode.downloadAll'),
|
||||||
|
action: () => downloadAsset(selectedItem!)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
icon: 'icon-[lucide--trash-2]',
|
||||||
|
label: t('queue.jobMenu.deleteAsset'),
|
||||||
|
action: () => mediaActions.confirmDelete(selectedItem!)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ImagePreview
|
||||||
|
v-if="latentPreview ?? getMediaType(selectedOutput) === 'images'"
|
||||||
|
:mobile
|
||||||
|
:src="latentPreview ?? selectedOutput!.url"
|
||||||
|
/>
|
||||||
|
<VideoPreview
|
||||||
|
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||||
|
:src="selectedOutput!.url"
|
||||||
|
class="object-contain flex-1 contain-size"
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="getMediaType(selectedOutput) === 'audio'"
|
||||||
|
class="w-full m-auto"
|
||||||
|
controls
|
||||||
|
:src="selectedOutput!.url"
|
||||||
|
/>
|
||||||
|
<article
|
||||||
|
v-else-if="getMediaType(selectedOutput) === 'text'"
|
||||||
|
class="w-full max-w-128 m-auto my-12 overflow-y-auto"
|
||||||
|
v-text="selectedOutput!.url"
|
||||||
|
/>
|
||||||
|
<Load3dViewerContent
|
||||||
|
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||||
|
:model-url="selectedOutput!.url"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
||||||
|
src="/assets/images/comfy-logo-mono.svg"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
309
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
309
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
|
||||||
|
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
|
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||||
|
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
|
||||||
|
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
|
||||||
|
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||||
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||||
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import {
|
||||||
|
getMediaType,
|
||||||
|
mediaTypes
|
||||||
|
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||||
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const displayWorkflows = ref(false)
|
||||||
|
const outputs = useMediaAssets('output')
|
||||||
|
const queueStore = useQueueStore()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
const workflowTab = useWorkspaceStore()
|
||||||
|
.getSidebarTabs()
|
||||||
|
.find((w) => w.id === 'workflows')
|
||||||
|
|
||||||
|
void outputs.fetchMediaList()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
scrollResetButtonTo?: string | HTMLElement
|
||||||
|
mobile?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updateSelection: [
|
||||||
|
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
||||||
|
]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineExpose({ onWheel })
|
||||||
|
|
||||||
|
const selectedIndex = ref<[number, number]>([-1, 0])
|
||||||
|
|
||||||
|
watch(selectedIndex, () => {
|
||||||
|
const [index] = selectedIndex.value
|
||||||
|
emit('updateSelection', [
|
||||||
|
outputs.media.value[index],
|
||||||
|
selectedOutput.value,
|
||||||
|
selectedIndex.value[0] <= 0
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputsRef = useTemplateRef('outputsRef')
|
||||||
|
const { reset: resetInfiniteScroll } = useInfiniteScroll(
|
||||||
|
outputsRef,
|
||||||
|
outputs.loadMore,
|
||||||
|
{ canLoadMore: () => outputs.hasMore.value }
|
||||||
|
)
|
||||||
|
function resetOutputsScroll() {
|
||||||
|
//TODO need to also prune outputs entries?
|
||||||
|
resetInfiniteScroll()
|
||||||
|
outputsRef.value?.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
const { y: outputScrollState } = useScroll(outputsRef)
|
||||||
|
|
||||||
|
watch(selectedIndex, () => {
|
||||||
|
const [index, key] = selectedIndex.value
|
||||||
|
if (!outputsRef.value) return
|
||||||
|
|
||||||
|
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
|
||||||
|
if (!outputElement) return
|
||||||
|
|
||||||
|
//container: 'nearest' is nice, but bleeding edge and chrome only
|
||||||
|
outputElement.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
|
||||||
|
function allOutputs(item?: AssetItem) {
|
||||||
|
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||||
|
if (!user_metadata?.allOutputs) return []
|
||||||
|
|
||||||
|
return user_metadata.allOutputs
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOutput = computed(() => {
|
||||||
|
const [index, key] = selectedIndex.value
|
||||||
|
if (index < 0) return undefined
|
||||||
|
|
||||||
|
const output = allOutputs(outputs.media.value[index])[key]
|
||||||
|
if (output) return output
|
||||||
|
|
||||||
|
return allOutputs(outputs.media.value[0])[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => outputs.media.value,
|
||||||
|
(newAssets, oldAssets) => {
|
||||||
|
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
|
||||||
|
if (selectedIndex.value[0] <= 0) {
|
||||||
|
//force update
|
||||||
|
selectedIndex.value = [0, 0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
|
||||||
|
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
|
||||||
|
|
||||||
|
if (newIndex === -1) selectedIndex.value = [0, 0]
|
||||||
|
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function gotoNextOutput() {
|
||||||
|
const [index, key] = selectedIndex.value
|
||||||
|
if (index < 0 || key < 0) {
|
||||||
|
selectedIndex.value = [0, 0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentItem = outputs.media.value[index]
|
||||||
|
if (allOutputs(currentItem)[key + 1]) {
|
||||||
|
selectedIndex.value = [index, key + 1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outputs.media.value[index + 1]) {
|
||||||
|
selectedIndex.value = [index + 1, 0]
|
||||||
|
}
|
||||||
|
//do nothing, no next output
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoPreviousOutput() {
|
||||||
|
const [index, key] = selectedIndex.value
|
||||||
|
if (key > 0) {
|
||||||
|
selectedIndex.value = [index, key - 1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index > 0) {
|
||||||
|
const currentItem = outputs.media.value[index - 1]
|
||||||
|
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedIndex.value = [0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let pointer = new CanvasPointer(document.body)
|
||||||
|
let scrollOffset = 0
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
if (!e.ctrlKey && !e.metaKey) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (!pointer.isTrackpadGesture(e)) {
|
||||||
|
if (e.deltaY > 0) gotoNextOutput()
|
||||||
|
else gotoPreviousOutput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollOffset += e.deltaY
|
||||||
|
while (scrollOffset >= 60) {
|
||||||
|
scrollOffset -= 60
|
||||||
|
gotoNextOutput()
|
||||||
|
}
|
||||||
|
while (scrollOffset <= -60) {
|
||||||
|
scrollOffset += 60
|
||||||
|
gotoPreviousOutput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLInputElement
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'ArrowDown') gotoNextOutput()
|
||||||
|
else gotoPreviousOutput()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
|
||||||
|
settingStore.get('Comfy.Sidebar.Location') === 'right'
|
||||||
|
? 'flex-row-reverse border-l'
|
||||||
|
: 'md:border-r'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!mobile"
|
||||||
|
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
|
||||||
|
>
|
||||||
|
<template v-if="workflowTab">
|
||||||
|
<SidebarIcon
|
||||||
|
:icon="workflowTab.icon"
|
||||||
|
:icon-badge="workflowTab.iconBadge"
|
||||||
|
:tooltip="workflowTab.tooltip"
|
||||||
|
:label="workflowTab.label || workflowTab.title"
|
||||||
|
:class="workflowTab.id + '-tab-button'"
|
||||||
|
:selected="displayWorkflows"
|
||||||
|
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
|
||||||
|
@click="displayWorkflows = !displayWorkflows"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<SidebarTemplatesButton />
|
||||||
|
<div class="flex-1" />
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
<div class="border-border-subtle md:border-r" />
|
||||||
|
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50" />
|
||||||
|
<article
|
||||||
|
v-else
|
||||||
|
ref="outputsRef"
|
||||||
|
data-testid="linear-outputs"
|
||||||
|
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
v-if="
|
||||||
|
queueStore.runningTasks.length > 0 ||
|
||||||
|
queueStore.pendingTasks.length > 0
|
||||||
|
"
|
||||||
|
data-testid="linear-job"
|
||||||
|
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="queueStore.runningTasks.length > 0"
|
||||||
|
class="icon-[lucide--loader-circle] size-full animate-spin"
|
||||||
|
/>
|
||||||
|
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||||
|
"
|
||||||
|
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||||
|
v-text="
|
||||||
|
queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-for="(item, index) in outputs.media.value"
|
||||||
|
:key="index"
|
||||||
|
data-testid="linear-job"
|
||||||
|
class="py-3 not-md:h-24 border-border-subtle flex md:flex-col md:w-full px-1 first:border-t-0 first:border-l-0 md:border-t-2 not-md:border-l-2"
|
||||||
|
>
|
||||||
|
<template v-for="(output, key) in allOutputs(item)" :key>
|
||||||
|
<img
|
||||||
|
v-if="getMediaType(output) === 'images'"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-1 rounded-lg aspect-square object-cover',
|
||||||
|
index === selectedIndex[0] &&
|
||||||
|
key === selectedIndex[1] &&
|
||||||
|
'border-2'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:src="output.url"
|
||||||
|
@click="selectedIndex = [index, key]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-1 rounded-lg aspect-square w-full',
|
||||||
|
index === selectedIndex[0] &&
|
||||||
|
key === selectedIndex[1] &&
|
||||||
|
'border-2'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<Teleport
|
||||||
|
v-if="outputScrollState && scrollResetButtonTo"
|
||||||
|
:to="scrollResetButtonTo"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-3 size-10 bg-base-foreground',
|
||||||
|
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||||
|
? 'left-4'
|
||||||
|
: 'right-4'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="resetOutputsScroll"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
|
||||||
|
</Button>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
27
src/renderer/extensions/linearMode/VideoPreview.vue
Normal file
27
src/renderer/extensions/linearMode/VideoPreview.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
const { src } = defineProps<{
|
||||||
|
src: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const videoRef = useTemplateRef('videoRef')
|
||||||
|
const width = ref('')
|
||||||
|
const height = ref('')
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
:src
|
||||||
|
controls
|
||||||
|
v-bind="$attrs"
|
||||||
|
@loadedmetadata="
|
||||||
|
() => {
|
||||||
|
if (!videoRef) return
|
||||||
|
width = `${videoRef.videoWidth}`
|
||||||
|
height = `${videoRef.videoHeight}`
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="self-center z-10" v-text="`${width} x ${height}`" />
|
||||||
|
</template>
|
||||||
33
src/renderer/extensions/linearMode/mediaTypes.ts
Normal file
33
src/renderer/extensions/linearMode/mediaTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
|
|
||||||
|
export type StatItem = { content?: string; iconClass?: string }
|
||||||
|
export const mediaTypes: Record<string, StatItem> = {
|
||||||
|
'3d': {
|
||||||
|
content: t('sideToolbar.mediaAssets.filter3D'),
|
||||||
|
iconClass: 'icon-[lucide--box]'
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
content: t('sideToolbar.mediaAssets.filterAudio'),
|
||||||
|
iconClass: 'icon-[lucide--audio-lines]'
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
content: t('sideToolbar.mediaAssets.filterImage'),
|
||||||
|
iconClass: 'icon-[lucide--image]'
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
content: t('sideToolbar.mediaAssets.filterText'),
|
||||||
|
iconClass: 'icon-[lucide--text]'
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
content: t('sideToolbar.mediaAssets.filterVideo'),
|
||||||
|
iconClass: 'icon-[lucide--video]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMediaType(output?: ResultItemImpl) {
|
||||||
|
if (!output) return ''
|
||||||
|
if (output.isVideo) return 'video'
|
||||||
|
return output.mediaType
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||||
zIndex: zIndex,
|
zIndex: zIndex,
|
||||||
opacity: nodeOpacity,
|
opacity: nodeOpacity,
|
||||||
'--component-node-background': nodeBodyBackgroundColor
|
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
v-bind="remainingPointerHandlers"
|
v-bind="remainingPointerHandlers"
|
||||||
@@ -168,7 +168,6 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
|
|||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
||||||
import { isTransparent } from '@/utils/colorUtil'
|
import { isTransparent } from '@/utils/colorUtil'
|
||||||
import {
|
import {
|
||||||
getLocatorIdFromNodeData,
|
getLocatorIdFromNodeData,
|
||||||
@@ -228,19 +227,6 @@ const bypassed = computed(
|
|||||||
)
|
)
|
||||||
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
|
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
|
||||||
|
|
||||||
const nodeBodyBackgroundColor = computed(() => {
|
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
|
||||||
|
|
||||||
if (!nodeData.bgcolor) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return applyLightThemeColor(
|
|
||||||
nodeData.bgcolor,
|
|
||||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodeOpacity = computed(() => {
|
const nodeOpacity = computed(() => {
|
||||||
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
headerShapeClass
|
headerShapeClass
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
:style="headerStyle"
|
:style="{
|
||||||
|
backgroundColor: applyLightThemeColor(nodeData?.color),
|
||||||
|
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
||||||
|
}"
|
||||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||||
@dblclick="handleDoubleClick"
|
@dblclick="handleDoubleClick"
|
||||||
>
|
>
|
||||||
@@ -104,7 +107,6 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
|||||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
import {
|
import {
|
||||||
getLocatorIdFromNodeData,
|
getLocatorIdFromNodeData,
|
||||||
@@ -156,23 +158,6 @@ const enterSubgraphTooltipConfig = computed(() => {
|
|||||||
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
|
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const headerStyle = computed(() => {
|
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
|
||||||
|
|
||||||
const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
|
|
||||||
|
|
||||||
if (!nodeData?.color) {
|
|
||||||
return { backgroundColor: '', opacity }
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerColor = applyLightThemeColor(
|
|
||||||
nodeData.color,
|
|
||||||
Boolean(colorPaletteStore.completedActivePalette.light_theme)
|
|
||||||
)
|
|
||||||
|
|
||||||
return { backgroundColor: headerColor, opacity }
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveTitle = (info: VueNodeData | undefined) => {
|
const resolveTitle = (info: VueNodeData | undefined) => {
|
||||||
const title = (info?.title ?? '').trim()
|
const title = (info?.title ?? '').trim()
|
||||||
if (title.length > 0) return title
|
if (title.length > 0) return title
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
import { adjustColor } from '@/utils/colorUtil'
|
import { adjustColor } from '@/utils/colorUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies light theme color adjustments to a color
|
* Applies light theme color adjustments to a color
|
||||||
*/
|
*/
|
||||||
export function applyLightThemeColor(
|
export function applyLightThemeColor(color?: string): string {
|
||||||
color: string,
|
if (!color) return ''
|
||||||
isLightTheme: boolean
|
|
||||||
): string {
|
if (!useColorPaletteStore().completedActivePalette.light_theme) return color
|
||||||
if (!color || !isLightTheme) {
|
|
||||||
return color
|
|
||||||
}
|
|
||||||
return adjustColor(color, { lightness: 0.5 })
|
return adjustColor(color, { lightness: 0.5 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1224,6 +1224,8 @@ export class ComfyApp {
|
|||||||
// Fit view if no nodes visible in restored viewport
|
// Fit view if no nodes visible in restored viewport
|
||||||
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
|
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
|
||||||
if (
|
if (
|
||||||
|
this.canvas.visible_area.width &&
|
||||||
|
this.canvas.visible_area.height &&
|
||||||
!anyItemOverlapsRect(
|
!anyItemOverlapsRect(
|
||||||
this.rootGraph._nodes,
|
this.rootGraph._nodes,
|
||||||
this.canvas.visible_area
|
this.canvas.visible_area
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|||||||
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
||||||
const { executionIdToNodeLocatorId } = useExecutionStore()
|
const { executionIdToNodeLocatorId } = useExecutionStore()
|
||||||
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
||||||
const latestOutput = ref<string[]>([])
|
const latestPreview = ref<string[]>([])
|
||||||
|
|
||||||
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
||||||
scheduledRevoke[locator]?.stop()
|
scheduledRevoke[locator]?.stop()
|
||||||
@@ -147,13 +147,6 @@ 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
|
app.nodeOutputs[nodeLocatorId] = outputs
|
||||||
nodeOutputs.value[nodeLocatorId] = outputs
|
nodeOutputs.value[nodeLocatorId] = outputs
|
||||||
}
|
}
|
||||||
@@ -221,7 +214,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|||||||
scheduledRevoke[nodeLocatorId].stop()
|
scheduledRevoke[nodeLocatorId].stop()
|
||||||
delete scheduledRevoke[nodeLocatorId]
|
delete scheduledRevoke[nodeLocatorId]
|
||||||
}
|
}
|
||||||
latestOutput.value = previewImages
|
latestPreview.value = previewImages
|
||||||
app.nodePreviewImages[nodeLocatorId] = previewImages
|
app.nodePreviewImages[nodeLocatorId] = previewImages
|
||||||
nodePreviewImages.value[nodeLocatorId] = previewImages
|
nodePreviewImages.value[nodeLocatorId] = previewImages
|
||||||
}
|
}
|
||||||
@@ -391,6 +384,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
|||||||
// State
|
// State
|
||||||
nodeOutputs,
|
nodeOutputs,
|
||||||
nodePreviewImages,
|
nodePreviewImages,
|
||||||
latestOutput
|
latestPreview
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,3 +75,17 @@ export const formatClockTime = (ts: number, locale: string): string => {
|
|||||||
second: '2-digit'
|
second: '2-digit'
|
||||||
}).format(d)
|
}).format(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDuration(durationSeconds?: number) {
|
||||||
|
if (durationSeconds == undefined) return ''
|
||||||
|
const hours = (durationSeconds / 60 ** 2) | 0
|
||||||
|
const minutes = ((durationSeconds % 60 ** 2) / 60) | 0
|
||||||
|
const seconds = (durationSeconds % 60) | 0
|
||||||
|
const parts = []
|
||||||
|
|
||||||
|
if (hours > 0) parts.push(`${hours}h`)
|
||||||
|
if (minutes > 0) parts.push(`${minutes}m`)
|
||||||
|
if (seconds > 0) parts.push(`${seconds}s`)
|
||||||
|
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
|||||||
await queueStore.update()
|
await queueStore.update()
|
||||||
// Only update assets if the assets sidebar is currently open
|
// Only update assets if the assets sidebar is currently open
|
||||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||||
if (sidebarTabStore.activeSidebarTabId === 'assets') {
|
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||||
await assetsStore.updateHistory()
|
await assetsStore.updateHistory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import {
|
||||||
|
breakpointsTailwind,
|
||||||
|
unrefElement,
|
||||||
|
useBreakpoints,
|
||||||
|
whenever
|
||||||
|
} from '@vueuse/core'
|
||||||
import Splitter from 'primevue/splitter'
|
import Splitter from 'primevue/splitter'
|
||||||
import SplitterPanel from 'primevue/splitterpanel'
|
import SplitterPanel from 'primevue/splitterpanel'
|
||||||
import { computed } from 'vue'
|
import { ref, useTemplateRef } 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 TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
|
||||||
import {
|
|
||||||
isValidWidgetValue,
|
|
||||||
safeWidgetMapper
|
|
||||||
} from '@/composables/graph/useGraphNodeManager'
|
|
||||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.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 { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||||
import { isElectron } from '@/utils/envUtil'
|
|
||||||
|
|
||||||
const nodeOutputStore = useNodeOutputStore()
|
const nodeOutputStore = useNodeOutputStore()
|
||||||
const commandStore = useCommandStore()
|
const settingStore = useSettingStore()
|
||||||
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.rootGraph.nodes
|
|
||||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
|
||||||
.map(nodeToNodeData)
|
|
||||||
})
|
|
||||||
const { isLoggedIn } = useCurrentUser()
|
|
||||||
const isDesktop = isElectron()
|
|
||||||
|
|
||||||
const batchCountWidget = {
|
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||||
options: { step2: 1, precision: 1, min: 1, max: 100 },
|
|
||||||
value: 1,
|
|
||||||
name: t('Number of generations'),
|
|
||||||
type: 'number'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
const hasPreview = ref(false)
|
||||||
|
whenever(
|
||||||
|
() => nodeOutputStore.latestPreview[0],
|
||||||
|
() => (hasPreview.value = true)
|
||||||
|
)
|
||||||
|
|
||||||
//TODO: refactor out of this file.
|
const selectedItem = ref<AssetItem>()
|
||||||
//code length is small, but changes should propagate
|
const selectedOutput = ref<ResultItemImpl>()
|
||||||
async function runButtonClick(e: Event) {
|
const canShowPreview = ref(true)
|
||||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
const outputHistoryRef = useTemplateRef('outputHistoryRef')
|
||||||
const commandId = isShiftPressed
|
|
||||||
? 'Comfy.QueuePromptFront'
|
|
||||||
: 'Comfy.QueuePrompt'
|
|
||||||
|
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
const topLeftRef = useTemplateRef('topLeftRef')
|
||||||
button_id: 'queue_run_linear'
|
const topRightRef = useTemplateRef('topRightRef')
|
||||||
})
|
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||||
if (batchCount.value > 1) {
|
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||||
useTelemetry()?.trackUiButtonClicked({
|
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||||
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="absolute w-full h-full">
|
<div class="absolute w-full h-full">
|
||||||
@@ -111,82 +51,132 @@ function openFeedback() {
|
|||||||
<TopbarBadges />
|
<TopbarBadges />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="mobileDisplay"
|
||||||
|
class="justify-center border-border-subtle border-t overflow-y-scroll h-[calc(100%-38px)] bg-comfy-menu-bg"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col text-muted-foreground">
|
||||||
|
<LinearPreview
|
||||||
|
:latent-preview="
|
||||||
|
canShowPreview && hasPreview
|
||||||
|
? nodeOutputStore.latestPreview[0]
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||||
|
:selected-item
|
||||||
|
:selected-output
|
||||||
|
mobile
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<OutputHistory
|
||||||
|
ref="outputHistoryRef"
|
||||||
|
mobile
|
||||||
|
@update-selection="
|
||||||
|
([item, output, canShow]) => {
|
||||||
|
selectedItem = item
|
||||||
|
selectedOutput = output
|
||||||
|
canShowPreview = canShow
|
||||||
|
hasPreview = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<LinearControls ref="linearWorkflowRef" mobile />
|
||||||
|
<div class="text-base-foreground flex items-center gap-4 justify-end m-4">
|
||||||
|
<div v-text="t('linearMode.beta')" />
|
||||||
|
<TypeformPopoverButton data-tf-widget="gmVqFi8l" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Splitter
|
<Splitter
|
||||||
|
v-else
|
||||||
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
||||||
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }"
|
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }"
|
||||||
|
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||||
>
|
>
|
||||||
<SplitterPanel :size="1" class="min-w-min bg-comfy-menu-bg">
|
<SplitterPanel
|
||||||
|
id="linearLeftPanel"
|
||||||
|
:size="1"
|
||||||
|
class="min-w-min outline-none"
|
||||||
|
>
|
||||||
|
<OutputHistory
|
||||||
|
v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
|
||||||
|
ref="outputHistoryRef"
|
||||||
|
:scroll-reset-button-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||||
|
@update-selection="
|
||||||
|
([item, output, canShow]) => {
|
||||||
|
selectedItem = item
|
||||||
|
selectedOutput = output
|
||||||
|
canShowPreview = canShow
|
||||||
|
hasPreview = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<LinearControls
|
||||||
|
v-else
|
||||||
|
ref="linearWorkflowRef"
|
||||||
|
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||||
|
:notes-to="unrefElement(topLeftRef) ?? undefined"
|
||||||
|
/>
|
||||||
|
<div />
|
||||||
|
</SplitterPanel>
|
||||||
|
<SplitterPanel
|
||||||
|
id="linearCenterPanel"
|
||||||
|
:size="98"
|
||||||
|
class="flex flex-col min-w-min gap-4 mx-2 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
|
||||||
|
@wheel.capture="(e: WheelEvent) => outputHistoryRef?.onWheel(e)"
|
||||||
|
>
|
||||||
|
<LinearPreview
|
||||||
|
:latent-preview="
|
||||||
|
canShowPreview && hasPreview
|
||||||
|
? nodeOutputStore.latestPreview[0]
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||||
|
:selected-item
|
||||||
|
:selected-output
|
||||||
|
/>
|
||||||
|
<div ref="topLeftRef" class="absolute z-20 top-4 left-4" />
|
||||||
|
<div ref="topRightRef" class="absolute z-20 top-4 right-4" />
|
||||||
|
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
|
||||||
|
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
|
||||||
<div
|
<div
|
||||||
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto border-r-1 border-node-component-border"
|
class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<ExtensionSlot :extension="useAssetsSidebarTab()" />
|
<div v-text="t('linearMode.beta')" />
|
||||||
|
<TypeformPopoverButton
|
||||||
|
data-tf-widget="gmVqFi8l"
|
||||||
|
:align="
|
||||||
|
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||||
|
? 'end'
|
||||||
|
: 'start'
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SplitterPanel>
|
</SplitterPanel>
|
||||||
<SplitterPanel
|
<SplitterPanel
|
||||||
:size="98"
|
id="linearRightPanel"
|
||||||
class="flex flex-row overflow-y-auto flex-wrap min-w-min gap-4 m-4"
|
:size="1"
|
||||||
|
class="min-w-min outline-none"
|
||||||
>
|
>
|
||||||
<img
|
<LinearControls
|
||||||
v-for="previewUrl in nodeOutputStore.latestOutput"
|
v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
|
||||||
:key="previewUrl"
|
ref="linearWorkflowRef"
|
||||||
class="pointer-events-none object-contain flex-1 max-h-full"
|
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||||
:src="previewUrl"
|
:notes-to="unrefElement(topRightRef) ?? undefined"
|
||||||
/>
|
/>
|
||||||
<img
|
<OutputHistory
|
||||||
v-if="nodeOutputStore.latestOutput.length === 0"
|
v-else
|
||||||
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
ref="outputHistoryRef"
|
||||||
src="/assets/images/comfy-logo-mono.svg"
|
:scroll-reset-button-to="unrefElement(bottomRightRef) ?? undefined"
|
||||||
|
@update-selection="
|
||||||
|
([item, output, canShow]) => {
|
||||||
|
selectedItem = item
|
||||||
|
selectedOutput = output
|
||||||
|
canShowPreview = canShow
|
||||||
|
hasPreview = false
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</SplitterPanel>
|
<div />
|
||||||
<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-end"
|
|
||||||
>
|
|
||||||
<Button variant="secondary" @click="openFeedback">
|
|
||||||
{{ t('g.feedback') }}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
class="min-w-max"
|
|
||||||
@click="useCanvasStore().linearMode = false"
|
|
||||||
>
|
|
||||||
{{ t('linearMode.openWorkflow') }}
|
|
||||||
<i class="icon-[comfy--workflow]" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="inverted"
|
|
||||||
@click="useWorkflowService().exportWorkflow('workflow', 'workflow')"
|
|
||||||
>
|
|
||||||
{{ t('linearMode.share') }}
|
|
||||||
</Button>
|
|
||||||
<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 class="w-full mt-4" @click="runButtonClick">
|
|
||||||
<i class="icon-[lucide--play]" />
|
|
||||||
{{ t('menu.run') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SplitterPanel>
|
</SplitterPanel>
|
||||||
</Splitter>
|
</Splitter>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -446,6 +446,9 @@ export default defineConfig({
|
|||||||
if (id.includes('/vue') || id.includes('pinia')) {
|
if (id.includes('/vue') || id.includes('pinia')) {
|
||||||
return 'vendor-vue'
|
return 'vendor-vue'
|
||||||
}
|
}
|
||||||
|
if (id.includes('reka-ui')) {
|
||||||
|
return 'vendor-reka-ui'
|
||||||
|
}
|
||||||
|
|
||||||
return 'vendor-other'
|
return 'vendor-other'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user