Compare commits
1 Commits
main
...
feat/home-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb48a8718 |
BIN
apps/website/public/images/hero/renders/render-01.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-02.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-03.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/website/public/images/hero/renders/render-04.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-05.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-06.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-07.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/website/public/images/hero/renders/render-08.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-09.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-10.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-11.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-12.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-13.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/website/public/images/hero/renders/render-14.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
apps/website/public/images/hero/renders/render-15.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-16.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-17.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/website/public/images/hero/renders/render-18.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-19.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-20.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-21.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-22.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/website/public/images/hero/renders/render-23.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-24.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-25.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-26.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
apps/website/public/images/hero/renders/render-27.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-28.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-29.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/website/public/images/hero/renders/render-30.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-31.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/website/public/images/hero/renders/render-32.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-33.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/hero/renders/render-34.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-35.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
apps/website/public/images/hero/renders/render-36.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-37.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/website/public/images/hero/renders/render-38.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/website/public/images/hero/renders/render-39.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/website/public/images/hero/renders/render-40.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-41.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
apps/website/public/images/hero/renders/render-42.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-43.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/website/public/images/hero/renders/render-44.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
apps/website/public/images/hero/renders/render-45.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website/public/images/hero/renders/render-46.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/website/public/images/hero/renders/render-47.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/website/public/images/hero/renders/render-48.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/website/public/images/hero/renders/render-49.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
apps/website/public/images/hero/renders/render-50.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
75
apps/website/src/components/home/HeroHeadline.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', compact = false } = defineProps<{
|
||||
locale?: Locale
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const lines = computed(() => t('hero.title', locale).split('\n'))
|
||||
|
||||
const size = computed(() => (compact ? 'text-3xl sm:text-4xl' : 'text-5xl'))
|
||||
|
||||
const lineGap = computed(() => (compact ? '-mt-2' : 'mt-2'))
|
||||
|
||||
const pill =
|
||||
'inline-block rounded-2xl px-5 py-2 font-formula-narrow leading-none font-semibold uppercase'
|
||||
|
||||
// PP Formula Narrow sits high in its em box; nudge the glyphs down so they read
|
||||
// optically centered inside the highlighter block.
|
||||
const inner = 'relative top-[0.06em] inline-block'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h1 class="flex flex-col items-center">
|
||||
<span
|
||||
:class="
|
||||
cn(pill, size, 'bg-primary-comfy-yellow text-primary-comfy-ink')
|
||||
"
|
||||
>
|
||||
<span :class="inner">{{ lines[0] }}</span>
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
pill,
|
||||
size,
|
||||
'bg-primary-comfy-yellow text-primary-comfy-ink',
|
||||
lineGap
|
||||
)
|
||||
"
|
||||
>
|
||||
<span :class="inner">{{ lines[1] }}</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'max-w-md text-primary-comfy-canvas',
|
||||
compact ? 'mt-5 text-sm/relaxed' : 'mt-8 text-base'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.cloud"
|
||||
target="_blank"
|
||||
variant="outline"
|
||||
size="nav"
|
||||
:class="cn('uppercase', compact ? 'mt-5' : 'mt-7')"
|
||||
>
|
||||
{{ t('hero.cta.cloud', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</template>
|
||||
38
apps/website/src/components/home/HeroNodeWidgets.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Minus, Plus } from '@lucide/vue'
|
||||
|
||||
import type { NodeWidget } from './heroWorkflowGraph'
|
||||
|
||||
const { widgets } = defineProps<{ widgets: NodeWidget[] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="widget in widgets"
|
||||
:key="widget.name"
|
||||
class="bg-hero-node-inset flex h-7 items-center justify-between gap-2 rounded-lg px-2.5 text-xs"
|
||||
>
|
||||
<template v-if="widget.kind === 'number'">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Minus class="size-3 shrink-0 text-white/30" />
|
||||
<span class="truncate text-white/40">{{ widget.name }}</span>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-2 text-white/80">
|
||||
<span class="tabular-nums">{{ widget.value }}</span>
|
||||
<Plus class="size-3 text-white/30" />
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate text-white/40">{{ widget.name }}</span>
|
||||
<span class="flex min-w-0 items-center gap-1 text-white/80">
|
||||
<span class="truncate">{{ widget.value }}</span>
|
||||
<ChevronDown
|
||||
v-if="widget.kind === 'combo'"
|
||||
class="size-3 shrink-0 text-white/35"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,55 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import HeroWorkflow from './HeroWorkflow.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-6 py-12 lg:px-16">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl font-light whitespace-pre-line lg:text-6xl"
|
||||
>
|
||||
{{ t('hero.title', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-lg text-sm/relaxed lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
|
||||
>
|
||||
{{ t('hero.runFirstWorkflow', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<section class="hero-dot-grid relative overflow-hidden bg-primary-comfy-ink">
|
||||
<HeroWorkflow :locale />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
344
apps/website/src/components/home/HeroWorkflow.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import HeroHeadline from './HeroHeadline.vue'
|
||||
import HeroNodeWidgets from './HeroNodeWidgets.vue'
|
||||
import HeroWorkflowNode from './HeroWorkflowNode.vue'
|
||||
import HeroWorkflowOutput from './HeroWorkflowOutput.vue'
|
||||
import {
|
||||
NODE_TITLE_KEYS,
|
||||
NODE_W,
|
||||
STAGE_H,
|
||||
STAGE_W,
|
||||
clampNodePosition,
|
||||
computeWires,
|
||||
homePositions,
|
||||
nodeWidgets
|
||||
} from './heroWorkflowGraph'
|
||||
import type {
|
||||
NodeWidget,
|
||||
Point,
|
||||
Rect,
|
||||
WorkflowNodeId
|
||||
} from './heroWorkflowGraph'
|
||||
import { useHeroWorkflowRun } from './useHeroWorkflowRun'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const run = useHeroWorkflowRun()
|
||||
const { activeNode, nodeProgress, phase, seed, totalProgress } = run
|
||||
|
||||
const NODE_IDS: WorkflowNodeId[] = [
|
||||
'model',
|
||||
'clip',
|
||||
'vae',
|
||||
'lora',
|
||||
'seed',
|
||||
'output'
|
||||
]
|
||||
|
||||
const percent = computed(() => Math.round(totalProgress.value * 100))
|
||||
|
||||
function widgetsFor(id: WorkflowNodeId): NodeWidget[] {
|
||||
if (id === 'seed') {
|
||||
return [
|
||||
{ name: 'seed', value: String(seed.value), kind: 'number' },
|
||||
{ name: 'control_after_generate', value: 'randomize', kind: 'combo' }
|
||||
]
|
||||
}
|
||||
return nodeWidgets[id] ?? []
|
||||
}
|
||||
|
||||
// The desktop graph is authored in a fixed design coordinate space and scaled
|
||||
// as a single unit to fit the viewport width, so the whole composition stays on
|
||||
// screen at every size. Node positions are live state so they can be dragged;
|
||||
// widths are fixed per node and heights are measured once for wiring.
|
||||
const MAX_SCALE = 1.3
|
||||
|
||||
const positions = ref<Record<WorkflowNodeId, Point>>(
|
||||
structuredClone(homePositions)
|
||||
)
|
||||
|
||||
const frameRef = ref<HTMLElement>()
|
||||
const stageRef = ref<HTMLElement>()
|
||||
const scale = ref(1)
|
||||
const heights = ref<Record<string, number>>({})
|
||||
|
||||
// Heights are read from layout offsets (not getBoundingClientRect) so they stay
|
||||
// in unscaled design coordinates regardless of the stage's scale transform.
|
||||
function measureHeights() {
|
||||
const stage = stageRef.value
|
||||
if (!stage) return
|
||||
const next: Record<string, number> = {}
|
||||
stage.querySelectorAll<HTMLElement>('[data-node]').forEach((el) => {
|
||||
next[el.dataset.node ?? ''] = el.offsetHeight
|
||||
})
|
||||
heights.value = next
|
||||
}
|
||||
|
||||
function updateScale() {
|
||||
const width = frameRef.value?.clientWidth ?? STAGE_W
|
||||
scale.value = Math.min(width / STAGE_W, MAX_SCALE)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
updateScale()
|
||||
measureHeights()
|
||||
}
|
||||
|
||||
useResizeObserver(frameRef, refresh)
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
width: `${STAGE_W}px`,
|
||||
height: `${STAGE_H}px`,
|
||||
transform: `translateX(-50%) scale(${scale.value})`
|
||||
}))
|
||||
|
||||
function nodeStyle(id: WorkflowNodeId) {
|
||||
const { x, y } = positions.value[id]
|
||||
return {
|
||||
transform: `translate3d(${x}px, ${y}px, 0)`,
|
||||
width: `${NODE_W[id]}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Wires recompute from live positions + measured heights, so they track the
|
||||
// nodes synchronously while dragging with no measure round-trip.
|
||||
const anchors = computed<Record<WorkflowNodeId, Rect>>(() => {
|
||||
const ids = Object.keys(positions.value) as WorkflowNodeId[]
|
||||
return Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
|
||||
])
|
||||
) as Record<WorkflowNodeId, Rect>
|
||||
})
|
||||
|
||||
const dragging = ref<WorkflowNodeId | null>(null)
|
||||
let drag = {
|
||||
id: '' as WorkflowNodeId,
|
||||
pointerId: -1,
|
||||
px: 0,
|
||||
py: 0,
|
||||
ox: 0,
|
||||
oy: 0
|
||||
}
|
||||
|
||||
function onPointerDown(id: WorkflowNodeId, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
drag = {
|
||||
id,
|
||||
pointerId: e.pointerId,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
ox: positions.value[id].x,
|
||||
oy: positions.value[id].y
|
||||
}
|
||||
dragging.value = id
|
||||
}
|
||||
|
||||
// A small threshold keeps clicks on buttons from registering as drags.
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragging.value == null || e.pointerId !== drag.pointerId) return
|
||||
const dx = e.clientX - drag.px
|
||||
const dy = e.clientY - drag.py
|
||||
if (Math.hypot(dx, dy) < 4) return
|
||||
positions.value[drag.id] = clampNodePosition(
|
||||
drag.id,
|
||||
{ x: drag.ox + dx / scale.value, y: drag.oy + dy / scale.value },
|
||||
heights.value[drag.id] ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = null
|
||||
}
|
||||
|
||||
// Listeners live on window so a drag continues even when the pointer outruns
|
||||
// the node; registered in onMounted to keep window off the SSR path.
|
||||
onMounted(() => {
|
||||
void nextTick(refresh)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
})
|
||||
|
||||
const wires = computed(() => computeWires(anchors.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<!-- Execution progress pinned to the top of the hero, like the real app. -->
|
||||
<div
|
||||
v-if="phase === 'running'"
|
||||
class="absolute inset-x-0 top-0 z-40"
|
||||
data-testid="hero-total-progress"
|
||||
>
|
||||
<div class="h-1 bg-white/10">
|
||||
<div
|
||||
class="bg-hero-exec h-full transition-[width] duration-100 ease-linear"
|
||||
:style="{ width: `${percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="absolute top-2.5 right-4 text-xs text-white/60 tabular-nums">
|
||||
{{ t('hero.totalProgress', locale) }}:
|
||||
<span class="font-semibold text-white">{{ percent }}%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop / large screens: a fixed design stage scaled to fit the width -->
|
||||
<div
|
||||
ref="frameRef"
|
||||
class="relative hidden aspect-1600/780 max-h-[1000px] w-full lg:block"
|
||||
>
|
||||
<div
|
||||
ref="stageRef"
|
||||
data-testid="hero-stage"
|
||||
class="absolute top-0 left-1/2 origin-top"
|
||||
:style="stageStyle"
|
||||
>
|
||||
<svg
|
||||
class="pointer-events-none absolute inset-0 size-full overflow-visible"
|
||||
:viewBox="`0 0 ${STAGE_W} ${STAGE_H}`"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="i"
|
||||
:d="wire.d"
|
||||
:stroke="wire.color"
|
||||
stroke-opacity="0.5"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<template v-for="(wire, i) in wires" :key="`d${i}`">
|
||||
<circle
|
||||
:cx="wire.from.x"
|
||||
:cy="wire.from.y"
|
||||
r="3.5"
|
||||
:fill="wire.color"
|
||||
/>
|
||||
<circle
|
||||
:cx="wire.to.x"
|
||||
:cy="wire.to.y"
|
||||
r="3.5"
|
||||
:fill="wire.color"
|
||||
/>
|
||||
</template>
|
||||
<!-- Energy pulses that flow along every wire while the workflow runs;
|
||||
idle-hidden via opacity, animated through CSS. -->
|
||||
<g :class="cn(phase === 'running' && 'hero-wire-active')">
|
||||
<path
|
||||
v-for="(wire, i) in wires"
|
||||
:key="`p${i}`"
|
||||
:d="wire.d"
|
||||
class="hero-wire-pulse"
|
||||
:stroke="wire.color"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
pathLength="1"
|
||||
stroke-dasharray="0.18 0.82"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- The headline stays beneath the nodes so a dragged node passes
|
||||
cleanly over it instead of flipping layers mid-drag. -->
|
||||
<div class="absolute top-[90px] left-[720px] -translate-x-1/2">
|
||||
<HeroHeadline :locale />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="id in NODE_IDS"
|
||||
:key="id"
|
||||
:data-node="id"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
|
||||
dragging === id && 'z-30 cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="nodeStyle(id)"
|
||||
@pointerdown="onPointerDown(id, $event)"
|
||||
>
|
||||
<HeroWorkflowNode
|
||||
:title="t(NODE_TITLE_KEYS[id], locale)"
|
||||
:state="run.nodeState(id)"
|
||||
:progress="activeNode === id ? nodeProgress : 0"
|
||||
>
|
||||
<HeroWorkflowOutput v-if="id === 'output'" :run :locale />
|
||||
<HeroNodeWidgets v-else :widgets="widgetsFor(id)" />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile / tablet: the loaders condense into a compact grid feeding the
|
||||
Save Image node, so the whole workflow still fits one screen. -->
|
||||
<div class="flex flex-col items-center px-5 pt-6 pb-10 lg:hidden">
|
||||
<HeroHeadline :locale compact />
|
||||
|
||||
<div class="mt-6 w-full max-w-sm sm:max-w-md">
|
||||
<div class="grid grid-cols-2 items-start gap-2">
|
||||
<HeroWorkflowNode
|
||||
v-for="id in ['model', 'clip', 'vae', 'lora'] as const"
|
||||
:key="id"
|
||||
:title="t(NODE_TITLE_KEYS[id], locale)"
|
||||
:state="run.nodeState(id)"
|
||||
:progress="activeNode === id ? nodeProgress : 0"
|
||||
>
|
||||
<HeroNodeWidgets :widgets="widgetsFor(id)" />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
|
||||
<HeroWorkflowNode
|
||||
class="mt-2"
|
||||
:title="t(NODE_TITLE_KEYS.seed, locale)"
|
||||
:state="run.nodeState('seed')"
|
||||
:progress="activeNode === 'seed' ? nodeProgress : 0"
|
||||
>
|
||||
<HeroNodeWidgets :widgets="widgetsFor('seed')" />
|
||||
</HeroWorkflowNode>
|
||||
|
||||
<div class="relative h-6 w-full" aria-hidden="true">
|
||||
<svg
|
||||
class="absolute inset-0 size-full"
|
||||
viewBox="0 0 100 36"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M50 3 C 50 18 50 18 50 33"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
|
||||
/>
|
||||
<span
|
||||
class="bg-hero-exec absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroWorkflowNode
|
||||
:title="t(NODE_TITLE_KEYS.output, locale)"
|
||||
:state="run.nodeState('output')"
|
||||
:progress="activeNode === 'output' ? nodeProgress : 0"
|
||||
>
|
||||
<HeroWorkflowOutput :run :locale />
|
||||
</HeroWorkflowNode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
53
apps/website/src/components/home/HeroWorkflowNode.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { NodeRunState } from './useHeroWorkflowRun'
|
||||
|
||||
const {
|
||||
title,
|
||||
state = 'idle',
|
||||
progress = 0,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
title: string
|
||||
state?: NodeRunState
|
||||
progress?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-hero-node overflow-hidden rounded-xl border shadow-xl shadow-black/30 transition-colors duration-300',
|
||||
state === 'running' ? 'border-hero-exec' : 'border-white/10',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 px-3 py-2">
|
||||
<ChevronDown class="size-3.5 shrink-0 text-white/35" />
|
||||
<span class="truncate text-[13px] font-medium text-white/85">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-white/5">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-hero-exec h-full',
|
||||
state === 'running' && 'transition-[width] duration-100 ease-linear'
|
||||
)
|
||||
"
|
||||
:style="{ width: `${state === 'running' ? progress * 100 : 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
107
apps/website/src/components/home/HeroWorkflowOutput.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight, ImagePlus, Loader2, Play, RefreshCw } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HeroNodeWidgets from './HeroNodeWidgets.vue'
|
||||
import { NODE_TITLE_KEYS } from './heroWorkflowGraph'
|
||||
import type { HeroWorkflowRun } from './useHeroWorkflowRun'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { run, locale = 'en' } = defineProps<{
|
||||
run: HeroWorkflowRun
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const filenameWidget = [
|
||||
{ name: 'filename_prefix', value: 'Krea2_turbo', kind: 'text' as const }
|
||||
]
|
||||
|
||||
const percent = computed(() => Math.round(run.totalProgress.value * 100))
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
run.activeNode.value
|
||||
? t(NODE_TITLE_KEYS[run.activeNode.value], locale)
|
||||
: t('hero.node.output', locale)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroNodeWidgets :widgets="filenameWidget" />
|
||||
|
||||
<div
|
||||
class="bg-hero-node-inset relative mt-1 aspect-square overflow-hidden rounded-lg"
|
||||
>
|
||||
<Transition name="hero-render">
|
||||
<img
|
||||
v-if="run.outputSrc.value"
|
||||
:key="run.outputSrc.value"
|
||||
:src="run.outputSrc.value"
|
||||
:alt="t('hero.output.alt', locale)"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover select-none"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="run.phase.value === 'idle'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6 text-center"
|
||||
>
|
||||
<span
|
||||
class="flex size-11 items-center justify-center rounded-full bg-white/5"
|
||||
>
|
||||
<ImagePlus class="size-5 text-white/40" />
|
||||
</span>
|
||||
<p class="max-w-52 text-sm text-white/50">
|
||||
{{ t('hero.output.hint', locale) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-hero-exec flex cursor-pointer items-center gap-2 rounded-lg px-7 py-2.5 text-sm font-semibold text-white transition-[filter] hover:brightness-110"
|
||||
@click="run.run()"
|
||||
>
|
||||
<Play class="size-4 fill-current" />
|
||||
{{ t('hero.run', locale) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="run.phase.value === 'running'"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/60"
|
||||
>
|
||||
<Loader2 class="text-hero-exec size-6 animate-spin" />
|
||||
<p class="flex items-baseline gap-2 text-sm text-white/75">
|
||||
<span>{{ statusLabel }}</span>
|
||||
<span class="font-semibold text-white tabular-nums">
|
||||
{{ percent }}%
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="run.phase.value === 'done'">
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<span class="truncate font-mono text-[11px] text-white/40 tabular-nums">
|
||||
{{ t('hero.output.seed', locale) }} {{ run.seed.value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-white/10 px-3 py-1.5 text-xs font-medium text-white/85 transition-colors hover:bg-white/15"
|
||||
@click="run.run()"
|
||||
>
|
||||
<RefreshCw class="size-3" />
|
||||
{{ t('hero.runAgain', locale) }}
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
:href="externalLinks.cloud"
|
||||
target="_blank"
|
||||
class="bg-primary-comfy-yellow mt-2 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold tracking-wide text-primary-comfy-ink uppercase transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('hero.output.openCloud', locale) }}
|
||||
<ArrowUpRight class="size-3.5" />
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
70
apps/website/src/components/home/heroWorkflowGraph.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Rect, WorkflowNodeId } from './heroWorkflowGraph'
|
||||
import {
|
||||
NODE_W,
|
||||
STAGE_H,
|
||||
STAGE_W,
|
||||
clampNodePosition,
|
||||
computeWires,
|
||||
connections,
|
||||
homePositions,
|
||||
spline
|
||||
} from './heroWorkflowGraph'
|
||||
|
||||
// Cubic command shape: "M sx sy C c1x c1y c2x c2y ex ey"
|
||||
function controlPoints(d: string) {
|
||||
const [sx, sy, c1x, c1y, c2x, c2y, ex, ey] = d
|
||||
.replace(/[MC]/g, ' ')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(Number)
|
||||
return { sx, sy, c1x, c1y, c2x, c2y, ex, ey }
|
||||
}
|
||||
|
||||
describe('spline', () => {
|
||||
it('departs and arrives horizontally for side ports even when the vertical gap dominates', () => {
|
||||
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
|
||||
spline({ x: 0, y: 200 }, { x: 120, y: 0 }, 'h')
|
||||
)
|
||||
expect(c1y).toBe(sy)
|
||||
expect(c2y).toBe(ey)
|
||||
expect(c1x).toBeGreaterThan(sx)
|
||||
expect(c2x).toBeLessThan(ex)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeWires', () => {
|
||||
const anchors = Object.fromEntries(
|
||||
(Object.keys(homePositions) as WorkflowNodeId[]).map((id) => [
|
||||
id,
|
||||
{ ...homePositions[id], w: NODE_W[id], h: 120 } satisfies Rect
|
||||
])
|
||||
) as Record<WorkflowNodeId, Rect>
|
||||
|
||||
it('produces one wire per connection with endpoints on the node edges', () => {
|
||||
const wires = computeWires(anchors)
|
||||
expect(wires).toHaveLength(connections.length)
|
||||
for (const [i, wire] of wires.entries()) {
|
||||
const from = anchors[connections[i].from]
|
||||
const to = anchors[connections[i].to]
|
||||
expect(wire.from.x).toBe(from.x + from.w)
|
||||
expect(wire.to.x).toBe(to.x)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips wires whose endpoints are not yet measured', () => {
|
||||
const { model, lora } = anchors
|
||||
expect(computeWires({ model, lora })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampNodePosition', () => {
|
||||
it('keeps nodes fully inside the stage', () => {
|
||||
const clamped = clampNodePosition('output', { x: 5000, y: -50 }, 560)
|
||||
expect(clamped).toEqual({ x: STAGE_W - NODE_W.output, y: 0 })
|
||||
expect(clampNodePosition('seed', { x: 100, y: 9999 }, 120).y).toBe(
|
||||
STAGE_H - 120
|
||||
)
|
||||
})
|
||||
})
|
||||
190
apps/website/src/components/home/heroWorkflowGraph.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
export type WorkflowNodeId =
|
||||
| 'model'
|
||||
| 'clip'
|
||||
| 'vae'
|
||||
| 'lora'
|
||||
| 'seed'
|
||||
| 'output'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Rect extends Point {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface Wire {
|
||||
d: string
|
||||
from: Point
|
||||
to: Point
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface NodeWidget {
|
||||
name: string
|
||||
value: string
|
||||
kind: 'combo' | 'number' | 'text'
|
||||
}
|
||||
|
||||
export const STAGE_W = 1600
|
||||
export const STAGE_H = 780
|
||||
|
||||
export const NODE_W: Record<WorkflowNodeId, number> = {
|
||||
model: 300,
|
||||
clip: 300,
|
||||
vae: 300,
|
||||
lora: 320,
|
||||
seed: 280,
|
||||
output: 460
|
||||
}
|
||||
|
||||
// Loaders stack on the left, the LoRA + seed chain runs under the centred
|
||||
// headline, and the Save Image node sits fully inside the right edge so
|
||||
// nothing bleeds offscreen.
|
||||
export const homePositions: Record<WorkflowNodeId, Point> = {
|
||||
model: { x: 24, y: 70 },
|
||||
clip: { x: 24, y: 280 },
|
||||
vae: { x: 24, y: 490 },
|
||||
lora: { x: 420, y: 470 },
|
||||
seed: { x: 790, y: 520 },
|
||||
output: { x: 1090, y: 48 }
|
||||
}
|
||||
|
||||
export const NODE_TITLE_KEYS = {
|
||||
model: 'hero.node.model',
|
||||
clip: 'hero.node.clip',
|
||||
vae: 'hero.node.vae',
|
||||
lora: 'hero.node.lora',
|
||||
seed: 'hero.node.seed',
|
||||
output: 'hero.node.output'
|
||||
} as const satisfies Record<WorkflowNodeId, TranslationKey>
|
||||
|
||||
export const nodeWidgets: Partial<Record<WorkflowNodeId, NodeWidget[]>> = {
|
||||
model: [
|
||||
{ name: 'unet_name', value: 'krea2_turbo_fp8_scaled', kind: 'combo' }
|
||||
],
|
||||
clip: [{ name: 'clip_name', value: 'qwen3vl_4b_fp8_scaled', kind: 'combo' }],
|
||||
vae: [{ name: 'vae_name', value: 'qwen_image_vae', kind: 'combo' }],
|
||||
lora: [
|
||||
{ name: 'lora_name', value: 'krea2_darkbrush', kind: 'combo' },
|
||||
{ name: 'strength_model', value: '0.80', kind: 'number' }
|
||||
]
|
||||
}
|
||||
|
||||
// Litegraph slot colors, so the wiring reads as the real ComfyUI canvas.
|
||||
const WIRE_COLORS = {
|
||||
model: '#b39ddb',
|
||||
clip: '#ffd500',
|
||||
vae: '#ff6e6e',
|
||||
int: '#6a8bad'
|
||||
} as const
|
||||
|
||||
type Axis = 'h' | 'v'
|
||||
type Port = (r: Rect) => Point
|
||||
|
||||
const rightPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x + r.w, y: r.y + r.h * f })
|
||||
const leftPort =
|
||||
(f = 0.5): Port =>
|
||||
(r) => ({ x: r.x, y: r.y + r.h * f })
|
||||
|
||||
function clampOffset(d: number): number {
|
||||
return Math.min(Math.max(Math.abs(d) * 0.5, 55), 120)
|
||||
}
|
||||
|
||||
// Soft cubic whose tangents follow the connected ports, so a wire between side
|
||||
// ports departs horizontally even when the vertical gap dominates.
|
||||
export function spline(s: Point, e: Point, axis: Axis): string {
|
||||
if (axis === 'h') {
|
||||
const off = Math.sign(e.x - s.x || 1) * clampOffset(e.x - s.x)
|
||||
return `M ${s.x} ${s.y} C ${s.x + off} ${s.y} ${e.x - off} ${e.y} ${e.x} ${e.y}`
|
||||
}
|
||||
const off = Math.sign(e.y - s.y || 1) * clampOffset(e.y - s.y)
|
||||
return `M ${s.x} ${s.y} C ${s.x} ${s.y + off} ${e.x} ${e.y - off} ${e.x} ${e.y}`
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
from: WorkflowNodeId
|
||||
to: WorkflowNodeId
|
||||
fromPort: Port
|
||||
toPort: Port
|
||||
axis: Axis
|
||||
color: string
|
||||
}
|
||||
|
||||
export const connections: Connection[] = [
|
||||
{
|
||||
from: 'model',
|
||||
to: 'lora',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.35),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.model
|
||||
},
|
||||
{
|
||||
from: 'clip',
|
||||
to: 'lora',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.6),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.clip
|
||||
},
|
||||
{
|
||||
from: 'lora',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.4),
|
||||
toPort: leftPort(0.14),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.model
|
||||
},
|
||||
{
|
||||
from: 'seed',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.45),
|
||||
toPort: leftPort(0.19),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.int
|
||||
},
|
||||
{
|
||||
from: 'vae',
|
||||
to: 'output',
|
||||
fromPort: rightPort(0.7),
|
||||
toPort: leftPort(0.24),
|
||||
axis: 'h',
|
||||
color: WIRE_COLORS.vae
|
||||
}
|
||||
]
|
||||
|
||||
export function computeWires(
|
||||
anchors: Partial<Record<WorkflowNodeId, Rect>>
|
||||
): Wire[] {
|
||||
return connections.flatMap((c) => {
|
||||
const fr = anchors[c.from]
|
||||
const to = anchors[c.to]
|
||||
if (!fr || !to) return []
|
||||
const from = c.fromPort(fr)
|
||||
const dest = c.toPort(to)
|
||||
return [{ from, to: dest, color: c.color, d: spline(from, dest, c.axis) }]
|
||||
})
|
||||
}
|
||||
|
||||
// Drags are confined to the stage rect so every node stops at the edge
|
||||
// instead of getting cut off.
|
||||
export function clampNodePosition(
|
||||
id: WorkflowNodeId,
|
||||
point: Point,
|
||||
height: number
|
||||
): Point {
|
||||
return {
|
||||
x: clamp(point.x, 0, STAGE_W - NODE_W[id]),
|
||||
y: clamp(point.y, 0, STAGE_H - height)
|
||||
}
|
||||
}
|
||||
27
apps/website/src/components/home/useHeroWorkflowRun.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { RENDER_COUNT, pickSeed, renderSrc } from './useHeroWorkflowRun'
|
||||
|
||||
describe('renderSrc', () => {
|
||||
it('maps indices to zero-padded webp paths', () => {
|
||||
expect(renderSrc(0)).toBe('/images/hero/renders/render-01.webp')
|
||||
expect(renderSrc(RENDER_COUNT - 1)).toBe(
|
||||
'/images/hero/renders/render-50.webp'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickSeed', () => {
|
||||
it('never lands on the previous render bucket two runs in a row', () => {
|
||||
const lastIndex = 7
|
||||
// Force a seed that collides with the last render bucket.
|
||||
const collidingRandom = () => (RENDER_COUNT + lastIndex) / 999_999_999
|
||||
const seed = pickSeed(collidingRandom, lastIndex)
|
||||
expect(seed % RENDER_COUNT).not.toBe(lastIndex)
|
||||
})
|
||||
|
||||
it('keeps the seed unchanged when there is no collision', () => {
|
||||
const seed = pickSeed(() => 0.5, null)
|
||||
expect(seed).toBe(Math.floor(0.5 * 999_999_999))
|
||||
})
|
||||
})
|
||||
124
apps/website/src/components/home/useHeroWorkflowRun.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkflowNodeId } from './heroWorkflowGraph'
|
||||
|
||||
export const RENDER_COUNT = 50
|
||||
|
||||
export function renderSrc(index: number): string {
|
||||
return `/images/hero/renders/render-${String(index + 1).padStart(2, '0')}.webp`
|
||||
}
|
||||
|
||||
// The seed is what the user sees; the render shown is seed % RENDER_COUNT.
|
||||
// Nudging the seed when it lands on the previous bucket guarantees a fresh
|
||||
// image on every consecutive run.
|
||||
export function pickSeed(
|
||||
random: () => number,
|
||||
lastIndex: number | null
|
||||
): number {
|
||||
const seed = Math.floor(random() * 999_999_999)
|
||||
return seed % RENDER_COUNT === lastIndex ? seed + 1 : seed
|
||||
}
|
||||
|
||||
// Fake execution timeline: loaders warm up quickly, then the sampler carries
|
||||
// most of the run — mirroring how the real workflow feels in ComfyUI.
|
||||
const RUN_STEPS: { id: WorkflowNodeId; duration: number }[] = [
|
||||
{ id: 'model', duration: 600 },
|
||||
{ id: 'clip', duration: 450 },
|
||||
{ id: 'vae', duration: 400 },
|
||||
{ id: 'lora', duration: 550 },
|
||||
{ id: 'seed', duration: 300 },
|
||||
{ id: 'output', duration: 1700 }
|
||||
]
|
||||
|
||||
const TOTAL_DURATION = RUN_STEPS.reduce((sum, s) => sum + s.duration, 0)
|
||||
|
||||
export type RunPhase = 'idle' | 'running' | 'done'
|
||||
export type NodeRunState = 'idle' | 'running' | 'done'
|
||||
|
||||
export function useHeroWorkflowRun() {
|
||||
const phase = ref<RunPhase>('idle')
|
||||
const seed = ref(52)
|
||||
const activeNode = ref<WorkflowNodeId | null>(null)
|
||||
const nodeProgress = ref(0)
|
||||
const totalProgress = ref(0)
|
||||
const outputSrc = ref<string | null>(null)
|
||||
|
||||
let elapsed = 0
|
||||
let pendingIndex: number | null = null
|
||||
let imageReady = false
|
||||
|
||||
const { pause, resume } = useRafFn(({ delta }) => advance(delta), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
function advance(delta: number) {
|
||||
// Cap long frames (background tab) so the run never skips visibly.
|
||||
elapsed += Math.min(delta, 100)
|
||||
let start = 0
|
||||
for (const step of RUN_STEPS) {
|
||||
if (elapsed < start + step.duration) {
|
||||
activeNode.value = step.id
|
||||
nodeProgress.value = (elapsed - start) / step.duration
|
||||
totalProgress.value = elapsed / TOTAL_DURATION
|
||||
return
|
||||
}
|
||||
start += step.duration
|
||||
}
|
||||
if (!imageReady) {
|
||||
// Hold just short of done until the render finishes downloading.
|
||||
activeNode.value = 'output'
|
||||
nodeProgress.value = 0.96
|
||||
totalProgress.value = 0.96
|
||||
return
|
||||
}
|
||||
pause()
|
||||
phase.value = 'done'
|
||||
activeNode.value = null
|
||||
nodeProgress.value = 0
|
||||
totalProgress.value = 1
|
||||
outputSrc.value = pendingIndex === null ? null : renderSrc(pendingIndex)
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (phase.value === 'running') return
|
||||
const nextSeed = pickSeed(Math.random, pendingIndex)
|
||||
seed.value = nextSeed
|
||||
pendingIndex = nextSeed % RENDER_COUNT
|
||||
imageReady = false
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
imageReady = true
|
||||
}
|
||||
image.onerror = () => {
|
||||
imageReady = true
|
||||
}
|
||||
image.src = renderSrc(pendingIndex)
|
||||
elapsed = 0
|
||||
totalProgress.value = 0
|
||||
phase.value = 'running'
|
||||
resume()
|
||||
}
|
||||
|
||||
function nodeState(id: WorkflowNodeId): NodeRunState {
|
||||
if (activeNode.value === id) return 'running'
|
||||
if (phase.value === 'done') return 'done'
|
||||
if (phase.value !== 'running') return 'idle'
|
||||
const activeIndex = RUN_STEPS.findIndex((s) => s.id === activeNode.value)
|
||||
const index = RUN_STEPS.findIndex((s) => s.id === id)
|
||||
return index < activeIndex ? 'done' : 'idle'
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
seed,
|
||||
activeNode,
|
||||
nodeProgress,
|
||||
totalProgress,
|
||||
outputSrc,
|
||||
run,
|
||||
nodeState
|
||||
}
|
||||
}
|
||||
|
||||
export type HeroWorkflowRun = ReturnType<typeof useHeroWorkflowRun>
|
||||
@@ -53,6 +53,32 @@ const translations = {
|
||||
en: 'Run your first workflow',
|
||||
'zh-CN': '运行你的第一个工作流'
|
||||
},
|
||||
'hero.cta.cloud': {
|
||||
en: 'Try it in ComfyUI Cloud',
|
||||
'zh-CN': '在 ComfyUI Cloud 体验'
|
||||
},
|
||||
'hero.node.model': { en: 'Load Diffusion Model', 'zh-CN': '加载扩散模型' },
|
||||
'hero.node.clip': { en: 'Load CLIP', 'zh-CN': '加载 CLIP' },
|
||||
'hero.node.vae': { en: 'Load VAE', 'zh-CN': '加载 VAE' },
|
||||
'hero.node.lora': { en: 'Load LoRA', 'zh-CN': '加载 LoRA' },
|
||||
'hero.node.seed': { en: 'Seed', 'zh-CN': '种子' },
|
||||
'hero.node.output': { en: 'Save Image', 'zh-CN': '保存图像' },
|
||||
'hero.run': { en: 'Run', 'zh-CN': '运行' },
|
||||
'hero.runAgain': { en: 'Run again', 'zh-CN': '再次运行' },
|
||||
'hero.totalProgress': { en: 'Total', 'zh-CN': '总进度' },
|
||||
'hero.output.hint': {
|
||||
en: 'Press Run to generate an image with a fresh seed',
|
||||
'zh-CN': '按下运行,用全新种子生成一张图像'
|
||||
},
|
||||
'hero.output.seed': { en: 'seed', 'zh-CN': '种子' },
|
||||
'hero.output.alt': {
|
||||
en: 'Generated image: a hand holding a martini glass surrounded by playful ink-sketch cartoon characters',
|
||||
'zh-CN': '生成的图像:手持马提尼酒杯,周围环绕着俏皮的手绘卡通角色'
|
||||
},
|
||||
'hero.output.openCloud': {
|
||||
en: 'Open in ComfyUI Cloud',
|
||||
'zh-CN': '在 ComfyUI Cloud 中打开'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
'showcase.subtitle1': {
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
--color-transparency-ink-t80: rgb(33 25 39 / 0.8);
|
||||
--color-hero-node: #1f2026;
|
||||
--color-hero-node-inset: #16171c;
|
||||
--color-hero-exec: #3d7eff;
|
||||
--font-formula: 'PP Formula', sans-serif;
|
||||
--font-formula-narrow: 'PP Formula Narrow', sans-serif;
|
||||
--text-3\.5xl: 2rem;
|
||||
@@ -224,6 +227,62 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ComfyUI-style canvas dot grid behind the hero workflow. */
|
||||
.hero-dot-grid {
|
||||
background-image: radial-gradient(
|
||||
rgb(255 255 255 / 0.07) 1px,
|
||||
transparent 1.5px
|
||||
);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
|
||||
/* Workflow connectors: a short bright dash flows along each wire while the
|
||||
workflow runs. path-length is normalized to 1 so the dash travels at a
|
||||
consistent rate regardless of wire length. */
|
||||
.hero-wire-pulse {
|
||||
stroke-dashoffset: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-wire-active .hero-wire-pulse {
|
||||
opacity: 0.9;
|
||||
animation: hero-wire-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hero-wire-flow {
|
||||
from {
|
||||
stroke-dashoffset: 1;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Freshly generated renders sharpen into place; the previous render fades
|
||||
beneath. Reduced-motion users get a near-instant swap via the global
|
||||
override below. */
|
||||
.hero-render-enter-active {
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
filter 0.5s ease;
|
||||
}
|
||||
|
||||
.hero-render-enter-from {
|
||||
opacity: 0;
|
||||
filter: blur(12px);
|
||||
}
|
||||
|
||||
.hero-render-leave-active {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.hero-render-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||