mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
84 Commits
fix/qwenvl
...
austin/lin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1c8a1c06 | ||
|
|
11c43ea5f5 | ||
|
|
4b9909701c | ||
|
|
d0cc44d962 | ||
|
|
aa91b52f6b | ||
|
|
532b8f0454 | ||
|
|
42025ab6d4 | ||
|
|
12e9bf6e34 | ||
|
|
4baf1cf355 | ||
|
|
ab39a394d1 | ||
|
|
129d3b0aa4 | ||
|
|
fc55fa9005 | ||
|
|
835a0fcc3e | ||
|
|
7cfe959340 | ||
|
|
4e1d46613f | ||
|
|
21559b079d | ||
|
|
d9547516dd | ||
|
|
2845320ddf | ||
|
|
664150b6a3 | ||
|
|
6071b63077 | ||
|
|
82fac5bc0b | ||
|
|
10752c31af | ||
|
|
0ef5c91a82 | ||
|
|
cfa3800ae3 | ||
|
|
6200722c39 | ||
|
|
f01bb4d8d2 | ||
|
|
5c693c5d23 | ||
|
|
8ffcf5a143 | ||
|
|
41ea0387d7 | ||
|
|
fc07cd15b0 | ||
|
|
1166e2a0e6 | ||
|
|
6fdfc72019 | ||
|
|
8a8325353b | ||
|
|
f8b6981fec | ||
|
|
5a05a6b9f5 | ||
|
|
522ad67ced | ||
|
|
5cf57ff8e8 | ||
|
|
24500415cf | ||
|
|
7cf8e89f44 | ||
|
|
1d611e857c | ||
|
|
44bd2469ef | ||
|
|
a780efb5ff | ||
|
|
e652944586 | ||
|
|
dbd1c30c98 | ||
|
|
0addee8fb1 | ||
|
|
eee096120a | ||
|
|
38479c4a40 | ||
|
|
8f83468eaa | ||
|
|
d1dd276335 | ||
|
|
9202b80ee2 | ||
|
|
1151350ee4 | ||
|
|
09021b605c | ||
|
|
3cbdc30517 | ||
|
|
cdb422fefc | ||
|
|
b145566a7f | ||
|
|
76c722c656 | ||
|
|
aa6a0580ec | ||
|
|
a9e5989a3a | ||
|
|
c7d1869e41 | ||
|
|
df974e56a2 | ||
|
|
79bd8a4dd2 | ||
|
|
5e740c6efe | ||
|
|
0beebd0302 | ||
|
|
63ad1b289b | ||
|
|
7051b5e491 | ||
|
|
a7a7ea348f | ||
|
|
a96e0dec0c | ||
|
|
1f17f5f1b5 | ||
|
|
49ede05221 | ||
|
|
d5d995de80 | ||
|
|
75cc8b8b59 | ||
|
|
b77f028097 | ||
|
|
4cb0eb6fec | ||
|
|
d1b4ec44df | ||
|
|
1c007d650d | ||
|
|
24523a5397 | ||
|
|
7ef03de7fb | ||
|
|
842043999c | ||
|
|
461ee6c6d6 | ||
|
|
302990ca9b | ||
|
|
a35b87354f | ||
|
|
735a60e6d7 | ||
|
|
5455c85a29 | ||
|
|
323649557f |
@@ -245,6 +245,7 @@
|
||||
--inverted-background-hover: var(--color-charcoal-600);
|
||||
--warning-background: var(--color-gold-400);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-smoke-600);
|
||||
--border-subtle: var(--color-smoke-400);
|
||||
--muted-background: var(--color-smoke-700);
|
||||
@@ -370,6 +371,7 @@
|
||||
--inverted-background-hover: var(--color-smoke-200);
|
||||
--warning-background: var(--color-gold-600);
|
||||
--warning-background-hover: var(--color-gold-500);
|
||||
--success-background: var(--color-jade-600);
|
||||
--border-default: var(--color-charcoal-200);
|
||||
--border-subtle: var(--color-charcoal-300);
|
||||
--muted-background: var(--color-charcoal-100);
|
||||
@@ -514,6 +516,7 @@
|
||||
--color-inverted-background-hover: var(--inverted-background-hover);
|
||||
--color-warning-background: var(--warning-background);
|
||||
--color-warning-background-hover: var(--warning-background-hover);
|
||||
--color-success-background: var(--success-background);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-muted-background: var(--muted-background);
|
||||
|
||||
31
src/components/sidebar/ModeToggle.vue
Normal file
31
src/components/sidebar/ModeToggle.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
class="disabled:opacity-100"
|
||||
size="icon"
|
||||
:title="t('Simple Mode')"
|
||||
:disabled="canvasStore.linearMode"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="canvasStore.linearMode = true"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
class="disabled:opacity-100"
|
||||
size="icon"
|
||||
:title="t('Graph Mode')"
|
||||
:disabled="!canvasStore.linearMode"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="canvasStore.linearMode = false"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,6 +44,12 @@
|
||||
<SidebarBottomPanelToggleButton :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
<ModeToggle
|
||||
v-if="
|
||||
useFeatureFlags().flags.linearToggleEnabled ||
|
||||
canvasStore.linearMode
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -55,9 +61,11 @@ import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.workflows')"
|
||||
v-bind="$attrs"
|
||||
class="workflows-sidebar-tab"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
@@ -151,6 +152,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
@@ -238,7 +240,12 @@ const renderTreeNode = (
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (this.leaf) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const fromLinearMode = canvasStore.linearMode
|
||||
await workflowService.openWorkflow(workflow)
|
||||
const extra = workflow.activeState?.extra
|
||||
if (extra && extra.linearMode === undefined && fromLinearMode)
|
||||
canvasStore.linearMode = extra.linearMode = true
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-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">
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
|
||||
75
src/components/ui/Popover.vue
Normal file
75
src/components/ui/Popover.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<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
|
||||
}>()
|
||||
</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>
|
||||
<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">
|
||||
<popover-group
|
||||
v-for="(entryGroup, index) in entries ?? []"
|
||||
:key="index"
|
||||
class="flex flex-col border-b-2 last:border-none border-border-subtle"
|
||||
>
|
||||
<popover-item
|
||||
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 }}
|
||||
</popover-item>
|
||||
</popover-group>
|
||||
</div>
|
||||
</slot>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
63
src/components/ui/ZoomPane.vue
Normal file
63
src/components/ui/ZoomPane.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots, useTemplateRef, watch } from 'vue'
|
||||
|
||||
const zoomPane = useTemplateRef('zoomPane')
|
||||
|
||||
const zoom = ref(1.0)
|
||||
const panX = ref(0.0)
|
||||
const panY = ref(0.0)
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
watch(
|
||||
() => slots.default?.(),
|
||||
() => {
|
||||
zoom.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 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) {
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
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 justify-center align-center"
|
||||
@wheel="handleWheel"
|
||||
@pointerdown.prevent="handleDown"
|
||||
@pointermove="handleMove"
|
||||
@pointerup="dragging = false"
|
||||
>
|
||||
<slot :style="{ transform }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -198,17 +198,6 @@ export function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidWidgetValue(value: unknown): value is WidgetValue {
|
||||
return (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
|
||||
@@ -1179,7 +1179,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'toggle linear mode',
|
||||
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
|
||||
function: () => {
|
||||
const newMode = !canvasStore.linearMode
|
||||
canvasStore.linearMode = newMode
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ export enum ServerFeatureFlag {
|
||||
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
|
||||
PRIVATE_MODELS_ENABLED = 'private_models_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'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +74,12 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
)
|
||||
},
|
||||
get linearToggleEnabled() {
|
||||
return (
|
||||
remoteConfig.value.linear_toggle_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -665,7 +665,8 @@
|
||||
"filterImage": "Image",
|
||||
"filterVideo": "Video",
|
||||
"filterAudio": "Audio",
|
||||
"filter3D": "3D"
|
||||
"filter3D": "3D",
|
||||
"filterText": "Text"
|
||||
},
|
||||
"backToAssets": "Back to all assets",
|
||||
"searchAssets": "Search Assets",
|
||||
@@ -2403,8 +2404,13 @@
|
||||
"message": "Switch back to Nodes 2.0 anytime from the main menu."
|
||||
},
|
||||
"linearMode": {
|
||||
"share": "Share",
|
||||
"openWorkflow": "Open Workflow"
|
||||
"linearMode": "Simple Mode",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Drag and drop an image",
|
||||
"runCount": "Run count:",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download All"
|
||||
},
|
||||
"missingNodes": {
|
||||
"cloud": {
|
||||
|
||||
@@ -39,4 +39,5 @@ export type RemoteConfig = {
|
||||
private_models_enabled?: boolean
|
||||
onboarding_survey_enabled?: boolean
|
||||
huggingface_model_import_enabled?: boolean
|
||||
linear_toggle_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -121,28 +122,7 @@ export function useTemplateWorkflows() {
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = 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
|
||||
sourceModule = template.sourceModule
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
@@ -159,12 +139,18 @@ export function useTemplateWorkflows() {
|
||||
template_source: sourceModule
|
||||
})
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const fromLinearMode = canvasStore.linearMode
|
||||
await app.loadGraphData(json, true, true, workflowName, {
|
||||
openSource: 'template'
|
||||
})
|
||||
|
||||
const extra = app.rootGraph.extra
|
||||
if (extra && extra.linearMode === undefined && fromLinearMode)
|
||||
canvasStore.linearMode = extra.linearMode = true
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
|
||||
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>
|
||||
<drop-wrapper
|
||||
v-if="onDragOver && onDragDrop"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg ring-inset ring-primary-500',
|
||||
canAcceptDrop && 'ring-4 bg-primary-500/10'
|
||||
)
|
||||
"
|
||||
@dragover.prevent="(e: DragEvent) => (canAcceptDrop = onDragOver!(e))"
|
||||
@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 w-full border-border-subtle my-3 py-2',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="(e: MouseEvent) => dropIndicator!.onClick?.(e)"
|
||||
>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</div>
|
||||
</drop-wrapper>
|
||||
<slot v-else />
|
||||
</template>
|
||||
30
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
30
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane 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>
|
||||
<span class="self-center z-10" v-text="`${width} x ${height}`" />
|
||||
</template>
|
||||
192
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
192
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<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 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 { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const mobile = true
|
||||
|
||||
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
|
||||
latentPreview?: string
|
||||
runButtonClick?: (e: Event) => void
|
||||
selectedItem?: AssetItem
|
||||
selectedOutput?: ResultItemImpl
|
||||
}>()
|
||||
|
||||
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)}`
|
||||
}
|
||||
|
||||
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(' ')
|
||||
}
|
||||
|
||||
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: `${allOutputs.length} asset` },
|
||||
(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)
|
||||
}
|
||||
|
||||
function loadWorkflow(item: AssetItem | undefined) {
|
||||
const workflow = getOutputAssetMetadata(item?.user_metadata)?.workflow
|
||||
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
|
||||
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>
|
||||
<linear-output-info
|
||||
v-if="selectedItem"
|
||||
class="flex gap-2 p-1 w-full items-center z-10 tabular-nums"
|
||||
>
|
||||
<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 class="grow" />
|
||||
<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="h-full 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!)
|
||||
}
|
||||
]
|
||||
]"
|
||||
/>
|
||||
</linear-output-info>
|
||||
<div
|
||||
v-if="getMediaType(selectedOutput) === 'images' && mobile"
|
||||
class="aspect-square w-full flex"
|
||||
>
|
||||
<img :src="latentPreview ?? selectedOutput!.url" />
|
||||
</div>
|
||||
<ImagePreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'images'"
|
||||
: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>
|
||||
242
src/renderer/extensions/linearMode/LinearWorkflow.vue
Normal file
242
src/renderer/extensions/linearMode/LinearWorkflow.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { safeWidgetMapper } 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 { 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 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'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
const showNoteData = ref(false)
|
||||
|
||||
defineProps<{
|
||||
toastTo: string | HTMLElement
|
||||
notesTo: string | HTMLElement
|
||||
}>()
|
||||
|
||||
const jobFinishedQueue = ref(true)
|
||||
const {
|
||||
ready: jobToastTimeout,
|
||||
start: resetJobToastTimeout,
|
||||
stop: stopJobTimeout
|
||||
} = useTimeout(5000, { controls: true })
|
||||
stopJobTimeout()
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const mapper = safeWidgetMapper(node, new Map())
|
||||
const widgets = node.widgets?.map(mapper) ?? []
|
||||
const dropIndicator =
|
||||
node.type !== 'LoadImage'
|
||||
? undefined
|
||||
: {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
label: t('linearMode.dragAndDropImage')
|
||||
}
|
||||
//of VueNodeData, only widgets is actually used
|
||||
return {
|
||||
executing: false,
|
||||
id: `${node.id}`,
|
||||
mode: 0,
|
||||
selected: false,
|
||||
title: node.title,
|
||||
type: node.type,
|
||||
widgets,
|
||||
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
|
||||
const nodeDatas = computed(() => {
|
||||
return graphNodes.value
|
||||
.filter(
|
||||
(node) =>
|
||||
node.mode === 0 &&
|
||||
node.widgets?.length &&
|
||||
!['MarkdownNote', 'Note'].includes(node.type)
|
||||
)
|
||||
.map(nodeToNodeData)
|
||||
.reverse()
|
||||
})
|
||||
const noteDatas = computed(() => {
|
||||
return graphNodes.value
|
||||
.filter(
|
||||
(node) => node.mode === 0 && ['MarkdownNote', 'Note'].includes(node.type)
|
||||
)
|
||||
.map(nodeToNodeData)
|
||||
})
|
||||
|
||||
const batchCountWidget = {
|
||||
options: { precision: 0, min: 1, max: 99 },
|
||||
value: 1,
|
||||
name: t('linearMode.runCount'),
|
||||
type: 'number'
|
||||
}
|
||||
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
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>
|
||||
<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>
|
||||
</linear-run-button>
|
||||
<linear-workflow-info
|
||||
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center"
|
||||
>
|
||||
<span
|
||||
class="font-bold truncate min-w-30"
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
v-if="noteDatas.length"
|
||||
variant="muted-textonly"
|
||||
@click="showNoteData = !showNoteData"
|
||||
>
|
||||
<i class="icon-[lucide--info]" />
|
||||
</Button>
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
</linear-workflow-info>
|
||||
<div
|
||||
class="border gap-2 border-[var(--interface-stroke)] bg-comfy-menu-bg px-2"
|
||||
>
|
||||
<linear-widgets>
|
||||
<template v-for="(nodeData, index) of nodeDatas" :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="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"
|
||||
/>
|
||||
</DropZone>
|
||||
</template>
|
||||
</linear-widgets>
|
||||
</div>
|
||||
</div>
|
||||
<teleport v-if="!jobToastTimeout || !jobFinishedQueue" 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"
|
||||
/>
|
||||
<ProgressSpinner v-else class="size-4" />
|
||||
<span v-text="t('queue.jobAddedToQueue')" />
|
||||
</div>
|
||||
</teleport>
|
||||
<teleport v-if="showNoteData" 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"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="self-end mr-3"
|
||||
@click="showNoteData = false"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
<template v-for="nodeData in noteDatas" :key="nodeData.id">
|
||||
<div class="w-full border-t border-border-subtle" />
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
310
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
310
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from '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 { t } from '@/i18n'
|
||||
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 { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const displayWorkflows = ref(false)
|
||||
const outputs = useMediaAssets('output')
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
void outputs.fetchMediaList()
|
||||
|
||||
defineProps<{
|
||||
scrollResetButtonTo: string | HTMLElement
|
||||
horizontal?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'updateSelection',
|
||||
selection: [AssetItem, ResultItemImpl, [number, number]]
|
||||
): void
|
||||
}>()
|
||||
|
||||
defineExpose({ onWheel })
|
||||
|
||||
const selectedIndex = ref<[number, number]>([0, 0])
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index] = selectedIndex.value
|
||||
emit('updateSelection', [
|
||||
filteredOutputs.value[index],
|
||||
selectedOutput.value,
|
||||
selectedIndex.value
|
||||
])
|
||||
})
|
||||
|
||||
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' })
|
||||
})
|
||||
|
||||
const filteredOutputs = computed(() => {
|
||||
const currentId = workflowStore.activeWorkflow?.activeState?.id
|
||||
return outputs.media.value.filter(
|
||||
(item) =>
|
||||
getOutputAssetMetadata(item?.user_metadata)?.workflow?.id === currentId
|
||||
)
|
||||
})
|
||||
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 && key >= 0) {
|
||||
const output = allOutputs(filteredOutputs.value[index])[key]
|
||||
if (output) return output
|
||||
}
|
||||
return allOutputs(filteredOutputs.value[0])[0]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filteredOutputs.value,
|
||||
() => {
|
||||
//TODO: Consider replace with resetOutputsScroll?
|
||||
selectedIndex.value = [0, 0]
|
||||
}
|
||||
)
|
||||
|
||||
function gotoNextOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0 || key < 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
}
|
||||
const currentItem = filteredOutputs.value[index]
|
||||
if (allOutputs(currentItem)[key + 1]) {
|
||||
selectedIndex.value = [index, key + 1]
|
||||
return
|
||||
}
|
||||
if (filteredOutputs.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 = filteredOutputs.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 h-full',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'right' &&
|
||||
'flex-row-reverse',
|
||||
horizontal && 'h-30'
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
v-if="!horizontal"
|
||||
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2 border-r border-node-component-border"
|
||||
>
|
||||
<SidebarIcon
|
||||
icon="icon-[comfy--workflow]"
|
||||
:selected="displayWorkflows"
|
||||
@click="displayWorkflows = !displayWorkflows"
|
||||
/>
|
||||
<SidebarTemplatesButton />
|
||||
<div class="flex-1" />
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
class="disabled:opacity-100"
|
||||
size="icon"
|
||||
:title="t('linearMode.linearMode')"
|
||||
disabled
|
||||
variant="inverted"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
:title="t('linearMode.graphMode')"
|
||||
@click="useCanvasStore().linearMode = false"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50" />
|
||||
<linear-outputs
|
||||
v-else
|
||||
ref="outputsRef"
|
||||
:class="
|
||||
cn(
|
||||
'min-w-24 grow-1 p-3 border-r-1 border-node-component-border flex items-center contain-size',
|
||||
horizontal ? 'overflow-x-auto' : 'flex-col overflow-y-auto w-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<linear-job
|
||||
v-if="queueStore.runningTasks.length > 0"
|
||||
class="py-3 aspect-square px-1 relative"
|
||||
>
|
||||
<ProgressSpinner class="size-full" />
|
||||
<div
|
||||
v-if="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||
"
|
||||
class="absolute top-0 right-0 p-1 min-w-5 h-5 justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||
v-text="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
"
|
||||
/>
|
||||
</linear-job>
|
||||
<linear-job
|
||||
v-for="(item, index) in filteredOutputs"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'border-border-subtle flex',
|
||||
horizontal
|
||||
? 'h-full px-3 py-1 first:border-l-0 border-l-2'
|
||||
: 'flex-col w-full py-3 px-1 first:border-t-0 border-t-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>
|
||||
</linear-job>
|
||||
</linear-outputs>
|
||||
</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
|
||||
}
|
||||
@@ -110,7 +110,10 @@ const buttonTooltip = computed(() => {
|
||||
<span class="pi pi-minus text-sm" />
|
||||
</template>
|
||||
</InputNumber>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
|
||||
@@ -1227,6 +1227,8 @@ export class ComfyApp {
|
||||
// Fit view if no nodes visible in restored viewport
|
||||
this.canvas.ds.computeVisibleArea(this.canvas.viewport)
|
||||
if (
|
||||
this.canvas.visible_area.width &&
|
||||
this.canvas.visible_area.height &&
|
||||
!anyItemOverlapsRect(
|
||||
this.rootGraph._nodes,
|
||||
this.canvas.visible_area
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
|
||||
const { executionIdToNodeLocatorId } = useExecutionStore()
|
||||
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
|
||||
const latestOutput = ref<string[]>([])
|
||||
const latestPreview = ref<string[]>([])
|
||||
|
||||
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
|
||||
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
|
||||
nodeOutputs.value[nodeLocatorId] = outputs
|
||||
}
|
||||
@@ -221,7 +214,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
scheduledRevoke[nodeLocatorId].stop()
|
||||
delete scheduledRevoke[nodeLocatorId]
|
||||
}
|
||||
latestOutput.value = previewImages
|
||||
latestPreview.value = previewImages
|
||||
app.nodePreviewImages[nodeLocatorId] = previewImages
|
||||
nodePreviewImages.value[nodeLocatorId] = previewImages
|
||||
}
|
||||
@@ -391,6 +384,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
// State
|
||||
nodeOutputs,
|
||||
nodePreviewImages,
|
||||
latestOutput
|
||||
latestPreview
|
||||
}
|
||||
})
|
||||
|
||||
@@ -211,7 +211,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
|
||||
await queueStore.update()
|
||||
// Only update assets if the assets sidebar is currently open
|
||||
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets') {
|
||||
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
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 WorkflowTabs from '@/components/topbar/WorkflowTabs.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 type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearWorkflow from '@/renderer/extensions/linearMode/LinearWorkflow.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const commandStore = useCommandStore()
|
||||
const nodeDatas = computed(() => {
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const mapper = safeWidgetMapper(node, new Map())
|
||||
const widgets =
|
||||
node.widgets?.map((widget) => {
|
||||
const safeWidget = mapper(widget)
|
||||
safeWidget.callback = function (value) {
|
||||
if (!isValidWidgetValue(value)) return
|
||||
widget.value = value ?? undefined
|
||||
return widget.callback?.(widget.value)
|
||||
}
|
||||
return safeWidget
|
||||
}) ?? []
|
||||
//Only widgets is actually used
|
||||
return {
|
||||
id: `${node.id}`,
|
||||
title: node.title,
|
||||
type: node.type,
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets
|
||||
}
|
||||
}
|
||||
return app.rootGraph.nodes
|
||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||
.map(nodeToNodeData)
|
||||
})
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const batchCountWidget = {
|
||||
options: { step2: 1, precision: 1, min: 1, max: 100 },
|
||||
value: 1,
|
||||
name: t('Number of generations'),
|
||||
type: 'number'
|
||||
}
|
||||
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const hasPreview = ref(false)
|
||||
whenever(
|
||||
() => nodeOutputStore.latestPreview[0],
|
||||
() => (hasPreview.value = true)
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
const selectedItem = ref<AssetItem | undefined>()
|
||||
const selectedOutput = ref<ResultItemImpl | undefined>()
|
||||
const selectedIndex = ref<[number, number]>([0, 0])
|
||||
const outputHistoryRef = useTemplateRef('outputHistoryRef')
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_linear'
|
||||
})
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
}
|
||||
function openFeedback() {
|
||||
//TODO: Does not link to a linear specific feedback section
|
||||
window.open(
|
||||
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=40026345549204',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</script>
|
||||
<template>
|
||||
<div class="absolute w-full h-full">
|
||||
@@ -111,85 +40,118 @@ function openFeedback() {
|
||||
<TopbarBadges />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="mobileDisplay"
|
||||
class="overflow-y-auto w-full h-full text-muted-foreground"
|
||||
>
|
||||
<OutputHistory
|
||||
ref="outputHistoryRef"
|
||||
scroll-reset-button-to="#linearDockBottomLeft"
|
||||
horizontal
|
||||
@update-selection="
|
||||
(e) => {
|
||||
;[selectedItem, selectedOutput, selectedIndex] = e
|
||||
hasPreview = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<LinearPreview
|
||||
:latent-preview="
|
||||
selectedIndex[0] === 0 && selectedIndex[1] === 0 && hasPreview
|
||||
? nodeOutputStore.latestPreview[0]
|
||||
: undefined
|
||||
"
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:selected-item
|
||||
:selected-output
|
||||
/>
|
||||
<div class="relative flex justify-center">
|
||||
<div id="linearDockMobileNotes" class="absolute z-20 top-0" />
|
||||
</div>
|
||||
<LinearWorkflow
|
||||
ref="linearWorkflowRef"
|
||||
toast-to="#linearDockMobileToast"
|
||||
notes-to="#linearDockMobileNotes"
|
||||
/>
|
||||
<div id="linearDockMobileToast" class="absolute bottom-20 z-20" />
|
||||
</div>
|
||||
<Splitter
|
||||
v-else
|
||||
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
||||
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }"
|
||||
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||
>
|
||||
<SplitterPanel :size="1" class="min-w-min bg-comfy-menu-bg">
|
||||
<div
|
||||
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto border-r-1 border-node-component-border"
|
||||
>
|
||||
<ExtensionSlot :extension="useAssetsSidebarTab()" />
|
||||
</div>
|
||||
<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="#linearDockBottomLeft"
|
||||
@update-selection="
|
||||
(e) => {
|
||||
;[selectedItem, selectedOutput, selectedIndex] = e
|
||||
hasPreview = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<LinearWorkflow
|
||||
v-else
|
||||
ref="linearWorkflowRef"
|
||||
toast-to="#linearDockBottomLeft"
|
||||
notes-to="#linearDockTopLeft"
|
||||
/>
|
||||
<div />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="98"
|
||||
class="flex flex-row overflow-y-auto flex-wrap min-w-min gap-4 m-4"
|
||||
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)"
|
||||
>
|
||||
<img
|
||||
v-for="previewUrl in nodeOutputStore.latestOutput"
|
||||
:key="previewUrl"
|
||||
class="pointer-events-none object-contain flex-1 max-h-full"
|
||||
:src="previewUrl"
|
||||
<LinearPreview
|
||||
:latent-preview="
|
||||
selectedIndex[0] === 0 && selectedIndex[1] === 0 && hasPreview
|
||||
? nodeOutputStore.latestPreview[0]
|
||||
: undefined
|
||||
"
|
||||
:run-button-click="linearWorkflowRef?.runButtonClick"
|
||||
:selected-item
|
||||
:selected-output
|
||||
/>
|
||||
<img
|
||||
v-if="nodeOutputStore.latestOutput.length === 0"
|
||||
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
<div id="linearDockTopLeft" class="absolute z-20 top-4 left-4" />
|
||||
<div id="linearDockTopRight" class="absolute z-20 top-4 right-4" />
|
||||
<div id="linearDockBottomLeft" class="absolute z-20 bottom-4 left-4" />
|
||||
<div
|
||||
id="linearDockBottomRight"
|
||||
class="absolute z-20 bottom-4 right-4"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel :size="1" class="flex flex-col gap-1 p-1 min-w-min">
|
||||
<div
|
||||
class="actionbar-container flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] p-2 gap-2 bg-comfy-menu-bg justify-end"
|
||||
>
|
||||
<Button
|
||||
:label="t('g.feedback')"
|
||||
severity="secondary"
|
||||
@click="openFeedback"
|
||||
/>
|
||||
<Button
|
||||
:label="t('linearMode.openWorkflow')"
|
||||
severity="secondary"
|
||||
class="min-w-max"
|
||||
icon="icon-[comfy--workflow]"
|
||||
icon-pos="right"
|
||||
@click="useCanvasStore().linearMode = false"
|
||||
/>
|
||||
<Button
|
||||
:label="t('linearMode.share')"
|
||||
severity="contrast"
|
||||
@click="useWorkflowService().exportWorkflow('workflow', 'workflow')"
|
||||
/>
|
||||
<CurrentUserButton v-if="isLoggedIn" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border p-2 gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="grow-1 flex justify-start flex-col overflow-y-auto contain-size *:max-h-100"
|
||||
>
|
||||
<NodeWidgets
|
||||
v-for="nodeData of nodeDatas"
|
||||
:key="nodeData.id"
|
||||
:node-data
|
||||
class="border-b-1 border-node-component-border pt-1 pb-2 last:border-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4 pb-0 border-t border-node-component-border">
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-56]:basis-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('menu.run')"
|
||||
class="w-full mt-4"
|
||||
icon="icon-[lucide--play]"
|
||||
@click="runButtonClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SplitterPanel
|
||||
id="linearRightPanel"
|
||||
:size="1"
|
||||
class="min-w-min outline-none"
|
||||
>
|
||||
<LinearWorkflow
|
||||
v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
|
||||
ref="linearWorkflowRef"
|
||||
toast-to="#linearDockBottomRight"
|
||||
notes-to="#linearDockTopRight"
|
||||
/>
|
||||
<OutputHistory
|
||||
v-else
|
||||
ref="outputHistoryRef"
|
||||
scroll-reset-button-to="#linearDockBottomRight"
|
||||
@update-selection="
|
||||
(e) => {
|
||||
;[selectedItem, selectedOutput, selectedIndex] = e
|
||||
hasPreview = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
|
||||
@@ -446,6 +446,9 @@ export default defineConfig({
|
||||
if (id.includes('/vue') || id.includes('pinia')) {
|
||||
return 'vendor-vue'
|
||||
}
|
||||
if (id.includes('reka-ui')) {
|
||||
return 'vendor-reka-ui'
|
||||
}
|
||||
|
||||
return 'vendor-other'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user