mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 17:52:16 +00:00
App Mode - Progress/outputs - 5 (#9028)
## Summary Adds new store for tracking outputs lin linear mode and reworks outputs to display the following: skeleton -> latent preview -> node output -> job result. ## Changes - **What**: - New store for wrangling various events into a usable list of live outputs - Replace manual list with reka-ui list box - Extract various composables ## Review Focus Getting all the events and stores aligned to happen consistently and in the correct order was a challenge, unifying the various sources. so suggestions there would be good ## Screenshots (if applicable) https://github.com/user-attachments/assets/13449780-ee48-4d9a-b3aa-51dca0a124c7 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9028-App-Mode-Progress-outputs-5-30d6d73d3650817aaa9dee622fffe426) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
7
src/renderer/extensions/linearMode/LatentPreview.vue
Normal file
7
src/renderer/extensions/linearMode/LatentPreview.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="flex-1 min-h-0 w-full flex items-center justify-center">
|
||||
<div
|
||||
class="aspect-square size-[min(50vw,50vh)] rounded-lg skeleton-shimmer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -158,7 +158,8 @@ defineExpose({ runButtonClick })
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
root-class="text-base-foreground grid-cols-[auto_96px]"
|
||||
class="*:[.min-w-0]:w-24"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
|
||||
<div v-else class="flex mt-4 gap-2">
|
||||
@@ -265,32 +266,23 @@ defineExpose({ runButtonClick })
|
||||
<WidgetInputNumberInput
|
||||
v-model="batchCount"
|
||||
:widget="batchCountWidget"
|
||||
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
|
||||
root-class="text-base-foreground grid-cols-[auto_96px]"
|
||||
class="*:[.min-w-0]:w-24"
|
||||
/>
|
||||
<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>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="w-full mt-4"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -12,27 +11,28 @@ 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 LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.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 { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration } from '@/utils/dateTimeUtil'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
const { t, d } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const queueStore = useQueueStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { runButtonClick } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
@@ -43,43 +43,14 @@ const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
whenever(
|
||||
() => nodeOutputStore.latestPreview[0],
|
||||
() => (latentPreview.value = nodeOutputStore.latestPreview[0])
|
||||
)
|
||||
|
||||
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 handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
canShowPreview.value = sel.canShowPreview
|
||||
latentPreview.value = sel.latentPreviewUrl
|
||||
}
|
||||
|
||||
const itemStats = computed<StatItem[]>(() => {
|
||||
if (!selectedItem.value) return []
|
||||
const user_metadata = getOutputAssetMetadata(selectedItem.value.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
const { allOutputs } = user_metadata
|
||||
return [
|
||||
{ content: formatTime(selectedItem.value.created_at) },
|
||||
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
|
||||
allOutputs && { content: t('g.asset', allOutputs.length) },
|
||||
(selectedOutput.value && mediaTypes[getMediaType(selectedOutput.value)]) ??
|
||||
{}
|
||||
].filter((i) => !!i)
|
||||
})
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
for (const output of user_metadata?.allOutputs ?? [])
|
||||
@@ -112,21 +83,11 @@ async function rerun(e: Event) {
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem"
|
||||
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
|
||||
data-testid="linear-output-info"
|
||||
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
|
||||
class="flex flex-wrap gap-2 p-4 w-full md:z-10 tabular-nums justify-center 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">
|
||||
<template v-if="selectedItem">
|
||||
<Button size="md" @click="rerun">
|
||||
{{ t('linearMode.rerun') }}
|
||||
<i class="icon-[lucide--refresh-cw]" />
|
||||
@@ -136,32 +97,44 @@ async function rerun(e: Event) {
|
||||
<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'),
|
||||
command: () => downloadAsset(selectedItem!)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
command: () => mediaActions.deleteAssets(selectedItem!)
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
v-if="selectedOutput"
|
||||
size="icon"
|
||||
:aria-label="t('g.download')"
|
||||
@click="
|
||||
() => {
|
||||
if (selectedOutput?.url) downloadFile(selectedOutput.url)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle && !selectedItem"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="selectedItem"
|
||||
:entries="[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
command: () => downloadAsset(selectedItem!)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
command: () => mediaActions.deleteAssets(selectedItem!)
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="
|
||||
@@ -191,14 +164,8 @@ async function rerun(e: Event) {
|
||||
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
|
||||
<LinearWelcome v-else />
|
||||
<OutputHistory
|
||||
@update-selection="
|
||||
(event) => {
|
||||
;[selectedItem, selectedOutput, canShowPreview] = event
|
||||
latentPreview = undefined
|
||||
}
|
||||
"
|
||||
/>
|
||||
<OutputHistory @update-selection="handleSelection" />
|
||||
</template>
|
||||
|
||||
40
src/renderer/extensions/linearMode/LinearProgressBar.vue
Normal file
40
src/renderer/extensions/linearMode/LinearProgressBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const {
|
||||
class: className,
|
||||
overallOpacity = 1,
|
||||
activeOpacity = 1
|
||||
} = defineProps<{
|
||||
class?: string
|
||||
overallOpacity?: number
|
||||
activeOpacity?: number
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const queueStore = useQueueStore()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 bg-secondary-background transition-opacity',
|
||||
queueStore.runningTasks.length === 0 && 'opacity-0',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
|
||||
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
|
||||
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,130 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useInfiniteScroll } from '@vueuse/core'
|
||||
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
|
||||
import {
|
||||
useAsyncState,
|
||||
useEventListener,
|
||||
useInfiniteScroll
|
||||
} from '@vueuse/core'
|
||||
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
computed,
|
||||
nextTick,
|
||||
toValue,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
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 {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
|
||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type {
|
||||
OutputSelection,
|
||||
SelectionValue
|
||||
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const outputs = useMediaAssets('output')
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const { outputs, allOutputs } = useOutputHistory()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
void outputs.fetchMediaList()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [
|
||||
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
||||
]
|
||||
updateSelection: [selection: OutputSelection]
|
||||
}>()
|
||||
|
||||
const selectedIndex = ref<[number, number]>([-1, 0])
|
||||
const queueCount = computed(
|
||||
() => queueStore.runningTasks.length + queueStore.pendingTasks.length
|
||||
)
|
||||
|
||||
const listboxRef = useTemplateRef<{
|
||||
highlightItem: (value: SelectionValue) => void
|
||||
}>('listboxRef')
|
||||
|
||||
const itemClass = cn(
|
||||
'shrink-0 cursor-pointer p-1 rounded-lg border-2 border-transparent outline-none',
|
||||
'data-[state=checked]:border-interface-panel-job-progress-border'
|
||||
)
|
||||
|
||||
const hasActiveContent = computed(() => store.inProgressItems.length > 0)
|
||||
|
||||
const visibleHistory = computed(() =>
|
||||
outputs.media.value.filter((a) => toValue(allOutputs(a)).length > 0)
|
||||
)
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
const items: SelectionValue[] = []
|
||||
for (const item of store.inProgressItems) {
|
||||
items.push({
|
||||
id: `slot:${item.id}`,
|
||||
kind: 'inProgress',
|
||||
itemId: item.id
|
||||
})
|
||||
}
|
||||
for (const asset of outputs.media.value) {
|
||||
const outs = toValue(allOutputs(asset))
|
||||
for (let k = 0; k < outs.length; k++) {
|
||||
items.push({
|
||||
id: `history:${asset.id}:${k}`,
|
||||
kind: 'history',
|
||||
assetId: asset.id,
|
||||
key: k
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const selectionMap = computed(
|
||||
() => new Map(selectableItems.value.map((v) => [v.id, v]))
|
||||
)
|
||||
|
||||
const selectedValue = computed(() => {
|
||||
if (!store.selectedId) return undefined
|
||||
return selectionMap.value.get(store.selectedId)
|
||||
})
|
||||
|
||||
function onSelectionChange(val: unknown) {
|
||||
const sv = val as SelectionValue | undefined
|
||||
store.select(sv?.id ?? null)
|
||||
}
|
||||
|
||||
function doEmit() {
|
||||
const [index] = selectedIndex.value
|
||||
emit('updateSelection', [
|
||||
outputs.media.value[index],
|
||||
selectedOutput.value,
|
||||
selectedIndex.value[0] <= 0
|
||||
])
|
||||
const sel = selectedValue.value
|
||||
if (!sel) {
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
return
|
||||
}
|
||||
if (sel.kind === 'inProgress') {
|
||||
const item = store.inProgressItems.find((i) => i.id === sel.itemId)
|
||||
if (!item || item.state === 'skeleton') {
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
} else if (item.state === 'latent') {
|
||||
emit('updateSelection', {
|
||||
canShowPreview: true,
|
||||
latentPreviewUrl: item.latentPreviewUrl
|
||||
})
|
||||
} else {
|
||||
emit('updateSelection', {
|
||||
output: item.output,
|
||||
canShowPreview: true
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
||||
const output = asset ? toValue(allOutputs(asset))[sel.key] : undefined
|
||||
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
||||
emit('updateSelection', {
|
||||
asset,
|
||||
output,
|
||||
canShowPreview: isFirst
|
||||
})
|
||||
}
|
||||
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
watchEffect(doEmit)
|
||||
|
||||
// Resolve in-progress items only when history outputs are loaded.
|
||||
// Using watchEffect so it re-runs when allOutputs refs resolve (async).
|
||||
watchEffect(() => {
|
||||
if (store.pendingResolve.size === 0) return
|
||||
for (const jobId of store.pendingResolve) {
|
||||
const asset = outputs.media.value.find((a) => {
|
||||
const m = getOutputAssetMetadata(a?.user_metadata)
|
||||
return m?.jobId === jobId
|
||||
})
|
||||
if (!asset) continue
|
||||
const loaded = toValue(allOutputs(asset)).length > 0
|
||||
if (loaded) {
|
||||
store.resolveIfReady(jobId, true)
|
||||
if (!store.selectedId) selectFirstHistory()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedIndex, () => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (!outputsRef.value) return
|
||||
|
||||
const outputElement = outputsRef.value?.querySelectorAll(
|
||||
`[data-output-index="${index}"]`
|
||||
)?.[key]
|
||||
if (!outputElement) return
|
||||
|
||||
//container: 'nearest' is nice, but bleeding edge and chrome only
|
||||
outputElement.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function outputCount(item?: AssetItem) {
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
return user_metadata?.outputCount ?? 0
|
||||
}
|
||||
|
||||
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
|
||||
|
||||
function flattenNodeOutput([nodeId, nodeOutput]: [
|
||||
string | number,
|
||||
NodeExecutionOutput
|
||||
]): ResultItemImpl[] {
|
||||
const knownOutputs: Record<string, ResultItem[]> = {}
|
||||
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
|
||||
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
|
||||
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
|
||||
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
|
||||
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
|
||||
|
||||
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
|
||||
outputs.map(
|
||||
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
|
||||
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
|
||||
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
if (
|
||||
user_metadata.allOutputs &&
|
||||
user_metadata.outputCount &&
|
||||
user_metadata.outputCount < user_metadata.allOutputs.length
|
||||
)
|
||||
return user_metadata.allOutputs
|
||||
|
||||
const outputRef = useAsyncState(
|
||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||
if (!jobDetail?.outputs) return []
|
||||
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
|
||||
}),
|
||||
[]
|
||||
).state
|
||||
outputsCache[item!.id] = outputRef
|
||||
return outputRef
|
||||
}
|
||||
|
||||
const selectedOutput = computed(() => {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0) return undefined
|
||||
|
||||
return toValue(allOutputs(outputs.media.value[index]))[key]
|
||||
})
|
||||
|
||||
watch([selectedIndex, selectedOutput], doEmit)
|
||||
// Keep history selection stable on media changes
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
(newAssets, oldAssets) => {
|
||||
@@ -133,52 +149,73 @@ watch(
|
||||
(oldAssets.length === 0 && newAssets.length !== 1)
|
||||
)
|
||||
return
|
||||
if (selectedIndex.value[0] <= 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
|
||||
if (store.selectedId?.startsWith('slot:')) return
|
||||
|
||||
const sv = store.selectedId
|
||||
? selectionMap.value.get(store.selectedId)
|
||||
: undefined
|
||||
|
||||
if (!sv || sv.kind !== 'history') {
|
||||
selectFirstHistory()
|
||||
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]]
|
||||
const wasFirst = sv.assetId === oldAssets[0]?.id
|
||||
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
|
||||
selectFirstHistory()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function gotoNextOutput() {
|
||||
const [index, key] = selectedIndex.value
|
||||
if (index < 0 || key < 0) {
|
||||
selectedIndex.value = [0, 0]
|
||||
return
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
if (first) {
|
||||
store.selectAsLatest(`history:${first.id}:0`)
|
||||
} else {
|
||||
store.selectAsLatest(null)
|
||||
}
|
||||
if (key + 1 < outputCount(outputs.media.value[index])) {
|
||||
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
|
||||
}
|
||||
const outputsRef = useTemplateRef('outputsRef')
|
||||
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
||||
canLoadMore: () => outputs.hasMore.value
|
||||
})
|
||||
|
||||
if (index > 0) {
|
||||
const len = outputCount(outputs.media.value[index - 1])
|
||||
selectedIndex.value = [index - 1, len - 1]
|
||||
return
|
||||
}
|
||||
|
||||
selectedIndex.value = [0, 0]
|
||||
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
|
||||
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
|
||||
// the global keybinding handler on window. Intercept in capture phase
|
||||
// and re-dispatch from above the Listbox.
|
||||
function onModifierEnter(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey || e.shiftKey)) return
|
||||
e.stopImmediatePropagation()
|
||||
outputsRef.value?.parentElement?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
shiftKey: e.shiftKey,
|
||||
altKey: e.altKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let pointer = new CanvasPointer(document.body)
|
||||
function navigateToAdjacent(direction: 1 | -1) {
|
||||
const items = selectableItems.value
|
||||
if (items.length === 0) return
|
||||
const currentId = store.selectedId
|
||||
const idx = currentId ? items.findIndex((i) => i.id === currentId) : -1
|
||||
const nextIdx =
|
||||
idx === -1 ? 0 : Math.max(0, Math.min(items.length - 1, idx + direction))
|
||||
const next = items[nextIdx]
|
||||
store.select(next.id)
|
||||
nextTick(() => listboxRef.value?.highlightItem(next))
|
||||
}
|
||||
|
||||
const pointer = new CanvasPointer(document.body)
|
||||
let scrollOffset = 0
|
||||
useEventListener(
|
||||
document.body,
|
||||
@@ -189,23 +226,34 @@ useEventListener(
|
||||
e.stopPropagation()
|
||||
|
||||
if (!pointer.isTrackpadGesture(e)) {
|
||||
if (e.deltaY > 0) gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
if (e.deltaY > 0) navigateToAdjacent(1)
|
||||
else navigateToAdjacent(-1)
|
||||
return
|
||||
}
|
||||
scrollOffset += e.deltaY
|
||||
while (scrollOffset >= 60) {
|
||||
scrollOffset -= 60
|
||||
gotoNextOutput()
|
||||
navigateToAdjacent(1)
|
||||
}
|
||||
while (scrollOffset <= -60) {
|
||||
scrollOffset += 60
|
||||
gotoPreviousOutput()
|
||||
navigateToAdjacent(-1)
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: false }
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
outputsRef,
|
||||
'wheel',
|
||||
function (e: WheelEvent) {
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
e.preventDefault()
|
||||
if (outputsRef.value) outputsRef.value.scrollLeft += e.deltaY
|
||||
},
|
||||
{ passive: false }
|
||||
)
|
||||
|
||||
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
||||
@@ -216,85 +264,95 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.key === 'ArrowDown') gotoNextOutput()
|
||||
else gotoPreviousOutput()
|
||||
if (e.key === 'ArrowDown') navigateToAdjacent(1)
|
||||
else navigateToAdjacent(-1)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="h-24 p-3 overflow-x-auto overflow-y-clip border-node-component-border flex items-center contain-size md:mx-15"
|
||||
<ListboxRoot
|
||||
ref="listboxRef"
|
||||
:model-value="selectedValue"
|
||||
orientation="horizontal"
|
||||
selection-behavior="replace"
|
||||
by="id"
|
||||
class="min-w-0"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<section
|
||||
v-if="
|
||||
queueStore.runningTasks.length > 0 || queueStore.pendingTasks.length > 0
|
||||
"
|
||||
data-testid="linear-job"
|
||||
class="py-3 h-24 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"
|
||||
/>
|
||||
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
|
||||
<div :class="progressBarContainerClass">
|
||||
<ListboxContent as-child>
|
||||
<article
|
||||
ref="outputsRef"
|
||||
data-testid="linear-outputs"
|
||||
class="p-3 overflow-y-clip overflow-x-auto min-w-0"
|
||||
@keydown.capture="onModifierEnter"
|
||||
>
|
||||
<div class="flex items-center gap-0.5 mx-auto w-fit">
|
||||
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
|
||||
<div
|
||||
class="shrink-0 p-1 border-2 border-transparent relative"
|
||||
data-testid="linear-job"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="queueCount > 1"
|
||||
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
|
||||
v-text="queueCount"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasActiveContent || visibleHistory.length > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ListboxItem
|
||||
v-for="item in store.inProgressItems"
|
||||
:key="`${item.id}-${item.state}`"
|
||||
:value="{
|
||||
id: `slot:${item.id}`,
|
||||
kind: 'inProgress',
|
||||
itemId: item.id
|
||||
}"
|
||||
:class="itemClass"
|
||||
>
|
||||
<OutputPreviewItem
|
||||
v-if="item.state !== 'image' || !item.output"
|
||||
:latent-preview="item.latentPreviewUrl"
|
||||
/>
|
||||
<OutputHistoryItem v-else :output="item.output" />
|
||||
</ListboxItem>
|
||||
|
||||
<div
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(totalPercent)"
|
||||
/>
|
||||
<div
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(currentNodePercent)"
|
||||
v-if="hasActiveContent && visibleHistory.length > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
|
||||
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
||||
<div
|
||||
v-if="aIdx > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
/>
|
||||
<ListboxItem
|
||||
v-for="(output, key) in toValue(allOutputs(asset))"
|
||||
:key
|
||||
:value="{
|
||||
id: `history:${asset.id}:${key}`,
|
||||
kind: 'history',
|
||||
assetId: asset.id,
|
||||
key
|
||||
}"
|
||||
:class="itemClass"
|
||||
>
|
||||
<OutputHistoryItem :output="output" />
|
||||
</ListboxItem>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-for="(item, index) in outputs.media.value" :key="index">
|
||||
<div class="border-border-subtle border-l first:border-none h-21 m-3" />
|
||||
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="
|
||||
cn(
|
||||
'p-1 rounded-lg aspect-square object-cover h-20',
|
||||
index === selectedIndex[0] &&
|
||||
key === selectedIndex[1] &&
|
||||
'border-2'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
: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'
|
||||
)
|
||||
"
|
||||
:data-output-index="index"
|
||||
@click="selectedIndex = [index, key]"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</article>
|
||||
</article>
|
||||
</ListboxContent>
|
||||
</ListboxRoot>
|
||||
</template>
|
||||
|
||||
26
src/renderer/extensions/linearMode/OutputHistoryItem.vue
Normal file
26
src/renderer/extensions/linearMode/OutputHistoryItem.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getMediaType,
|
||||
mediaTypes
|
||||
} from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { output } = defineProps<{
|
||||
output: ResultItemImpl
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<img
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
class="block size-10 rounded-sm object-cover bg-secondary-background"
|
||||
loading="lazy"
|
||||
width="40"
|
||||
height="40"
|
||||
:src="output.url"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"
|
||||
/>
|
||||
</template>
|
||||
21
src/renderer/extensions/linearMode/OutputPreviewItem.vue
Normal file
21
src/renderer/extensions/linearMode/OutputPreviewItem.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
|
||||
const { latentPreview } = defineProps<{
|
||||
latentPreview?: string
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="relative size-10">
|
||||
<img
|
||||
v-if="latentPreview"
|
||||
class="block size-10 rounded-sm object-cover"
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<div v-else class="size-10 rounded-sm skeleton-shimmer" />
|
||||
<LinearProgressBar
|
||||
class="absolute inset-0 size-full bg-transparent"
|
||||
:overall-opacity="0.7"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
85
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
Normal file
85
src/renderer/extensions/linearMode/flattenNodeOutput.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
|
||||
|
||||
function makeOutput(
|
||||
overrides: Partial<NodeExecutionOutput> = {}
|
||||
): NodeExecutionOutput {
|
||||
return { ...overrides }
|
||||
}
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('flattens images into ResultItemImpl instances', () => {
|
||||
const output = makeOutput({
|
||||
images: [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
|
||||
]
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['42', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].filename).toBe('a.png')
|
||||
expect(result[0].nodeId).toBe('42')
|
||||
expect(result[0].mediaType).toBe('images')
|
||||
expect(result[1].filename).toBe('b.png')
|
||||
expect(result[1].subfolder).toBe('sub')
|
||||
})
|
||||
|
||||
it('flattens audio outputs', () => {
|
||||
const output = makeOutput({
|
||||
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput([7, output])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('audio')
|
||||
expect(result[0].nodeId).toBe(7)
|
||||
})
|
||||
|
||||
it('flattens multiple media types in a single output', () => {
|
||||
const output = makeOutput({
|
||||
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
|
||||
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
const types = result.map((r) => r.mediaType)
|
||||
expect(types).toContain('images')
|
||||
expect(types).toContain('video')
|
||||
})
|
||||
|
||||
it('handles gifs and 3d output types', () => {
|
||||
const output = makeOutput({
|
||||
gifs: [
|
||||
{ filename: 'anim.gif', subfolder: '', type: 'output' }
|
||||
] as NodeExecutionOutput['gifs'],
|
||||
'3d': [
|
||||
{ filename: 'model.glb', subfolder: '', type: 'output' }
|
||||
] as NodeExecutionOutput['3d']
|
||||
})
|
||||
|
||||
const result = flattenNodeOutput(['5', output])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
const types = result.map((r) => r.mediaType)
|
||||
expect(types).toContain('gifs')
|
||||
expect(types).toContain('3d')
|
||||
})
|
||||
|
||||
it('ignores empty arrays', () => {
|
||||
const output = makeOutput({ images: [], audio: [] })
|
||||
const result = flattenNodeOutput(['1', output])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
20
src/renderer/extensions/linearMode/flattenNodeOutput.ts
Normal file
20
src/renderer/extensions/linearMode/flattenNodeOutput.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function flattenNodeOutput([nodeId, nodeOutput]: [
|
||||
string | number,
|
||||
NodeExecutionOutput
|
||||
]): ResultItemImpl[] {
|
||||
const knownOutputs: Record<string, ResultItem[]> = {}
|
||||
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
|
||||
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
|
||||
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
|
||||
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
|
||||
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
|
||||
|
||||
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
|
||||
outputs.map(
|
||||
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
|
||||
)
|
||||
)
|
||||
}
|
||||
21
src/renderer/extensions/linearMode/linearModeTypes.ts
Normal file
21
src/renderer/extensions/linearMode/linearModeTypes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export interface InProgressItem {
|
||||
id: string
|
||||
jobId: string
|
||||
state: 'skeleton' | 'latent' | 'image'
|
||||
latentPreviewUrl?: string
|
||||
output?: ResultItemImpl
|
||||
}
|
||||
|
||||
export interface OutputSelection {
|
||||
asset?: AssetItem
|
||||
output?: ResultItemImpl
|
||||
canShowPreview: boolean
|
||||
latentPreviewUrl?: string
|
||||
}
|
||||
|
||||
export type SelectionValue =
|
||||
| { id: string; kind: 'inProgress'; itemId: string }
|
||||
| { id: string; kind: 'history'; assetId: string; key: number }
|
||||
534
src/renderer/extensions/linearMode/linearOutputStore.test.ts
Normal file
534
src/renderer/extensions/linearMode/linearOutputStore.test.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const activeJobIdRef = ref<string | null>(null)
|
||||
const previewsRef = ref<Record<string, string>>({})
|
||||
const isAppModeRef = ref(true)
|
||||
|
||||
const { apiTarget } = vi.hoisted(() => ({
|
||||
apiTarget: new EventTarget()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
get isAppMode() {
|
||||
return isAppModeRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
get activeJobId() {
|
||||
return activeJobIdRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/jobPreviewStore', () => ({
|
||||
useJobPreviewStore: () => ({
|
||||
get previewsByPromptId() {
|
||||
return previewsRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: Object.assign(apiTarget, {
|
||||
apiURL: (path: string) => path
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
|
||||
flattenNodeOutput: ([nodeId, output]: [
|
||||
string | number,
|
||||
Record<string, unknown>
|
||||
]) => {
|
||||
if (!output.images) return []
|
||||
return (output.images as Array<Record<string, string>>).map(
|
||||
(img) =>
|
||||
new ResultItemImpl({
|
||||
...img,
|
||||
nodeId: String(nodeId),
|
||||
mediaType: 'images'
|
||||
})
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
function makeExecutedDetail(
|
||||
promptId: string,
|
||||
images: Array<Record<string, string>> = [
|
||||
{ filename: 'out.png', subfolder: '', type: 'output' }
|
||||
]
|
||||
): ExecutedWsMessage {
|
||||
return {
|
||||
prompt_id: promptId,
|
||||
node: '1',
|
||||
display_node: '1',
|
||||
output: { images }
|
||||
} as ExecutedWsMessage
|
||||
}
|
||||
|
||||
describe('linearOutputStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
activeJobIdRef.value = null
|
||||
previewsRef.value = {}
|
||||
isAppModeRef.value = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
activeJobIdRef.value = null
|
||||
previewsRef.value = {}
|
||||
})
|
||||
|
||||
it('creates a skeleton item when a job starts', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].state).toBe('skeleton')
|
||||
expect(store.inProgressItems[0].jobId).toBe('job-1')
|
||||
})
|
||||
|
||||
it('auto-selects skeleton on first job start when no selection', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
|
||||
})
|
||||
|
||||
it('transitions to latent on preview', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
const itemId = store.inProgressItems[0].id
|
||||
store.onLatentPreview('job-1', 'blob:preview')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store.inProgressItems[0].state).toBe('latent')
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview')
|
||||
expect(store.selectedId).toBe(`slot:${itemId}`)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores latent preview for other jobs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onLatentPreview('job-other', 'blob:preview')
|
||||
|
||||
expect(store.inProgressItems[0].state).toBe('skeleton')
|
||||
})
|
||||
|
||||
it('transitions to image on executed event', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(imageItems).toHaveLength(1)
|
||||
expect(imageItems[0].output).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not create trailing skeleton after executed output', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].state).toBe('image')
|
||||
})
|
||||
|
||||
it('handles multi-output executed events', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'a.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
|
||||
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(imageItems).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removes slots when job ends without image outputs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
expect(store.inProgressItems).toHaveLength(1)
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
})
|
||||
|
||||
it('adds to pendingResolve when job completes with images', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('removes items when history resolves', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
|
||||
store.resolveIfReady('job-1', true)
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.pendingResolve.has('job-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not resolve if history has not arrived', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
store.resolveIfReady('job-1', false)
|
||||
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not auto-select when user is browsing history', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// User manually selects a history item (browsing)
|
||||
store.select('history:asset-2:0')
|
||||
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Should NOT yank to in-progress — user is browsing
|
||||
expect(store.selectedId).toBe('history:asset-2:0')
|
||||
|
||||
store.onLatentPreview('job-1', 'blob:preview')
|
||||
|
||||
// Still should NOT yank
|
||||
expect(store.selectedId).toBe('history:asset-2:0')
|
||||
})
|
||||
|
||||
it('auto-selects on new job when following latest', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// selectAsLatest simulates "following the latest output"
|
||||
store.selectAsLatest('history:asset-1:0')
|
||||
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Following latest → auto-select new skeleton
|
||||
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not auto-select on new job when browsing history', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// User manually browses to an older output
|
||||
store.select('history:asset-1:0')
|
||||
|
||||
store.onJobStart('job-2')
|
||||
|
||||
// Should NOT auto-select — user is browsing
|
||||
expect(store.selectedId).toBe('history:asset-1:0')
|
||||
})
|
||||
|
||||
it('falls back selection when selected item is removed', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
const firstId = `slot:${store.inProgressItems[0].id}`
|
||||
expect(store.selectedId).toBe(firstId)
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// Skeleton removed, no images, should clear selection
|
||||
expect(store.selectedId).toBeNull()
|
||||
})
|
||||
|
||||
it('creates skeleton on-demand when latent arrives after execute', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// No skeleton after execute
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
||||
).toHaveLength(0)
|
||||
|
||||
// Next node sends latent preview — skeleton created on demand
|
||||
store.onLatentPreview('job-1', 'blob:next')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store.inProgressItems[0].state).toBe('latent')
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:next')
|
||||
expect(store.inProgressItems).toHaveLength(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('handles execute without prior skeleton (no latent preview)', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// First node executes (consumes initial skeleton)
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
expect(store.inProgressItems).toHaveLength(1)
|
||||
|
||||
// Second node executes directly (no latent, no skeleton)
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
|
||||
const images = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(images).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not fall back selection to stale items from other jobs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job 1 starts but is never completed (simulates watcher bug)
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Job 2 starts directly
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
||||
store.onJobComplete('job-2')
|
||||
|
||||
// Resolve job-2
|
||||
store.resolveIfReady('job-2', true)
|
||||
|
||||
// Should clear to null for history takeover, NOT fall back to job-1
|
||||
expect(store.selectedId).toBeNull()
|
||||
})
|
||||
|
||||
it('transitions to latent via previewsByPromptId watcher', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].state).toBe('skeleton')
|
||||
|
||||
// Simulate jobPreviewStore update
|
||||
previewsRef.value = { 'job-1': 'blob:preview-1' }
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
expect(store.inProgressItems[0].state).toBe('latent')
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview-1')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('completes previous job on direct job transition', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Direct transition: job-1 → job-2 (no null in between)
|
||||
activeJobIdRef.value = 'job-2'
|
||||
await nextTick()
|
||||
|
||||
// job-1 should have been completed
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
// job-2 should have started
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('two sequential runs: selection clears after each resolve', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Run 1: 3 outputs
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'c.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
|
||||
const run1Images = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(run1Images).toHaveLength(3)
|
||||
|
||||
store.onJobComplete('job-1')
|
||||
store.resolveIfReady('job-1', true)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
|
||||
// Simulate OutputHistory selecting run 1's first output (following latest)
|
||||
store.selectAsLatest('history:asset-run1:0')
|
||||
|
||||
// Run 2: 3 outputs
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
||||
store.onNodeExecuted(
|
||||
'job-2',
|
||||
makeExecutedDetail('job-2', [
|
||||
{ filename: 'e.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
store.onNodeExecuted(
|
||||
'job-2',
|
||||
makeExecutedDetail('job-2', [
|
||||
{ filename: 'f.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
|
||||
const run2Images = store.inProgressItems.filter((i) => i.state === 'image')
|
||||
expect(run2Images).toHaveLength(3)
|
||||
// Selection on run 2's latest output, not run 1's
|
||||
expect(store.selectedId).toBe(`slot:${run2Images[0].id}`)
|
||||
|
||||
store.onJobComplete('job-2')
|
||||
store.resolveIfReady('job-2', true)
|
||||
|
||||
// Must be null for history takeover — not a stale item
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps items visible across multiple resolveIfReady calls until loaded', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
// History asset exists but outputs not loaded yet (async)
|
||||
store.resolveIfReady('job-1', false)
|
||||
expect(store.inProgressItems).toHaveLength(2)
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
|
||||
// Still not loaded on next check
|
||||
store.resolveIfReady('job-1', false)
|
||||
expect(store.inProgressItems).toHaveLength(2)
|
||||
|
||||
// Outputs finally loaded
|
||||
store.resolveIfReady('job-1', true)
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.pendingResolve.has('job-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not remove in-progress items while history outputs are loading', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'b.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
store.onNodeExecuted(
|
||||
'job-1',
|
||||
makeExecutedDetail('job-1', [
|
||||
{ filename: 'c.png', subfolder: '', type: 'output' }
|
||||
])
|
||||
)
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
const itemCount = store.inProgressItems.length
|
||||
expect(itemCount).toBe(3)
|
||||
|
||||
// History asset arrived but allOutputs() returns [] (still loading).
|
||||
// Caller passes false — items must stay visible to prevent a gap
|
||||
// where neither in-progress nor history items are rendered.
|
||||
store.resolveIfReady('job-1', false)
|
||||
expect(store.inProgressItems).toHaveLength(itemCount)
|
||||
|
||||
// Once allOutputs() loads, caller passes true — safe to resolve
|
||||
store.resolveIfReady('job-1', true)
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores executed events for other jobs', () => {
|
||||
const store = useLinearOutputStore()
|
||||
store.onJobStart('job-1')
|
||||
|
||||
store.onNodeExecuted('job-other', makeExecutedDetail('job-other'))
|
||||
|
||||
expect(store.inProgressItems.every((i) => i.state === 'skeleton')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('resets state when leaving app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.select('slot:some-id')
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.trackedJobId).toBeNull()
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores execution events when not in app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Watcher-driven job start should be ignored
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
272
src/renderer/extensions/linearMode/linearOutputStore.ts
Normal file
272
src/renderer/extensions/linearMode/linearOutputStore.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const appModeStore = useAppModeStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
|
||||
const inProgressItems = ref<InProgressItem[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
const isFollowing = ref(true)
|
||||
const trackedJobId = ref<string | null>(null)
|
||||
const pendingResolve = ref(new Set<string>())
|
||||
|
||||
let nextSeq = 0
|
||||
|
||||
function makeItemId(jobId: string): string {
|
||||
return `job-${jobId}-${nextSeq++}`
|
||||
}
|
||||
|
||||
function replaceItem(
|
||||
id: string,
|
||||
updater: (item: InProgressItem) => InProgressItem
|
||||
) {
|
||||
inProgressItems.value = inProgressItems.value.map((i) =>
|
||||
i.id === id ? updater(i) : i
|
||||
)
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const currentSkeletonId = shallowRef<string | null>(null)
|
||||
|
||||
function onJobStart(jobId: string) {
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'skeleton'
|
||||
}
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
|
||||
trackedJobId.value = jobId
|
||||
autoSelect(`slot:${item.id}`)
|
||||
}
|
||||
|
||||
let raf: number | null = null
|
||||
function onLatentPreview(jobId: string, url: string) {
|
||||
// Issue in Firefox where it doesnt seem to always re-render, wrapping in RAF fixes it
|
||||
if (raf) cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(() => {
|
||||
const existing = inProgressItems.value.find(
|
||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
const wasEmpty = existing.state === 'skeleton'
|
||||
replaceItem(existing.id, (i) => ({
|
||||
...i,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: url
|
||||
}))
|
||||
if (wasEmpty) autoSelect(`slot:${existing.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create on-demand for the tracked job
|
||||
if (jobId !== trackedJobId.value) return
|
||||
|
||||
const item: InProgressItem = {
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'latent',
|
||||
latentPreviewUrl: url
|
||||
}
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
autoSelect(`slot:${item.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
function onNodeExecuted(jobId: string, detail: ExecutedWsMessage) {
|
||||
const nodeId = String(detail.display_node || detail.node)
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
const skeletonItem = inProgressItems.value.find(
|
||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||
)
|
||||
|
||||
if (skeletonItem) {
|
||||
const imageItem: InProgressItem = {
|
||||
...skeletonItem,
|
||||
state: 'image',
|
||||
output: newOutputs[0],
|
||||
latentPreviewUrl: undefined
|
||||
}
|
||||
autoSelect(`slot:${imageItem.id}`)
|
||||
|
||||
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
|
||||
const idx = inProgressItems.value.indexOf(skeletonItem)
|
||||
const arr = [...inProgressItems.value]
|
||||
arr.splice(idx, 1, imageItem, ...extras)
|
||||
currentSkeletonId.value = null
|
||||
inProgressItems.value = arr
|
||||
return
|
||||
}
|
||||
|
||||
// No skeleton — create image items directly (only for tracked job)
|
||||
if (jobId !== trackedJobId.value) return
|
||||
|
||||
const newItems: InProgressItem[] = newOutputs.map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
jobId,
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
autoSelect(`slot:${newItems[0].id}`)
|
||||
inProgressItems.value = [...newItems, ...inProgressItems.value]
|
||||
}
|
||||
|
||||
function onJobComplete(jobId: string) {
|
||||
currentSkeletonId.value = null
|
||||
|
||||
const hasImages = inProgressItems.value.some(
|
||||
(i) => i.jobId === jobId && i.state === 'image'
|
||||
)
|
||||
|
||||
if (hasImages) {
|
||||
// Remove non-image items (skeletons, latents), keep images for absorption
|
||||
inProgressItems.value = inProgressItems.value.filter(
|
||||
(i) => i.jobId !== jobId || i.state === 'image'
|
||||
)
|
||||
pendingResolve.value = new Set([...pendingResolve.value, jobId])
|
||||
} else {
|
||||
removeJobItems(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
function removeJobItems(jobId: string) {
|
||||
const removed = inProgressItems.value.filter((i) => i.jobId === jobId)
|
||||
inProgressItems.value = inProgressItems.value.filter(
|
||||
(i) => i.jobId !== jobId
|
||||
)
|
||||
|
||||
if (
|
||||
selectedId.value &&
|
||||
removed.some((i) => `slot:${i.id}` === selectedId.value)
|
||||
) {
|
||||
selectedId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIfReady(jobId: string, historyLoaded: boolean) {
|
||||
if (!pendingResolve.value.has(jobId)) return
|
||||
if (!historyLoaded) return
|
||||
|
||||
const next = new Set(pendingResolve.value)
|
||||
next.delete(jobId)
|
||||
pendingResolve.value = next
|
||||
|
||||
removeJobItems(jobId)
|
||||
}
|
||||
|
||||
function select(id: string | null) {
|
||||
selectedId.value = id
|
||||
isFollowing.value = false
|
||||
}
|
||||
|
||||
function selectAsLatest(id: string | null) {
|
||||
selectedId.value = id
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string) {
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
selectedId.value = slotId
|
||||
isFollowing.value = true
|
||||
return
|
||||
}
|
||||
// User is browsing history — don't yank
|
||||
}
|
||||
|
||||
// --- Event bindings (only active in app mode) ---
|
||||
|
||||
function handleExecuted({ detail }: CustomEvent<ExecutedWsMessage>) {
|
||||
const jobId = detail.prompt_id
|
||||
if (jobId !== executionStore.activeJobId) return
|
||||
onNodeExecuted(jobId, detail)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
inProgressItems.value = []
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
trackedJobId.value = null
|
||||
currentSkeletonId.value = null
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
if (jobId) {
|
||||
onJobStart(jobId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => jobPreviewStore.previewsByPromptId,
|
||||
(previews) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
const jobId = executionStore.activeJobId
|
||||
if (!jobId) return
|
||||
const url = previews[jobId]
|
||||
if (url) onLatentPreview(jobId, url)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appModeStore.isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
} else if (wasActive) {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
reset()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
inProgressItems,
|
||||
selectedId,
|
||||
trackedJobId,
|
||||
pendingResolve,
|
||||
select,
|
||||
selectAsLatest,
|
||||
resolveIfReady,
|
||||
|
||||
onJobStart,
|
||||
onLatentPreview,
|
||||
onNodeExecuted,
|
||||
onJobComplete
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { t } from '@/i18n'
|
||||
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export type StatItem = { content?: string; iconClass?: string }
|
||||
type StatItem = { content?: string; iconClass?: string }
|
||||
export const mediaTypes: Record<string, StatItem> = {
|
||||
'3d': {
|
||||
content: t('sideToolbar.mediaAssets.filter3D'),
|
||||
|
||||
65
src/renderer/extensions/linearMode/useOutputHistory.ts
Normal file
65
src/renderer/extensions/linearMode/useOutputHistory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => MaybeRef<ResultItemImpl[]>
|
||||
} {
|
||||
const outputs = useMediaAssets('output')
|
||||
void outputs.fetchMediaList()
|
||||
const linearStore = useLinearOutputStore()
|
||||
|
||||
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
|
||||
|
||||
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
|
||||
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
|
||||
|
||||
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
|
||||
// For recently completed jobs still pending resolve, derive order from
|
||||
// the in-progress items which are in correct execution order.
|
||||
if (linearStore.pendingResolve.has(user_metadata.jobId)) {
|
||||
const ordered = linearStore.inProgressItems
|
||||
.filter((i) => i.jobId === user_metadata.jobId && i.output)
|
||||
.map((i) => i.output!)
|
||||
if (ordered.length > 0) {
|
||||
outputsCache[item!.id] = ordered
|
||||
return ordered
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
user_metadata.allOutputs &&
|
||||
user_metadata.outputCount &&
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length
|
||||
) {
|
||||
const reversed = user_metadata.allOutputs.toReversed()
|
||||
outputsCache[item!.id] = reversed
|
||||
return reversed
|
||||
}
|
||||
|
||||
const outputRef = useAsyncState(
|
||||
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
||||
if (!jobDetail?.outputs) return []
|
||||
return Object.entries(jobDetail.outputs)
|
||||
.flatMap(flattenNodeOutput)
|
||||
.toReversed()
|
||||
}),
|
||||
[]
|
||||
).state
|
||||
outputsCache[item!.id] = outputRef
|
||||
return outputRef
|
||||
}
|
||||
|
||||
return { outputs, allOutputs }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const { locale } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
rootClass?: string
|
||||
}>()
|
||||
|
||||
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
|
||||
@@ -156,7 +157,7 @@ const inputAriaAttrs = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<WidgetLayoutField :widget :root-class="props.rootClass">
|
||||
<ScrubableNumberInput
|
||||
v-model="modelValue"
|
||||
v-tooltip="buttonTooltip"
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { useHideLayoutField } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineProps<{
|
||||
const { rootClass } = defineProps<{
|
||||
widget: Pick<
|
||||
SimplifiedWidget<string | number | undefined>,
|
||||
'name' | 'label' | 'borderStyle'
|
||||
>
|
||||
rootClass?: string
|
||||
}>()
|
||||
|
||||
const hideLayoutField = useHideLayoutField()
|
||||
@@ -15,7 +16,12 @@ const hideLayoutField = useHideLayoutField()
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-subgrid min-w-0 justify-between gap-1 text-node-component-slot-text"
|
||||
:class="
|
||||
cn(
|
||||
'grid grid-cols-subgrid min-w-0 justify-between gap-1 text-node-component-slot-text',
|
||||
rootClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="!hideLayoutField" class="truncate content-center-safe">
|
||||
<template v-if="widget.name">
|
||||
|
||||
Reference in New Issue
Block a user