feat: output grid and media preview improvements

Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Koshi
2026-03-27 17:32:24 +01:00
parent 07e3b92266
commit ec3c7bd8fe
13 changed files with 603 additions and 85 deletions

View File

@@ -9,6 +9,7 @@ import {
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -20,6 +21,16 @@ const { src, alt = '' } = defineProps<{
alt?: string
}>()
const isVideo = computed(() => {
const videoExt = /\.(mp4|webm|mov)/i
return (
videoExt.test(src) ||
videoExt.test(
new URL(src, location.href).searchParams.get('filename') ?? ''
)
)
})
const { t } = useI18n()
</script>
<template>
@@ -46,7 +57,15 @@ const { t } = useI18n()
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<video
v-if="isVideo"
:src
controls
autoplay
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
<img
v-else
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"

View File

@@ -25,10 +25,15 @@ const {
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
forceHovered?: boolean
}>()
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-neutral-800 text-white shadow-md transition-colors hover:bg-neutral-700'
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const pointerStart = ref<{ x: number; y: number } | null>(null)
@@ -164,23 +169,44 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
>
<button
type="button"
:class="actionButtonClass"
:aria-label="t('mediaAsset.actions.zoom')"
:title="t('mediaAsset.actions.zoom')"
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
@click.stop="lightboxOpen = true"
>
<i class="icon-[lucide--zoom-in] size-4" />
<i class="icon-[lucide--fullscreen] size-4" />
</button>
<button
v-if="dropIndicator.onMaskEdit"
type="button"
:class="actionButtonClass"
:aria-label="t('maskEditor.editMask')"
:title="t('maskEditor.editMask')"
@click.stop="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
<button
v-if="dropIndicator.onDownload"
type="button"
:class="actionButtonClass"
:aria-label="t('g.downloadImage')"
:title="t('g.downloadImage')"
@click.stop="dropIndicator.onDownload()"
>
<i class="icon-[lucide--download] size-4" />
</button>
<button
v-if="dropIndicator.onRemove"
type="button"
:class="actionButtonClass"
:aria-label="t('g.removeImage')"
:title="t('g.removeImage')"
@click.stop="dropIndicator.onRemove()"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<button
v-if="dropIndicator.onMaskEdit"
type="button"
class="mx-3 flex w-[calc(100%-1.5rem)] cursor-pointer items-center justify-center gap-2 rounded-lg border border-node-component-border bg-component-node-widget-background p-2 text-sm text-component-node-foreground transition-colors hover:bg-component-node-widget-background-hovered"
@click.stop="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
{{ t('maskEditor.editMask') }}
</button>
<ImageLightbox
v-if="dropIndicator.imageUrl"
v-model="lightboxOpen"

View File

@@ -38,7 +38,7 @@ const height = ref('')
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
class="min-h-0 flex-1 object-contain"
:src
@load="
() => {
@@ -48,5 +48,9 @@ const height = ref('')
}
"
/>
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
<span
v-if="!mobile"
class="self-end pr-2 md:z-10"
v-text="`${width} x ${height}`"
/>
</template>

View File

@@ -7,9 +7,11 @@ import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const { setMode } = useAppMode()
@@ -18,6 +20,23 @@ const { hasOutputs } = storeToRefs(appModeStore)
const nodeOutputStore = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
const outputsByNode = computed(() => {
const map = new Map<string, ResultItemImpl | undefined>()
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) {
map.set(String(nodeId), undefined)
continue
}
const results = flattenNodeOutput([nodeId, nodeOutput])
map.set(String(nodeId), results[0])
}
return map
})
const existingOutput = computed(() => {
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
@@ -28,11 +47,25 @@ const existingOutput = computed(() => {
}
return undefined
})
function handleReorder(fromIndex: number, toIndex: number) {
const outputs = [...appModeStore.selectedOutputs]
const [moved] = outputs.splice(fromIndex, 1)
outputs.splice(toIndex, 0, moved)
appModeStore.selectedOutputs = outputs
}
</script>
<template>
<OutputGrid
v-if="isMultiOutput && hasOutputs"
:outputs-by-node="outputsByNode"
:output-count="appModeStore.selectedOutputs.length"
builder-mode
@reorder="handleReorder"
/>
<MediaOutputPreview
v-if="existingOutput"
v-else-if="existingOutput"
:output="existingOutput"
class="px-12 py-24"
/>

View File

@@ -11,13 +11,13 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
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 LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
@@ -27,13 +27,8 @@ import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const hasAppContent = computed(
() =>
appModeStore.selectedInputs.length > 0 ||
appModeStore.selectedOutputs.length > 0
)
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
@@ -47,9 +42,38 @@ const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
const lightboxSrc = ref('')
const lightboxUrl = ref('')
const lightboxOpen = ref(false)
function openLightbox(url: string) {
if (mobile) {
document
.querySelectorAll<HTMLMediaElement>('video, audio')
.forEach((el) => el.pause())
}
lightboxUrl.value = url
lightboxOpen.value = true
}
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
const outputsByNode = computed(() => {
const map = new Map<string, ResultItemImpl>()
if (!selectedItem.value) return map
const outputs = allOutputs(selectedItem.value)
const outputLookup = new Map<string, ResultItemImpl>()
for (const output of outputs) {
if (!outputLookup.has(String(output.nodeId))) {
outputLookup.set(String(output.nodeId), output)
}
}
for (const nodeId of appModeStore.selectedOutputs) {
const output = outputLookup.get(String(nodeId))
if (output) map.set(String(nodeId), output)
}
return map
})
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
@@ -143,25 +167,31 @@ async function rerun(e: Event) {
]"
/>
</section>
<LinearArrange v-if="isArrangeMode" />
<AppTemplateView
v-else-if="hasAppContent"
:selected-output="selectedOutput"
<OutputGrid
v-if="isMultiOutput && outputsByNode.size > 0"
:outputs-by-node="outputsByNode"
:output-count="appModeStore.selectedOutputs.length"
:show-skeleton="showSkeleton"
:mobile
@open-lightbox="openLightbox"
/>
<template v-else>
<ImagePreview
v-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
/>
<MediaOutputPreview
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearWelcome v-else />
</template>
<ImagePreview
v-else-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
/>
<MediaOutputPreview
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
@dblclick="
!mobile && selectedOutput.url && openLightbox(selectedOutput.url)
"
@click="mobile && selectedOutput.url && openLightbox(selectedOutput.url)"
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<LinearWelcome v-else />
<div
v-if="!mobile"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
@@ -175,12 +205,7 @@ async function rerun(e: Event) {
v-if="!isBuilderMode"
class="z-10 min-w-0"
@update-selection="handleSelection"
@open-lightbox="
(url) => {
lightboxSrc = url
lightboxOpen = true
}
"
@open-lightbox="openLightbox"
/>
<LinearFeedback
v-if="typeformWidgetId"
@@ -191,12 +216,7 @@ async function rerun(e: Event) {
<OutputHistory
v-else-if="!isBuilderMode"
@update-selection="handleSelection"
@open-lightbox="
(url) => {
lightboxSrc = url
lightboxOpen = true
}
"
@open-lightbox="openLightbox"
/>
<ImageLightbox v-model="lightboxOpen" :src="lightboxSrc" />
<ImageLightbox v-model="lightboxOpen" :src="lightboxUrl" />
</template>

View File

@@ -3,8 +3,8 @@ import { defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import type { MediaOutputItem } from '@/renderer/extensions/linearMode/mediaTypes'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const Preview3d = defineAsyncComponent(
@@ -14,7 +14,7 @@ const Preview3d = defineAsyncComponent(
defineOptions({ inheritAttrs: false })
const { output } = defineProps<{
output: ResultItemImpl
output: MediaOutputItem
mobile?: boolean
}>()
@@ -30,6 +30,7 @@ const attrs = useAttrs()
<VideoPreview
v-else-if="getMediaType(output) === 'video'"
:src="output.url"
:mobile
:class="
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
"

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
outputsByNode,
outputCount,
showSkeleton = false,
builderMode = false,
mobile = false
} = defineProps<{
outputsByNode: Map<string, ResultItemImpl | undefined>
outputCount: number
showSkeleton?: boolean
builderMode?: boolean
mobile?: boolean
}>()
const emit = defineEmits<{
reorder: [fromIndex: number, toIndex: number]
openLightbox: [url: string]
}>()
const AREA_NAMES = ['a', 'b', 'c', 'd']
const MEDIA_TYPE_META: Record<string, { label: string; icon: string }> = {
images: { label: 'Image', icon: 'icon-[lucide--image]' },
video: { label: 'Video', icon: 'icon-[lucide--film]' },
audio: { label: 'Audio', icon: 'icon-[lucide--volume-2]' },
text: { label: 'Text', icon: 'icon-[lucide--file-text]' },
gltf: { label: '3D', icon: 'icon-[lucide--box]' }
}
function getOutputLabel(
nodeId: string,
index: number
): { label: string; icon: string } {
const node = resolveNode(Number(nodeId))
if (!node)
return { label: `Output ${index + 1}`, icon: 'icon-[lucide--layout-grid]' }
const comfyClass = node.comfyClass ?? ''
if (comfyClass.toLowerCase().includes('image') || comfyClass === 'SaveImage')
return { label: node.title || 'Image', icon: MEDIA_TYPE_META.images.icon }
if (comfyClass.toLowerCase().includes('video'))
return { label: node.title || 'Video', icon: MEDIA_TYPE_META.video.icon }
if (comfyClass.toLowerCase().includes('audio'))
return { label: node.title || 'Audio', icon: MEDIA_TYPE_META.audio.icon }
if (
comfyClass.toLowerCase().includes('3d') ||
comfyClass.toLowerCase().includes('gltf')
)
return { label: node.title || '3D', icon: MEDIA_TYPE_META.gltf.icon }
if (comfyClass.toLowerCase().includes('text'))
return { label: node.title || 'Text', icon: MEDIA_TYPE_META.text.icon }
return {
label: node.title || `Output ${index + 1}`,
icon: 'icon-[lucide--layout-grid]'
}
}
// Matches p-2 and gap-2 on the grid container
const GRID_PADDING_PX = 8
const GRID_GAP_PX = 8
const MIN_RATIO = 0.2
const MAX_RATIO = 0.8
const rowRatio = ref(0.5)
const colRatio = ref(0.5)
const gridRef = useTemplateRef('gridRef')
const isResizing = ref(false)
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
function cssSplitPos(ratio: number) {
const totalPad = GRID_PADDING_PX * 2
const pct = ratio * 100
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
}
const rowHandleCssTop = computed(() => cssSplitPos(rowRatio.value))
const colHandleCssLeft = computed(() => cssSplitPos(colRatio.value))
/** For 3 outputs, horizontal handle only spans the left column. */
const rowHandleWidth = computed(() => {
if (outputCount !== 3) return '100%'
const totalPad = GRID_PADDING_PX * 2
const pct = colRatio.value * 100
return `calc((100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_PADDING_PX}px)`
})
function gridStyleForCount(count: number) {
const r = rowRatio.value
const c = colRatio.value
switch (count) {
case 2:
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
case 3:
return {
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
case 4:
return {
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
default:
return { gridTemplate: '"a" 1fr / 1fr' }
}
}
const gridStyle = computed(() => {
if (mobile) {
const rows = AREA_NAMES.slice(0, outputCount)
.map((a) => `"${a}" 1fr`)
.join(' ')
return { gridTemplate: `${rows} / 1fr` }
}
return gridStyleForCount(outputCount)
})
function startResize(
ratioRef: { value: number },
axis: 'row' | 'col',
e: MouseEvent
) {
e.preventDefault()
isResizing.value = true
const startPos = axis === 'row' ? e.clientY : e.clientX
const startRatio = ratioRef.value
const container = gridRef.value
if (!container) return
const size = axis === 'row' ? container.clientHeight : container.clientWidth
function onMouseMove(ev: MouseEvent) {
const pos = axis === 'row' ? ev.clientY : ev.clientX
const delta = pos - startPos
ratioRef.value = Math.max(
MIN_RATIO,
Math.min(MAX_RATIO, startRatio + delta / size)
)
}
function onMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onRowResizeStart(e: MouseEvent) {
startResize(rowRatio, 'row', e)
}
function onColResizeStart(e: MouseEvent) {
startResize(colRatio, 'col', e)
}
const cells = computed(() => {
const nodeIds = [...outputsByNode.keys()]
return nodeIds.slice(0, 4).map((nodeId, i) => {
const meta = getOutputLabel(nodeId, i)
return {
nodeId,
label: meta.label,
icon: meta.icon,
output: outputsByNode.get(nodeId),
area: AREA_NAMES[i]
}
})
})
const dragFromIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number, e: DragEvent) {
dragFromIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(index: number, e: DragEvent) {
e.preventDefault()
dragOverIndex.value = index
}
function onDragLeave() {
dragOverIndex.value = null
}
function onDrop(index: number) {
if (dragFromIndex.value !== null && dragFromIndex.value !== index) {
emit('reorder', dragFromIndex.value, index)
}
dragFromIndex.value = null
dragOverIndex.value = null
}
function onDragEnd() {
dragFromIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div
ref="gridRef"
:class="
cn(
'relative grid min-h-0 flex-1 gap-2 overflow-hidden p-2',
builderMode &&
'pt-[calc(var(--workflow-tabs-height)+var(--spacing)*18)]',
isResizing && 'select-none'
)
"
:style="gridStyle"
>
<div
v-for="(cell, index) in cells"
:key="cell.nodeId"
:class="
cn(
'relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg',
builderMode
? 'border-2 border-dashed border-warning-background'
: 'border border-border-subtle',
dragOverIndex === index && 'ring-2 ring-primary-background'
)
"
:style="{ gridArea: cell.area }"
:draggable="builderMode"
@dragstart="builderMode && onDragStart(index, $event)"
@dragover="builderMode && onDragOver(index, $event)"
@dragleave="builderMode && onDragLeave()"
@drop="builderMode && onDrop(index)"
@dragend="builderMode && onDragEnd()"
@dblclick="
!mobile && cell.output?.url && emit('openLightbox', cell.output.url)
"
@click="
mobile && cell.output?.url && emit('openLightbox', cell.output.url)
"
>
<div
v-if="builderMode || !cell.output"
class="absolute top-0 left-0 z-10 flex items-center gap-1.5 rounded-br-lg bg-base-background/80 px-2.5 py-1 text-xxs text-muted-foreground"
>
<i :class="cn(cell.icon, 'size-3')" />
{{ cell.label }}
</div>
<MediaOutputPreview
v-if="cell.output"
:output="cell.output"
:mobile
class="size-full object-contain"
/>
<LatentPreview v-else-if="showSkeleton" />
<div
v-else
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
>
<i :class="cn(cell.icon, 'size-8 opacity-30')" />
<span v-if="builderMode" class="text-xs opacity-50">
{{ t('linearMode.arrange.resultsLabel') }}
</span>
</div>
<div v-if="mobile && cell.output" class="absolute inset-0 z-10" />
</div>
<!-- Horizontal resize handle (row split) -->
<div
v-if="outputCount >= 2 && !builderMode && !mobile"
class="absolute left-0 z-20 h-2 cursor-row-resize"
:style="{ top: rowHandleCssTop, width: rowHandleWidth }"
@mousedown="onRowResizeStart"
>
<div
:class="
cn(
'mx-auto h-px w-full bg-border-subtle/30 transition-colors',
isResizing && 'bg-border-subtle'
)
"
/>
</div>
<!-- Vertical resize handle (column split) -->
<div
v-if="outputCount >= 3 && !builderMode && !mobile"
class="absolute top-0 z-20 h-full w-2 cursor-col-resize"
:style="{ left: colHandleCssLeft }"
@mousedown="onColResizeStart"
>
<div
:class="
cn(
'my-auto h-full w-px bg-border-subtle/30 transition-colors',
isResizing && 'bg-border-subtle'
)
"
/>
</div>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
const { src } = defineProps<{
const { src, mobile = false } = defineProps<{
src: string
mobile?: boolean
}>()
const videoRef = useTemplateRef('videoRef')
@@ -23,5 +24,9 @@ const height = ref('')
}
"
/>
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
<span
v-if="!mobile"
class="z-10 self-end pr-2"
v-text="`${width} x ${height}`"
/>
</template>

View File

@@ -8,13 +8,12 @@ import type { InProgressItem } from '@/renderer/extensions/linearMode/linearMode
import type { ResultItemImpl } from '@/stores/queueStore'
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 { isAppMode } = useAppMode()
const appModeStore = useAppModeStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const workflowStore = useWorkflowStore()
@@ -117,13 +116,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const newOutputs = flattenNodeOutput([nodeId, detail.output])
if (newOutputs.length === 0) return
// Skip output items for nodes not flagged as output nodes
const outputNodeIds = appModeStore.selectedOutputs
if (
outputNodeIds.length > 0 &&
!outputNodeIds.some((id) => String(id) === String(nodeId))
)
return
// Track in-progress items for all output nodes, regardless of
// which ones are selected for the grid view. This ensures the
// full history shows every generated output.
const skeletonItem = inProgressItems.value.find(
(i) => i.id === currentSkeletonId.value && i.jobId === jobId

View File

@@ -1,6 +1,12 @@
import { t } from '@/i18n'
import type { ResultItemImpl } from '@/stores/queueStore'
export interface MediaOutputItem {
url: string
content?: string
isVideo: boolean
isImage: boolean
mediaType: string
}
type StatItem = { content?: string; iconClass?: string }
export const mediaTypes: Record<string, StatItem> = {
@@ -26,7 +32,7 @@ export const mediaTypes: Record<string, StatItem> = {
}
}
export function getMediaType(output?: ResultItemImpl) {
export function getMediaType(output?: MediaOutputItem) {
if (!output) return ''
if (output.isVideo) return 'video'
if (output.isImage) return 'images'

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { cssSplitPos, gridStyleForCount } from './outputGridUtil'
describe('cssSplitPos', () => {
it('returns calc expression for ratio 0.5', () => {
const result = cssSplitPos(0.5)
expect(result).toBe('calc(8px + (100% - 24px) * 0.5 + 4px)')
})
it('returns calc expression for ratio 0', () => {
const result = cssSplitPos(0)
expect(result).toBe('calc(8px + (100% - 24px) * 0 + 4px)')
})
it('returns calc expression for ratio 1', () => {
const result = cssSplitPos(1)
expect(result).toBe('calc(8px + (100% - 24px) * 1 + 4px)')
})
})
describe('gridStyleForCount', () => {
it('returns single column for count 1', () => {
expect(gridStyleForCount(1, 0.5, 0.5)).toEqual({
gridTemplate: '"a" 1fr / 1fr'
})
})
it('returns two rows for count 2', () => {
const result = gridStyleForCount(2, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a" 0.5fr "b" 0.5fr / 1fr')
})
it('returns L-shape for count 3', () => {
const result = gridStyleForCount(3, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a c" 0.5fr "b c" 0.5fr / 0.5fr 0.5fr')
})
it('returns 2x2 grid for count 4', () => {
const result = gridStyleForCount(4, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a b" 0.5fr "c d" 0.5fr / 0.5fr 0.5fr')
})
it('respects custom row ratio', () => {
const result = gridStyleForCount(2, 0.7, 0.5)
expect(result.gridTemplate).toContain('0.7fr')
expect(result.gridTemplate).toContain(`${1 - 0.7}fr`)
})
it('respects custom column ratio', () => {
const result = gridStyleForCount(4, 0.5, 0.3)
expect(result.gridTemplate).toContain('0.3fr')
expect(result.gridTemplate).toContain(`${1 - 0.3}fr`)
})
it('defaults to single for count 0', () => {
expect(gridStyleForCount(0, 0.5, 0.5)).toEqual({
gridTemplate: '"a" 1fr / 1fr'
})
})
})

View File

@@ -0,0 +1,34 @@
// Matches p-2 and gap-2 on the grid container
const GRID_PADDING_PX = 8
const GRID_GAP_PX = 8
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
export function cssSplitPos(ratio: number): string {
const totalPad = GRID_PADDING_PX * 2
const pct = ratio * 100
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
}
/** Build CSS grid-template for a given output count. */
export function gridStyleForCount(
count: number,
rowRatio: number,
colRatio: number
): { gridTemplate: string } {
const r = rowRatio
const c = colRatio
switch (count) {
case 2:
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
case 3:
return {
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
case 4:
return {
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
default:
return { gridTemplate: '"a" 1fr / 1fr' }
}
}

View File

@@ -30,7 +30,6 @@ export function useOutputHistory(): {
const linearStore = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const appModeStore = useAppModeStore()
const queueStore = useQueueStore()
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
@@ -63,14 +62,6 @@ export function useOutputHistory(): {
hasActiveWorkflowJobs()
)
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return []
return items.filter((r) =>
nodeIds.some((id) => String(id) === String(r.nodeId))
)
}
const sessionMedia = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
@@ -101,7 +92,7 @@ export function useOutputHistory(): {
if (!item?.id) return []
const cached = resolvedCache.get(item.id)
if (cached) return filterByOutputNodes(cached)
if (cached) return cached
const user_metadata = getOutputAssetMetadata(item.user_metadata)
if (!user_metadata) return []
@@ -114,7 +105,7 @@ export function useOutputHistory(): {
.map((i) => i.output!)
if (ordered.length > 0) {
resolvedCache.set(item.id, ordered)
return filterByOutputNodes(ordered)
return ordered
}
}
@@ -129,13 +120,13 @@ export function useOutputHistory(): {
) {
const reversed = user_metadata.allOutputs.toReversed()
resolvedCache.set(item.id, reversed)
return filterByOutputNodes(reversed)
return reversed
}
// Async fallback for multi-output jobs — fetch full /jobs/{id} detail.
// This can be hit if the user executes the job then switches tabs.
const existing = asyncRefs.get(item.id)
if (existing) return filterByOutputNodes(existing.value)
if (existing) return existing.value
const itemId = item.id
const outputRef = useAsyncState(
@@ -150,16 +141,26 @@ export function useOutputHistory(): {
[]
).state
asyncRefs.set(item.id, outputRef)
return filterByOutputNodes(outputRef.value)
return outputRef.value
}
function selectFirstHistory() {
const first = outputs.media.value[0]
if (first) {
linearStore.selectAsLatest(`history:${first.id}:0`)
} else {
if (!first) {
linearStore.selectAsLatest(null)
return
}
// Prefer the first output that matches a user-selected output node
const selectedNodeIds = useAppModeStore().selectedOutputs
const outs = allOutputs(first)
const preferredIdx = selectedNodeIds.length
? outs.findIndex((o) =>
selectedNodeIds.some((id) => String(id) === String(o.nodeId))
)
: -1
linearStore.selectAsLatest(
`history:${first.id}:${preferredIdx >= 0 ? preferredIdx : 0}`
)
}
// Resolve in-progress items when history outputs are loaded.