mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Workflow preview added.
This commit is contained in:
@@ -170,6 +170,22 @@
|
||||
>
|
||||
{{ template.title }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="template.author_name"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
template.author_avatar_url ??
|
||||
'/assets/images/comfy-logo-single.svg'
|
||||
"
|
||||
:alt="template.author_name"
|
||||
class="size-4 rounded-full bg-secondary-background object-cover"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ template.author_name }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="line-clamp-2 text-xs text-muted-foreground"
|
||||
:title="template.description"
|
||||
|
||||
@@ -178,17 +178,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Right column: Workflow preview -->
|
||||
<div class="flex flex-1 flex-col bg-graph-canvas">
|
||||
<div
|
||||
class="flex size-full items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<i class="icon-[lucide--workflow] size-16 opacity-30" />
|
||||
<span class="text-sm">{{
|
||||
$t('discover.detail.workflowPreview')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 min-w-0 flex-1">
|
||||
<WorkflowPreviewCanvas :workflow-url="workflow.workflow_url" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,6 +191,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import WorkflowPreviewCanvas from '@/components/discover/WorkflowPreviewCanvas.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AlgoliaWorkflowTemplate } from '@/types/discoverTypes'
|
||||
|
||||
|
||||
192
src/components/discover/WorkflowPreviewCanvas.vue
Normal file
192
src/components/discover/WorkflowPreviewCanvas.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full min-h-0 bg-graph-canvas">
|
||||
<canvas ref="canvasRef" class="absolute left-0 top-0" />
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-2] size-8 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--alert-circle] size-8" />
|
||||
<span class="text-sm">{{ $t('discover.detail.previewError') }}</span>
|
||||
<span class="text-xs opacity-50">{{ error.message }}</span>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="!workflowUrl"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--workflow] size-16 opacity-30" />
|
||||
<span class="text-sm">{{ $t('discover.detail.workflowPreview') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const { workflowUrl } = defineProps<{
|
||||
workflowUrl?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const graph = shallowRef<LGraph>()
|
||||
const canvas = shallowRef<LGraphCanvas>()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
function updateCanvasSize() {
|
||||
if (!canvasRef.value || !containerRef.value || !canvas.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return
|
||||
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
canvas.value.resize(
|
||||
Math.round(rect.width * dpr),
|
||||
Math.round(rect.height * dpr)
|
||||
)
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
if (!canvasRef.value || !containerRef.value || isInitialized.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return
|
||||
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
canvasRef.value.width = Math.round(rect.width * dpr)
|
||||
canvasRef.value.height = Math.round(rect.height * dpr)
|
||||
|
||||
graph.value = new LGraph()
|
||||
canvas.value = new LGraphCanvas(canvasRef.value, graph.value, {
|
||||
skip_render: true
|
||||
})
|
||||
canvas.value.state.readOnly = true
|
||||
canvas.value.allow_searchbox = false
|
||||
canvas.value.allow_dragnodes = false
|
||||
canvas.value.allow_interaction = false
|
||||
canvas.value.startRendering()
|
||||
isInitialized.value = true
|
||||
}
|
||||
|
||||
function fitGraphToCanvas() {
|
||||
if (!graph.value || !canvas.value) return
|
||||
|
||||
const nodes = graph.value.nodes
|
||||
if (nodes.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
minX = Math.min(minX, node.pos[0])
|
||||
minY = Math.min(minY, node.pos[1])
|
||||
maxX = Math.max(maxX, node.pos[0] + node.size[0])
|
||||
maxY = Math.max(maxY, node.pos[1] + node.size[1])
|
||||
}
|
||||
|
||||
const graphWidth = maxX - minX
|
||||
const graphHeight = maxY - minY
|
||||
const dpr = Math.max(window.devicePixelRatio, 1)
|
||||
const canvasWidth = canvas.value.canvas.width / dpr
|
||||
const canvasHeight = canvas.value.canvas.height / dpr
|
||||
const padding = 50
|
||||
|
||||
if (graphWidth <= 0 || graphHeight <= 0) return
|
||||
if (canvasWidth <= 0 || canvasHeight <= 0) return
|
||||
|
||||
const scaleX = (canvasWidth - padding * 2) / graphWidth
|
||||
const scaleY = (canvasHeight - padding * 2) / graphHeight
|
||||
const scale = Math.min(scaleX, scaleY, 1)
|
||||
|
||||
canvas.value.ds.scale = scale
|
||||
canvas.value.ds.offset[0] = -minX + padding / scale
|
||||
canvas.value.ds.offset[1] = -minY + padding / scale
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
async function loadWorkflow() {
|
||||
if (!workflowUrl) return
|
||||
|
||||
// Wait for canvas to be initialized
|
||||
if (!isInitialized.value) {
|
||||
await nextTick()
|
||||
initCanvas()
|
||||
}
|
||||
|
||||
if (!graph.value || !canvas.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(workflowUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Check if node types are registered
|
||||
const registeredTypes = Object.keys(LiteGraph.registered_node_types)
|
||||
if (registeredTypes.length === 0) {
|
||||
throw new Error('No node types registered yet')
|
||||
}
|
||||
|
||||
graph.value.configure(data)
|
||||
await nextTick()
|
||||
updateCanvasSize()
|
||||
fitGraphToCanvas()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(containerRef, () => {
|
||||
updateCanvasSize()
|
||||
fitGraphToCanvas()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowUrl,
|
||||
() => {
|
||||
loadWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initCanvas()
|
||||
await loadWorkflow()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
canvas.value?.stopRendering()
|
||||
})
|
||||
</script>
|
||||
@@ -26,7 +26,13 @@ const RETRIEVE_ATTRIBUTES = [
|
||||
'tags',
|
||||
'models',
|
||||
'open_source',
|
||||
'requires_custom_nodes'
|
||||
'requires_custom_nodes',
|
||||
'author_name',
|
||||
'author_avatar_url',
|
||||
'run_count',
|
||||
'view_count',
|
||||
'copy_count',
|
||||
'workflow_url'
|
||||
] as const
|
||||
|
||||
export function useWorkflowTemplateSearch() {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"author": "Comfy Org",
|
||||
"runWorkflow": "Run Workflow",
|
||||
"makeCopy": "Make a Copy",
|
||||
"workflowPreview": "Workflow Preview"
|
||||
"workflowPreview": "Workflow Preview",
|
||||
"previewError": "Failed to load preview"
|
||||
}
|
||||
},
|
||||
"g": {
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface AlgoliaWorkflowTemplate {
|
||||
run_count?: number
|
||||
view_count?: number
|
||||
copy_count?: number
|
||||
workflow_json?: string
|
||||
workflow_url?: string
|
||||
}
|
||||
|
||||
export type WorkflowTemplateSearchAttribute = keyof AlgoliaWorkflowTemplate
|
||||
|
||||
Reference in New Issue
Block a user