mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 07:14:11 +00:00
linear v2: Simple Mode (#7734)
A major, full rewrite of linear mode, now under the name "Simple Mode". - Fixes widget styling - Adds a new simplified history - Adds support for non-image outputs - Supports right sidebar - Allows and panning on the output image preview - Provides support for drag and drop zones - Moves workflow notes into a popover. - Allows scrolling through outputs with Ctrl+scroll or arrow keys The primary means of accessing Simple Mode is a toggle button on the bottom right. This button is only shown if a feature flag is enabled, or the user has already seen linear mode during the current session. Simple Mode can also be accessed by - Using the toggle linear mode keybind - Loading a workflow that that was saved in Simple Mode workflow - Loading a template url with appropriate parameter <img width="1790" height="1387" alt="image" src="https://github.com/user-attachments/assets/d86a4a41-dfbf-41e7-a6d9-146473005606" /> Known issues: - Outputs on cloud are not filtered to those produced by the current workflow. - Output filtering has been globally disabled for consistency - Outputs will load more items on scroll, but does not unload - Performance may be reduced on weak devices with very large histories. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7734-linear-v2-2d16d73d3650819b8a10f150ff12ea22) by [Unito](https://www.unito.io)
This commit is contained in:
52
src/renderer/extensions/linearMode/DropZone.vue
Normal file
52
src/renderer/extensions/linearMode/DropZone.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
onDragOver?: (e: DragEvent) => boolean
|
||||
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
|
||||
dropIndicator?: {
|
||||
label?: string
|
||||
iconClass?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
}
|
||||
}>()
|
||||
|
||||
const canAcceptDrop = ref(false)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="onDragOver && onDragDrop"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg ring-inset ring-primary-500',
|
||||
canAcceptDrop && 'ring-4 bg-primary-500/10'
|
||||
)
|
||||
"
|
||||
@dragover.prevent="canAcceptDrop = onDragOver?.($event)"
|
||||
@dragleave="canAcceptDrop = false"
|
||||
@drop.stop.prevent="
|
||||
(e: DragEvent) => {
|
||||
onDragDrop!(e)
|
||||
canAcceptDrop = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="dropIndicator"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center justify-center gap-2 border-dashed rounded-lg border h-25 border-border-subtle m-3 py-2',
|
||||
dropIndicator?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@click.prevent="dropIndicator?.onClick?.($event)"
|
||||
>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
44
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
44
src/renderer/extensions/linearMode/ImagePreview.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane v-if="!mobile" v-slot="slotProps" class="flex-1 w-full">
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src
|
||||
v-bind="slotProps"
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
</ZoomPane>
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="w-full"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
if (!imageRef) return
|
||||
width = `${imageRef.naturalWidth}`
|
||||
height = `${imageRef.naturalHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||
</template>
|
||||
284
src/renderer/extensions/linearMode/LinearControls.vue
Normal file
284
src/renderer/extensions/linearMode/LinearControls.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||
import { partition } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const props = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
notesTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const jobFinishedQueue = ref(true)
|
||||
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
5000,
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator =
|
||||
node.type !== 'LoadImage'
|
||||
? undefined
|
||||
: {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
label: t('linearMode.dragAndDropImage')
|
||||
}
|
||||
const nodeData = extractVueNodeData(node)
|
||||
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
const partitionedNodes = computed(() => {
|
||||
return partition(
|
||||
graphNodes.value
|
||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||
.map(nodeToNodeData)
|
||||
.reverse(),
|
||||
(node) => ['MarkdownNote', 'Note'].includes(node.type)
|
||||
)
|
||||
})
|
||||
|
||||
const batchCountWidget: SimplifiedWidget<number> = {
|
||||
options: { precision: 0, min: 1, max: isCloud ? 4 : 99 },
|
||||
value: 1,
|
||||
name: t('linearMode.runCount'),
|
||||
type: 'number'
|
||||
} as const
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
async function runButtonClick(e: Event) {
|
||||
if (!jobFinishedQueue.value) return
|
||||
try {
|
||||
jobFinishedQueue.value = false
|
||||
resetJobToastTimeout()
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_linear'
|
||||
})
|
||||
if (batchCount.value > 1) {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'queue_run_multiple_batches_submitted'
|
||||
})
|
||||
}
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: {
|
||||
subscribe_to_run: false,
|
||||
trigger_source: 'button'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
//TODO: Error state indicator for failed queue?
|
||||
jobFinishedQueue.value = true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ runButtonClick })
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col min-w-80 md:h-full">
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
class="w-10 p-2"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
data-testid="linear-workflow-info"
|
||||
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
|
||||
>
|
||||
<span
|
||||
class="font-bold truncate"
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<Popover
|
||||
v-if="partitionedNodes[0].length"
|
||||
align="start"
|
||||
class="overflow-y-auto overflow-x-clip max-h-(--reka-popover-content-available-height)"
|
||||
:reference="notesTo"
|
||||
side="left"
|
||||
:to="notesTo"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--info]" />
|
||||
</Button>
|
||||
</template>
|
||||
<div>
|
||||
<template
|
||||
v-for="(nodeData, index) in partitionedNodes[0]"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="w-full border-t border-border-subtle"
|
||||
/>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
</section>
|
||||
<div
|
||||
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
|
||||
>
|
||||
<section
|
||||
data-testid="linear-widgets"
|
||||
class="grow-1 md:overflow-y-auto md:contain-size"
|
||||
>
|
||||
<template
|
||||
v-for="(nodeData, index) of partitionedNodes[1]"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="w-full border-t-1 border-node-component-border"
|
||||
/>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
class="py-3 gap-y-4 **:[.col-span-2]:grid-cols-1 text-sm **:[.p-floatlabel]:h-35 rounded-lg"
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
/>
|
||||
</DropZone>
|
||||
</template>
|
||||
</section>
|
||||
<section
|
||||
v-if="!mobile"
|
||||
data-testid="linear-run-button"
|
||||
class="p-4 pb-6 border-t border-node-component-border"
|
||||
>
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="w-full mt-4"
|
||||
/>
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
class="grow-1"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
class="w-10 p-2"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
|
||||
defer
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<i
|
||||
v-if="jobFinishedQueue"
|
||||
class="icon-[lucide--check] size-5 bg-success-background"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span v-text="t('queue.jobAddedToQueue')" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport v-if="false" defer :to="notesTo">
|
||||
<div
|
||||
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
|
||||
></div>
|
||||
</Teleport>
|
||||
</template>
|
||||
184
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
184
src/renderer/extensions/linearMode/LinearPreview.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { d, t } from '@/i18n'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration } from '@/utils/dateTimeUtil'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
const mediaActions = useMediaAssetActions()
|
||||
|
||||
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
|
||||
latentPreview?: string
|
||||
runButtonClick?: (e: Event) => void
|
||||
selectedItem?: AssetItem
|
||||
selectedOutput?: ResultItemImpl
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const dateOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
} as const
|
||||
const timeOptions = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
} as const
|
||||
|
||||
function formatTime(time: string) {
|
||||
if (!time) return ''
|
||||
const date = new Date(time)
|
||||
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
|
||||
}
|
||||
|
||||
const itemStats = computed<StatItem[]>(() => {
|
||||
if (!selectedItem) return []
|
||||
const user_metadata = getOutputAssetMetadata(selectedItem.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
const { allOutputs } = user_metadata
|
||||
return [
|
||||
{ content: formatTime(selectedItem.created_at) },
|
||||
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
|
||||
allOutputs && { content: t('g.asset', allOutputs.length) },
|
||||
(selectedOutput && mediaTypes[getMediaType(selectedOutput)]) ?? {}
|
||||
].filter((i) => !!i)
|
||||
})
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
for (const output of user_metadata?.allOutputs ?? [])
|
||||
downloadFile(output.url, output.filename)
|
||||
}
|
||||
|
||||
async function loadWorkflow(item: AssetItem | undefined) {
|
||||
if (!item) return
|
||||
const { workflow } = await extractWorkflowFromAsset(item)
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
|
||||
//update graph to new version, set old to top of undo queue
|
||||
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
if (!changeTracker) return app.loadGraphData(workflow)
|
||||
changeTracker.redoQueue = []
|
||||
changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
}
|
||||
|
||||
async function rerun(e: Event) {
|
||||
if (!runButtonClick) return
|
||||
await loadWorkflow(selectedItem)
|
||||
//FIXME don't use timeouts here
|
||||
//Currently seeds fail to properly update even with timeouts?
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
|
||||
|
||||
runButtonClick(e)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem"
|
||||
data-testid="linear-output-info"
|
||||
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
|
||||
>
|
||||
<div class="flex gap-3 text-nowrap">
|
||||
<div
|
||||
v-for="({ content, iconClass }, index) in itemStats"
|
||||
:key="index"
|
||||
class="flex items-center justify-items-center gap-1 tabular-nums"
|
||||
>
|
||||
<i v-if="iconClass" :class="iconClass" />
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-self-end">
|
||||
<Button size="md" @click="rerun">
|
||||
{{ t('linearMode.rerun') }}
|
||||
<i class="icon-[lucide--refresh-cw]" />
|
||||
</Button>
|
||||
<Button size="md" @click="() => loadWorkflow(selectedItem)">
|
||||
{{ t('linearMode.reuseParameters') }}
|
||||
<i class="icon-[lucide--list-restart]" />
|
||||
</Button>
|
||||
<div class="border-r border-border-subtle mx-1" />
|
||||
<Button
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Popover
|
||||
:entries="[
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
action: () => downloadAsset(selectedItem!)
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
action: () => mediaActions.confirmDelete(selectedItem!)
|
||||
}
|
||||
]
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="latentPreview ?? getMediaType(selectedOutput) === 'images'"
|
||||
:mobile
|
||||
:src="latentPreview ?? selectedOutput!.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||
:src="selectedOutput!.url"
|
||||
class="object-contain flex-1 contain-size"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(selectedOutput) === 'audio'"
|
||||
class="w-full m-auto"
|
||||
controls
|
||||
:src="selectedOutput!.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(selectedOutput) === 'text'"
|
||||
class="w-full max-w-128 m-auto my-12 overflow-y-auto"
|
||||
v-text="selectedOutput!.url"
|
||||
/>
|
||||
<Load3dViewerContent
|
||||
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
/>
|
||||
</template>
|
||||
309
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
309
src/renderer/extensions/linearMode/OutputHistory.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
|
||||
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
|
||||
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const displayWorkflows = ref(false)
|
||||
const outputs = useMediaAssets('output')
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowTab = useWorkspaceStore()
|
||||
.getSidebarTabs()
|
||||
.find((w) => w.id === 'workflows')
|
||||
|
||||
void outputs.fetchMediaList()
|
||||
|
||||
defineProps<{
|
||||
scrollResetButtonTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [
|
||||
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
||||
]
|
||||
}>()
|
||||
|
||||
defineExpose({ onWheel })
|
||||
|
||||
const selectedIndex = ref<[number, number]>([-1, 0])
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index] = selectedIndex.value
|
||||
emit('updateSelection', [
|
||||
outputs.media.value[index],
|
||||
selectedOutput.value,
|
||||
selectedIndex.value[0] <= 0
|
||||
])
|
||||
})
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
const { reset: resetInfiniteScroll } = useInfiniteScroll(
|
||||
outputsRef,
|
||||
outputs.loadMore,
|
||||
{ canLoadMore: () => outputs.hasMore.value }
|
||||
)
|
||||
function resetOutputsScroll() {
|
||||
//TODO need to also prune outputs entries?
|
||||
resetInfiniteScroll()
|
||||
outputsRef.value?.scrollTo(0, 0)
|
||||
}
|
||||
const { y: outputScrollState } = useScroll(outputsRef)
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (!outputsRef.value) return
|
||||
|
||||
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
|
||||
if (!outputElement) return
|
||||
|
||||
//container: 'nearest' is nice, but bleeding edge and chrome only
|
||||
outputElement.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function allOutputs(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
if (!user_metadata?.allOutputs) return []
|
||||
|
||||
return user_metadata.allOutputs
|
||||
}
|
||||
|
||||
const selectedOutput = computed(() => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0) return undefined
|
||||
|
||||
const output = allOutputs(outputs.media.value[index])[key]
|
||||
if (output) return output
|
||||
|
||||
return allOutputs(outputs.media.value[0])[0]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
(newAssets, oldAssets) => {
|
||||
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
|
||||
if (selectedIndex.value[0] <= 0) {
|
||||
//force update
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
}
|
||||
|
||||
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
|
||||
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
|
||||
|
||||
if (newIndex === -1) selectedIndex.value = [0, 0]
|
||||
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
|
||||
}
|
||||
)
|
||||
|
||||
function gotoNextOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0 || key < 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
}
|
||||
const currentItem = outputs.media.value[index]
|
||||
if (allOutputs(currentItem)[key + 1]) {
|
||||
selectedIndex.value = [index, key + 1]
|
||||
return
|
||||
}
|
||||
if (outputs.media.value[index + 1]) {
|
||||
selectedIndex.value = [index + 1, 0]
|
||||
}
|
||||
//do nothing, no next output
|
||||
}
|
||||
|
||||
function gotoPreviousOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (key > 0) {
|
||||
selectedIndex.value = [index, key - 1]
|
||||
return
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const currentItem = outputs.media.value[index - 1]
|
||||
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
|
||||
return
|
||||
}
|
||||
|
||||
selectedIndex.value = [0, 0]
|
||||
}
|
||||
|
||||
let pointer = new CanvasPointer(document.body)
|
||||
let scrollOffset = 0
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (!pointer.isTrackpadGesture(e)) {
|
||||
if (e.deltaY > 0) gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
return
|
||||
}
|
||||
scrollOffset += e.deltaY
|
||||
while (scrollOffset >= 60) {
|
||||
scrollOffset -= 60
|
||||
gotoNextOutput()
|
||||
}
|
||||
while (scrollOffset <= -60) {
|
||||
scrollOffset += 60
|
||||
gotoPreviousOutput()
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement
|
||||
)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'ArrowDown') gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'right'
|
||||
? 'flex-row-reverse border-l'
|
||||
: 'md:border-r'
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
|
||||
>
|
||||
<template v-if="workflowTab">
|
||||
<SidebarIcon
|
||||
:icon="workflowTab.icon"
|
||||
:icon-badge="workflowTab.iconBadge"
|
||||
:tooltip="workflowTab.tooltip"
|
||||
:label="workflowTab.label || workflowTab.title"
|
||||
:class="workflowTab.id + '-tab-button'"
|
||||
:selected="displayWorkflows"
|
||||
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
|
||||
@click="displayWorkflows = !displayWorkflows"
|
||||
/>
|
||||
</template>
|
||||
<SidebarTemplatesButton />
|
||||
<div class="flex-1" />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div class="border-border-subtle md:border-r" />
|
||||
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50" />
|
||||
<article
|
||||
v-else
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
|
||||
>
|
||||
<section
|
||||
v-if="
|
||||
queueStore.runningTasks.length > 0 ||
|
||||
queueStore.pendingTasks.length > 0
|
||||
"
|
||||
data-testid="linear-job"
|
||||
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
|
||||
>
|
||||
<i
|
||||
v-if="queueStore.runningTasks.length > 0"
|
||||
class="icon-[lucide--loader-circle] size-full animate-spin"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
||||
<div
|
||||
v-if="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
||||
"
|
||||
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
||||
v-text="
|
||||
queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-for="(item, index) in outputs.media.value"
|
||||
:key="index"
|
||||
data-testid="linear-job"
|
||||
class="py-3 not-md:h-24 border-border-subtle flex md:flex-col md:w-full px-1 first:border-t-0 first:border-l-0 md:border-t-2 not-md:border-l-2"
|
||||
>
|
||||
<template v-for="(output, key) in allOutputs(item)" :key>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square object-cover',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:src="output.url"
|
||||
@click="selectedIndex = [index, key]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square w-full',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="outputScrollState && scrollResetButtonTo"
|
||||
:to="scrollResetButtonTo"
|
||||
>
|
||||
<Button
|
||||
:class="
|
||||
cn(
|
||||
'p-3 size-10 bg-base-foreground',
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? 'left-4'
|
||||
: 'right-4'
|
||||
)
|
||||
"
|
||||
@click="resetOutputsScroll"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
|
||||
</Button>
|
||||
</Teleport>
|
||||
</template>
|
||||
27
src/renderer/extensions/linearMode/VideoPreview.vue
Normal file
27
src/renderer/extensions/linearMode/VideoPreview.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const videoRef = useTemplateRef('videoRef')
|
||||
const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src
|
||||
controls
|
||||
v-bind="$attrs"
|
||||
@loadedmetadata="
|
||||
() => {
|
||||
if (!videoRef) return
|
||||
width = `${videoRef.videoWidth}`
|
||||
height = `${videoRef.videoHeight}`
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center z-10" v-text="`${width} x ${height}`" />
|
||||
</template>
|
||||
33
src/renderer/extensions/linearMode/mediaTypes.ts
Normal file
33
src/renderer/extensions/linearMode/mediaTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export type StatItem = { content?: string; iconClass?: string }
|
||||
export const mediaTypes: Record<string, StatItem> = {
|
||||
'3d': {
|
||||
content: t('sideToolbar.mediaAssets.filter3D'),
|
||||
iconClass: 'icon-[lucide--box]'
|
||||
},
|
||||
audio: {
|
||||
content: t('sideToolbar.mediaAssets.filterAudio'),
|
||||
iconClass: 'icon-[lucide--audio-lines]'
|
||||
},
|
||||
images: {
|
||||
content: t('sideToolbar.mediaAssets.filterImage'),
|
||||
iconClass: 'icon-[lucide--image]'
|
||||
},
|
||||
text: {
|
||||
content: t('sideToolbar.mediaAssets.filterText'),
|
||||
iconClass: 'icon-[lucide--text]'
|
||||
},
|
||||
video: {
|
||||
content: t('sideToolbar.mediaAssets.filterVideo'),
|
||||
iconClass: 'icon-[lucide--video]'
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaType(output?: ResultItemImpl) {
|
||||
if (!output) return ''
|
||||
if (output.isVideo) return 'video'
|
||||
return output.mediaType
|
||||
}
|
||||
Reference in New Issue
Block a user