Compare commits

...

84 Commits

Author SHA1 Message Date
Austin Mroz
3e1c8a1c06 Move run button up, fix font color 2026-01-08 10:12:19 -08:00
Austin Mroz
11c43ea5f5 Fix notes positioning 2026-01-08 09:43:41 -08:00
Austin Mroz
4b9909701c WIP mobile layout for linear 2026-01-08 09:22:55 -08:00
Austin Mroz
d0cc44d962 Move center preview to component 2026-01-07 23:23:18 -08:00
Austin Mroz
aa91b52f6b Move workflow/widgets into component 2026-01-07 22:46:46 -08:00
Austin Mroz
532b8f0454 Fix scrollToTop button, respect sidebar location 2026-01-07 21:50:09 -08:00
Austin Mroz
42025ab6d4 Magically fix build 2026-01-07 21:31:56 -08:00
Austin Mroz
12e9bf6e34 Refactor outputs into sub-component 2026-01-07 20:22:27 -08:00
Austin Mroz
4baf1cf355 Sizing cleanup 2026-01-07 15:02:29 -08:00
Austin Mroz
ab39a394d1 Fix pending jobs count 2026-01-07 13:49:59 -08:00
Austin Mroz
129d3b0aa4 Update floating items to reflect sidebar location 2026-01-07 13:34:32 -08:00
Austin Mroz
fc55fa9005 More reliable dimension tracking 2026-01-07 12:49:42 -08:00
Austin Mroz
835a0fcc3e Use teleport for panel content
Simpler than fully untangling dependencies, but still pushes towards it
2026-01-07 11:44:43 -08:00
Austin Mroz
7cfe959340 Fix guard on selected item watch 2026-01-07 10:56:41 -08:00
Austin Mroz
4e1d46613f Prevent deselecting output 2026-01-06 20:39:00 -08:00
Austin Mroz
21559b079d Update locales 2026-01-06 18:26:50 -08:00
Austin Mroz
d9547516dd Move note popover, widget styling 2026-01-06 18:15:37 -08:00
Austin Mroz
2845320ddf Workflows/Templates fixes
Match styles

Make workflows button functional

When opening a workflow or template from linear mode that does not
explicitly have a value set for linear mode, open the workflow or
template in linear mode

Known issue: workflows sidebar pane has a higher min-width than the
outputs panel. This is not an easy fix and I may have to re-implement
our own splitter component to smooth over the annoyances I've faced
2026-01-06 11:49:32 -08:00
Austin Mroz
664150b6a3 Make download all download all 2026-01-06 10:13:15 -08:00
Austin Mroz
6071b63077 Remove Primevue divider. Move tabular-nums up 2026-01-06 08:24:31 -08:00
Austin Mroz
82fac5bc0b Use tabular nums for item stats
Potentially worth adding additional padding, but this is a small win
2026-01-06 00:16:59 -08:00
Austin Mroz
10752c31af Remove click handler, reset only scroll 2026-01-06 00:13:30 -08:00
Austin Mroz
0ef5c91a82 Use temp variable when toggling linearMode
This seems to make the workflow indicator more consistent
2026-01-05 22:52:40 -08:00
Austin Mroz
cfa3800ae3 Add badge to outputs if multiple jobs queued 2026-01-05 22:45:14 -08:00
Austin Mroz
6200722c39 Minor component nits 2026-01-05 22:22:28 -08:00
Austin Mroz
f01bb4d8d2 Add workflow info popover 2026-01-05 21:52:53 -08:00
Austin Mroz
5c693c5d23 Popover polish 2026-01-05 21:27:59 -08:00
Austin Mroz
8ffcf5a143 Widget border cleanup 2026-01-05 21:03:13 -08:00
Austin Mroz
41ea0387d7 Sizing cleanup 2026-01-05 20:34:30 -08:00
Austin Mroz
fc07cd15b0 Fix SplitterPanel outline. Unintended scrolling 2026-01-05 17:09:45 -08:00
Austin Mroz
1166e2a0e6 On output click, only select. Don't load 2026-01-05 16:14:20 -08:00
Austin Mroz
6fdfc72019 Make cancel button square 2026-01-05 16:12:01 -08:00
Austin Mroz
8a8325353b Remove ghost div on controless number widgets 2026-01-05 15:57:42 -08:00
Austin Mroz
f8b6981fec Minor comment cleanup 2026-01-05 15:56:06 -08:00
Austin Mroz
5a05a6b9f5 Implement cancel button interrupting execution
Was just placeholder
2026-01-05 15:51:18 -08:00
Austin Mroz
522ad67ced Fix dropzone border 2026-01-05 15:50:12 -08:00
Austin Mroz
5cf57ff8e8 Add click handler to DropZone 2026-01-05 13:41:49 -08:00
Austin Mroz
24500415cf Fix swapping from linear to normal workflow
There was an attempted fitView with 0 visible area
Not super happy with location of fix
- FitView shouldn't cause total loss of functionality if called from
  invalid state
