Supports VHS video outputs in queue sidebar tab (#1174)

* Properly identify gifs

* Detect VHS video

* Basic video support in queue

* Video in lightbox

* Preview button

* nit

* Fix vitest
This commit is contained in:
Chenlei Hu
2024-10-08 17:10:44 -04:00
committed by GitHub
parent 3cafc10c2b
commit 5f3afa3776
6 changed files with 72 additions and 37 deletions

View File

@@ -249,7 +249,7 @@ const menuItems = computed<MenuItem[]>(() => [
{ {
label: t('loadWorkflow'), label: t('loadWorkflow'),
icon: 'pi pi-file-export', icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow() command: () => menuTargetTask.value?.loadWorkflow(app)
}, },
{ {
label: t('goToNode'), label: t('goToNode'),

View File

@@ -25,7 +25,12 @@
:src="item.url" :src="item.url"
:contain="false" :contain="false"
class="galleria-image" class="galleria-image"
v-if="item.isImage"
/> />
<video v-else-if="item.isVideo" controls width="100%" height="100%">
<source :src="item.url" :type="item.format" />
{{ $t('videoFailedToLoad') }}
</video>
</template> </template>
</Galleria> </Galleria>
</template> </template>

View File

@@ -1,27 +1,30 @@
<template> <template>
<div class="result-container" ref="resultContainer"> <div class="result-container" ref="resultContainer">
<template <ComfyImage
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'" v-if="result.isImage"
> :src="result.url"
<ComfyImage class="task-output-image"
:src="result.url" :contain="imageFit === 'contain'"
class="task-output-image" />
:contain="imageFit === 'contain'" <template v-else-if="result.isVideo">
/> <video controls width="100%" height="100%">
<div class="image-preview-mask"> <source :src="result.url" :type="result.format" />
<Button {{ $t('videoFailedToLoad') }}
icon="pi pi-eye" </video>
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</template> </template>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview"> <div v-else class="task-result-preview">
<i class="pi pi-file"></i> <i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span> <span>{{ result.mediaType }}</span>
</div> </div>
<div v-if="result.supportsPreview" class="preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</div> </div>
</template> </template>
@@ -67,7 +70,7 @@ onMounted(() => {
align-items: center; align-items: center;
} }
.image-preview-mask { .preview-mask {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
@@ -80,7 +83,7 @@ onMounted(() => {
z-index: 1; z-index: 1;
} }
.result-container:hover .image-preview-mask { .result-container:hover .preview-mask {
opacity: 1; opacity: 1;
} }
</style> </style>

View File

@@ -12,15 +12,12 @@ describe('ResultGallery', () => {
let mockResultItem: ResultItemImpl let mockResultItem: ResultItemImpl
beforeEach(() => { beforeEach(() => {
mockResultItem = { mockResultItem = new ResultItemImpl({
filename: 'test.jpg', filename: 'test.jpg',
type: 'images', type: 'images',
nodeId: 'test', nodeId: 1,
mediaType: 'images', mediaType: 'images'
url: 'https://picsum.photos/200/300', })
urlWithTimestamp: 'https://picsum.photos/200/300?t=123456',
supportsPreview: true
}
}) })
const mountResultGallery = (props: ResultGalleryProps, options = {}) => { const mountResultGallery = (props: ResultGalleryProps, options = {}) => {

View File

@@ -2,6 +2,7 @@ import { createI18n } from 'vue-i18n'
const messages = { const messages = {
en: { en: {
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name', extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes', reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert', insert: 'Insert',
@@ -112,6 +113,7 @@ const messages = {
} }
}, },
zh: { zh: {
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称', extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改', reloadToApplyChanges: '重新加载以应用更改',
insert: '插入', insert: '插入',

View File

@@ -1,5 +1,5 @@
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import type { ComfyApp } from '@/scripts/app'
import type { import type {
TaskItem, TaskItem,
TaskType, TaskType,
@@ -10,7 +10,6 @@ import type {
ResultItem ResultItem
} from '@/types/apiTypes' } from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow' import type { NodeId } from '@/types/comfyWorkflow'
import { plainToClass } from 'class-transformer'
import _ from 'lodash' import _ from 'lodash'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toRaw } from 'vue' import { toRaw } from 'vue'
@@ -35,6 +34,22 @@ export class ResultItemImpl {
// 'audio' | 'images' | ... // 'audio' | 'images' | ...
mediaType: string mediaType: string
// VHS output specific fields
format?: string
frame_rate?: number
constructor(obj: Record<string, any>) {
this.filename = obj.filename
this.subfolder = obj.subfolder
this.type = obj.type
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
this.format = obj.format
this.frame_rate = obj.frame_rate
}
get url(): string { get url(): string {
return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}& return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}&
subfolder=${encodeURIComponent(this.subfolder || '')}`) subfolder=${encodeURIComponent(this.subfolder || '')}`)
@@ -44,8 +59,20 @@ export class ResultItemImpl {
return `${this.url}&t=${+new Date()}` return `${this.url}&t=${+new Date()}`
} }
get isVideo(): boolean {
return this.format && this.format.startsWith('video/')
}
get isGif(): boolean {
return this.filename.endsWith('.gif')
}
get isImage(): boolean {
return this.mediaType === 'images' || this.isGif
}
get supportsPreview(): boolean { get supportsPreview(): boolean {
return ['images', 'gifs'].includes(this.mediaType) return this.isImage || this.isVideo
} }
} }
@@ -76,12 +103,13 @@ export class TaskItemImpl {
} }
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) => return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) => Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map((item: ResultItem) => (items as ResultItem[]).map(
plainToClass(ResultItemImpl, { (item: ResultItem) =>
...item, new ResultItemImpl({
nodeId, ...item,
mediaType nodeId,
}) mediaType
})
) )
) )
) )
@@ -213,7 +241,7 @@ export class TaskItemImpl {
: undefined : undefined
} }
public async loadWorkflow() { public async loadWorkflow(app: ComfyApp) {
await app.loadGraphData(toRaw(this.workflow)) await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) { if (this.outputs) {
app.nodeOutputs = toRaw(this.outputs) app.nodeOutputs = toRaw(this.outputs)