- As previously discussed, this fitview call should probably be moved to
  only apply on newly loaded workflow, not restored workflows
2026-01-05 11:40:45 -08:00
Austin Mroz
7cf8e89f44 Filter linear outputs to current workflow 2026-01-05 10:42:51 -08:00
Austin Mroz
1d611e857c Add resolution indicator for video previews
Attempting to future proof things a little here.
I'm conflicted on current implementation vs slot based implementation
(which also requires a siblingElement lookup) vs dedicated components
for each type media being previewed
2026-01-05 09:34:43 -08:00
Austin Mroz
44bd2469ef Fix bad enum 2025-12-31 17:59:50 -08:00
Austin Mroz
a780efb5ff Add linear indicator to workflow tab 2025-12-31 17:56:14 -08:00
Austin Mroz
e652944586 Sidebar cleanup, toggle as feature flag 2025-12-31 17:47:29 -08:00
Austin Mroz
dbd1c30c98 3d support 2025-12-31 13:13:58 -08:00
Austin Mroz
0addee8fb1 Improve mediaType handling 2025-12-31 12:20:44 -08:00
Austin Mroz
eee096120a Preview dimensions for images
Need to put some further thought into handling other types

Adding a global ref here is bad.
Creating stub components for each preview type is bad
Each preview type needs different handling for calculation of it's
dimensions/type
2025-12-31 11:41:07 -08:00
Austin Mroz
38479c4a40 Persist enabling of linear mode to workflow 2025-12-31 11:17:05 -08:00
Austin Mroz
8f83468eaa Round linear toggle, fix scoll reset padding 2025-12-31 11:15:47 -08:00
Austin Mroz
d1dd276335 Allow center image to expand beyond initial size 2025-12-31 11:02:56 -08:00
Austin Mroz
9202b80ee2 Inset border, default dropIndicator for LoadImage 2025-12-30 22:04:37 -08:00
Austin Mroz
1151350ee4 Drag/drop support for linear mode
Causes some border woes which need fixing
2025-12-30 21:09:05 -08:00
Austin Mroz
09021b605c Disable display of markdown/note nodes 2025-12-30 19:51:47 -08:00
Austin Mroz
3cbdc30517 Allow navigating history with up/down keys 2025-12-30 19:48:13 -08:00
Austin Mroz
cdb422fefc Placeholder support for text output
Since text outputs are not currently exposed in history, there's not way
to test this and the label doesn't actually exist
2025-12-30 19:14:44 -08:00
Austin Mroz
b145566a7f Placeholder icons for non-image outputs 2025-12-30 18:30:54 -08:00
Austin Mroz
76c722c656 Support right sidebar 2025-12-30 17:02:57 -08:00
Austin Mroz
aa6a0580ec Analog scrolling for touchpad 2025-12-30 16:38:48 -08:00
Austin Mroz
a9e5989a3a Add Popover component,with extra options
Extra options are implemented through commands that seem to not
function. Further investigation is needed
2025-12-30 15:30:29 -08:00
Austin Mroz
c7d1869e41 Preview and rerun cleanup 2025-12-30 10:09:49 -08:00
Austin Mroz
df974e56a2 Implement reuse and rerun buttons 2025-12-29 14:34:57 -08:00
Austin Mroz
79bd8a4dd2 Update histroy for new outputs, spinner for active
A spinner is now displayed when  a job is actively executing
- Spinner has slightly incorrect sizing
- Only one spinner is displayed regardless of output count

History tracking change makes me very unhappy. I want to clean this up
to correctly leverage vue reactivity in the future, but required
functionality comes first
2025-12-29 11:16:58 -08:00
Austin Mroz
5e740c6efe Re-enable previews in naive way 2025-12-29 10:14:38 -08:00
Austin Mroz
0beebd0302 Display audio outputs, div cleanup 2025-12-29 09:52:33 -08:00
Austin Mroz
63ad1b289b Reset zoom on preview change 2025-12-27 19:09:23 -08:00
Austin Mroz
7051b5e491 Center zoom on mouse 2025-12-27 18:35:29 -08:00
Austin Mroz
a7a7ea348f Minor fixes: video size, no job toast on enter 2025-12-23 20:30:24 -08:00
Austin Mroz
a96e0dec0c Initial pan/zoom implementation 2025-12-23 17:52:18 -08:00
Austin Mroz
1f17f5f1b5 Popover cleanup - a working queue indicator 2025-12-23 15:49:04 -08:00
Austin Mroz
49ede05221 Skeleton queue button changes 2025-12-23 15:49:04 -08:00
Austin Mroz
d5d995de80 Initial main panel video support 2025-12-23 15:49:04 -08:00
Austin Mroz
75cc8b8b59 Toggle button cleanup
Experimented with ToggleGroups, but it was too much effort and YAGNI

Add icon and descriptions for other media types
2025-12-23 15:49:04 -08:00
Austin Mroz
b77f028097 Prune unused export 2025-12-23 15:49:04 -08:00
Austin Mroz
4cb0eb6fec Setup infinite scroll for outputs 2025-12-23 15:49:04 -08:00
Austin Mroz
d1b4ec44df Fix node reactivity in linear mode 2025-12-23 15:49:04 -08:00
Austin Mroz
1c007d650d Add sidebar
Conflicted on this one. Will likely argue for inlining history
2025-12-23 15:49:04 -08:00
Austin Mroz
24523a5397 Ugly growth hack 2025-12-23 15:49:04 -08:00
Austin Mroz
7ef03de7fb Add icon for image mediatype 2025-12-23 15:49:04 -08:00
Austin Mroz
842043999c Scoll into view for history panel
Functional, but I don't like the implementation specifics,
will return to later
2025-12-23 15:49:04 -08:00
Austin Mroz
461ee6c6d6 Side panel cleanup 2025-12-23 15:49:04 -08:00
Austin Mroz
302990ca9b Buttons, new and old 2025-12-23 15:49:03 -08:00
Austin Mroz
a35b87354f Add generation info display 2025-12-23 15:49:03 -08:00
Austin Mroz
735a60e6d7 Allow resizing history 2025-12-23 15:49:03 -08:00
Austin Mroz
5455c85a29 Initial scorll implementation 2025-12-23 15:49:03 -08:00
Austin Mroz
323649557f WIP sidebar update 2025-12-23 15:49:03 -08:00
26 changed files with 1243 additions and 210 deletions

View File

@@ -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);

View 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>

View File

@@ -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'

View File

@@ -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)
}

View File

@@ -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>

View 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>

View 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>

View File

@@ -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()

View File

@@ -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
}
}
]

View File

@@ -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)
)
}
})

View File

@@ -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": {

View File

@@ -39,4 +39,5 @@ export type RemoteConfig = {
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
linear_toggle_enabled?: boolean
}

View File

@@ -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)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}
})

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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'
}