Compare commits

...

10 Commits

Author SHA1 Message Date
Marwan Ahmed
417632504c feat(website): swap hero output to real renders when available
The output frame now layers a real ComfyUI render for the selected
preset/light combo over the base image, with the REMIX slider acting as
a strength blend. The CSS grade drops to a subtle preview layer whenever
a render exists and remains the fallback for combos without one.
Renders are listed in heroGraphData (bakedRenderKeys) and preloaded per
active input; the meta bar credits 'Rendered with ComfyUI' when one is
showing. Variant selection moved into useHeroControls as shared state.
2026-07-05 02:09:33 +03:00
Marwan Ahmed
93cc41d233 fix(website): center hero headline, fit graph on screen, perf pass
- Centre the headline on the stage midpoint so it aligns with the screen,
  not the left-shifted graph
- Re-author node positions so the OUTPUT sits fully inside the stage; the
  scale-to-fit stage now keeps every node on screen at all desktop sizes
- Drag nodes via compositor-only translate3d instead of left/top layout
- Remove the animated SVG goo filter (continuous rasterization cost) in
  favour of static headline pills
- Drop per-node backdrop-blur
- Add a ComfyUI-style canvas dot grid and node title-bar status dots
2026-07-05 01:06:31 +03:00
Marwan Ahmed
13f4d3ac72 fix(website): apply hero graph review feedback
- Clamp node drags to an invisible stage bounding box so nodes hit the
  edge instead of getting cut off (OUTPUT keeps its home right-edge bleed)
- Keep dragged nodes consistently above the headline so layers no longer
  flip mid-drag over the title and subtitle
- Remove the yellow glow shadows on node hover/active and the OUTPUT live rim
- Remove the animated specular sheen sweeping across the output image
- Remove the cursor rollover that let the light drift toward the pointer
  on the output image
2026-07-04 22:27:22 +03:00
Marwan Mostafa
619d6d3bf9 feat(website): interactive color + lighting nodes drive the live hero output
Fill the empty Color Remixer and Lighting nodes with real controls (swatches,
mood presets, intensity sliders, a light-direction pad) that grade the OUTPUT
in real time via CSS overlays and filters. Add a live metadata bar, active node
glow, and connectors that pulse toward the output when a node is engaged.

Rework the mobile hero into a compact connected graph that fits one screen:
IMAGE forks into Color + Lighting which merge into the live output. Slow the
headline liquid link and respect prefers-reduced-motion throughout.
2026-06-30 13:41:57 +03:00
Marwan Mostafa
f6099e418b feat(website): tighten mobile hero to lead with output and remix picker
Collapse the mobile hero into a single OUTPUT card that leads with the
result and keeps the input thumbnails directly beneath it, so a tap swaps
the visible output with no scroll. Add a remix hint and thin the headline's
liquid connector.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
95d5127d5b feat(website): drag hero nodes and animate the split headline
- Make graph nodes draggable with live-routed wires; suppress the native
  image drag-out on the IMAGE node while keeping its picker clickable
- Rewire inputs into an image->texture->color->lighting->output chain and
  route side-port wires horizontally; extract the wire geometry into a
  tested heroGraphWires module
- Split the headline into two highlighter lines joined by a liquid link
  that glides side to side through the goo filter
- Nudge the whole graph left so the OUTPUT node bleeds less to the right
2026-06-30 13:41:56 +03:00
Marwan Mostafa
7a145d2fc6 fix(website): liquid headline, smoother wires, less output bleed
Move the gooey liquid effect onto the headline so the two highlighter blocks
merge into one liquid shape (crisp text layered over a goo-filtered yellow
backing); the IMAGE->TEXTURE link is a plain wire again. Cap the wire control
offsets so the splines stay smooth without twirling. Pull the OUTPUT node in so
most of it stays on screen, and keep the headline optically centered.
2026-06-30 13:41:56 +03:00
Marwan Mostafa
a7cc2f87bf fix(website): rebalance hero layout, center headline, liquid links
Fill the viewport width on large screens instead of centering with margins, so
the IMAGE node sits near the left edge and the OUTPUT bleeds off the right
(no more right-leaning gap). Widen the design stage and move the headline into
the clear gap between IMAGE and OUTPUT, with the COLOR/LIGHTING placeholders
dropped to the lower band so nothing overlaps the copy. Optically center the
headline text inside the highlighter blocks. Replace the wiggly bezier wires
with tangent-aware S-curves plus endpoint dots, and render the IMAGE->TEXTURE
connection as a gooey liquid link.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
969e32afc1 feat(website): make hero responsive across all screens
Lay the desktop node graph out in a fixed 1440x760 design stage that scales
as a single unit to fit any width, so nodes and wires never collide and the
OUTPUT bleed is preserved proportionally from 1024px up. Wires are measured in
design coordinates via layout offsets, independent of the scale transform.
Extract the headline into HeroHeadline (shared by both layouts) and give phones
and tablets a centered, width-capped stacked column.
2026-06-30 13:41:55 +03:00
Marwan Mostafa
949af59c4f feat(website): interactive node-graph hero
Replace the homepage hero with a free-floating node-graph behind a yellow
highlighter headline. The IMAGE node is a hover-driven image picker: hovering
(or focusing) a thumbnail crossfades the large input and cascades the matching
result through the OUTPUT node, with a smooth chromatic transition.

- New HeroGraph/HeroGraphNode/HeroImagePicker components and heroGraphData
- SVG bezier wires anchored to measured node rects, recomputed on resize
- Per-variant input/output image pairs; graceful stacked mobile layout
- Remove the old canvas hero (useHeroLogo) and the unused three dependency
- e2e coverage for hover/keyboard selection and the output cascade
2026-06-30 13:41:55 +03:00
28 changed files with 1885 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
import { fileURLToPath } from 'node:url'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -153,6 +154,121 @@ test.describe('Product showcase accordion @interaction', () => {
})
})
test.describe('Hero image picker @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
// Exclude the glitch swap's outgoing image, which lingers in the DOM until
// the transition settles, so the locator always resolves to the current one.
const activeImage = (page: Page) =>
page.locator(
'[data-testid="hero-active-image"]:visible:not(.hero-glitch-leave-active)'
)
const outputImage = (page: Page) =>
page.locator(
'[data-testid="hero-output-image"]:visible:not(.hero-glitch-leave-active)'
)
test('defaults to the portrait variant with its thumbnail selected', async ({
page
}) => {
await expect(activeImage(page)).toHaveAttribute(
'src',
/input-portrait\.png/
)
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-cyberpunk\.png/
)
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'true')
})
test('hovering a thumbnail swaps the large image and moves selection', async ({
page
}) => {
const deerThumb = page.getByRole('button', { name: /deer/i })
await deerThumb.hover()
await expect(activeImage(page)).toHaveAttribute('src', /input-deer\.png/)
await expect(deerThumb).toHaveAttribute('aria-pressed', 'true')
await expect(
page.getByRole('button', { name: /portrait/i })
).toHaveAttribute('aria-pressed', 'false')
})
test('selecting an input cascades the matching output', async ({ page }) => {
await page.getByRole('button', { name: /vase/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-vase\.png/
)
await page.getByRole('button', { name: /deer/i }).click()
await expect(outputImage(page).first()).toHaveAttribute(
'src',
/output-deer\.png/
)
})
test('thumbnails are keyboard operable', async ({ page }) => {
const mirrorThumb = page.getByRole('button', { name: /mirror/i })
await mirrorThumb.focus()
await page.keyboard.press('Enter')
await expect(activeImage(page)).toHaveAttribute('src', /input-mirror\.png/)
await expect(mirrorThumb).toHaveAttribute('aria-pressed', 'true')
})
})
test.describe('Hero graph drag bounds @interaction', () => {
async function dragNodeTo(page: Page, node: Locator, x: number, y: number) {
const box = (await node.boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(x, y, { steps: 15 })
await page.mouse.up()
}
test('a dragged node stops at the stage edges instead of escaping', async ({
page,
viewport
}) => {
await page.goto('/')
const node = page.locator('[data-node="texture"]')
await expect(node).toBeVisible()
const stageBox = (await page.getByTestId('hero-stage').boundingBox())!
await dragNodeTo(page, node, 1, viewport!.height - 1)
await expect
.poll(async () => {
const box = (await node.boundingBox())!
return {
atLeftEdge: Math.abs(box.x - stageBox.x) <= 2,
atBottomEdge:
Math.abs(box.y + box.height - (stageBox.y + stageBox.height)) <= 2
}
})
.toEqual({ atLeftEdge: true, atBottomEdge: true })
await dragNodeTo(page, node, viewport!.width - 1, 1)
await expect
.poll(async () => {
const box = (await node.boundingBox())!
return {
atRightEdge:
Math.abs(box.x + box.width - (stageBox.x + stageBox.width)) <= 2,
atTopEdge: Math.abs(box.y - stageBox.y) <= 2
}
})
.toEqual({ atRightEdge: true, atTopEdge: true })
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(

View File

@@ -34,7 +34,6 @@
"lenis": "catalog:",
"posthog-js": "catalog:",
"reka-ui": "catalog:",
"three": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { colorPresets, colorSwatches } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { colorPresetId, swatchId, colorIntensity } = controls
const remixLabel = computed(
() => `${t('hero.color.remix', locale)} ${colorIntensity.value}%`
)
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
class="flex gap-1.5"
role="group"
:aria-label="t('hero.color.palette', locale)"
>
<button
v-for="s in colorSwatches"
:key="s.id"
type="button"
:aria-pressed="s.id === swatchId"
:aria-label="t(s.labelKey, locale)"
:style="{ backgroundColor: `rgb(${s.rgb})` }"
:class="
cn(
'size-5 rounded-full ring-1 ring-white/20 transition-transform ring-inset',
s.id === swatchId
? 'scale-110 ring-2 ring-white/80'
: 'opacity-75 hover:opacity-100'
)
"
@pointerdown.stop
@click.stop="swatchId = s.id"
/>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="p in colorPresets"
:key="p.id"
type="button"
:aria-pressed="p.id === colorPresetId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
p.id === colorPresetId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="colorPresetId = p.id"
>
{{ t(p.labelKey, locale) }}
</button>
</div>
<label class="flex flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ remixLabel }}
</span>
<input
v-model.number="colorIntensity"
type="range"
min="0"
max="100"
:aria-label="remixLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,418 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useResizeObserver } from '@vueuse/core'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import HeroColorNode from './HeroColorNode.vue'
import HeroGraphNode from './HeroGraphNode.vue'
import HeroHeadline from './HeroHeadline.vue'
import HeroImagePicker from './HeroImagePicker.vue'
import HeroLightingNode from './HeroLightingNode.vue'
import HeroOutputFrame from './HeroOutputFrame.vue'
import { imageVariants, textureImage } from './heroGraphData'
import {
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
homePositions
} from './heroGraphLayout'
import { computeWires } from './heroGraphWires'
import type { NodeId, Point, Rect } from './heroGraphWires'
import { useHeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const controls = useHeroControls()
const { activeNode, variantId } = controls
// 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<NodeId, 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: NodeId) {
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<NodeId, Rect>>(() => {
const ids = Object.keys(positions.value) as NodeId[]
return Object.fromEntries(
ids.map((id) => [
id,
{ ...positions.value[id], w: NODE_W[id], h: heights.value[id] ?? 0 }
])
) as Record<NodeId, Rect>
})
const dragging = ref<NodeId | null>(null)
let drag = { id: '' as NodeId, pointerId: -1, px: 0, py: 0, ox: 0, oy: 0 }
function onPointerDown(id: NodeId, 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 taps on the image picker 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))
const dots = computed<{ p: Point; accent: boolean }[]>(() =>
wires.value.flatMap((w) => [
{ p: w.from, accent: w.accent },
{ p: w.to, accent: w.accent }
])
)
</script>
<template>
<div class="relative w-full">
<!-- 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.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.16)'
"
:stroke-width="wire.accent ? 2 : 1.5"
stroke-linecap="round"
/>
<circle
v-for="(dot, i) in dots"
:key="`d${i}`"
:cx="dot.p.x"
:cy="dot.p.y"
:r="dot.accent ? 4 : 3"
:fill="
dot.accent
? 'var(--color-primary-comfy-yellow)'
: 'rgba(255,255,255,0.3)'
"
/>
<!-- Energy pulses that flow toward the OUTPUT while a control node is
engaged; idle-hidden via opacity, animated through CSS. -->
<g :class="cn(activeNode && 'hero-wire-active')">
<path
v-for="(wire, i) in wires"
:key="`p${i}`"
:d="wire.d"
class="hero-wire-pulse"
stroke="var(--color-primary-comfy-yellow)"
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 window passes
cleanly over it instead of flipping layers mid-drag. -->
<div class="absolute top-[140px] left-[800px] -translate-x-1/2">
<HeroHeadline :locale />
</div>
<div
data-node="image"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'image' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('image')"
@pointerdown="onPointerDown('image', $event)"
>
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="variantId"
:locale
@select="(id) => (variantId = id)"
/>
</HeroGraphNode>
</div>
<div
data-node="texture"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'texture' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('texture')"
@pointerdown="onPointerDown('texture', $event)"
>
<HeroGraphNode :label="t('hero.node.texture', locale)" accent>
<div class="aspect-square w-full overflow-hidden rounded-xl">
<img
:src="textureImage.src"
:alt="t(textureImage.altKey, locale)"
draggable="false"
class="size-full object-cover"
/>
</div>
</HeroGraphNode>
</div>
<div
data-node="color"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'color' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('color')"
@pointerdown="onPointerDown('color', $event)"
@pointerenter="activeNode = 'color'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.color', locale)"
:active="activeNode === 'color'"
>
<HeroColorNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="lighting"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'lighting' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('lighting')"
@pointerdown="onPointerDown('lighting', $event)"
@pointerenter="activeNode = 'lighting'"
@pointerleave="activeNode = null"
>
<HeroGraphNode
:label="t('hero.node.lighting', locale)"
:active="activeNode === 'lighting'"
>
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<div
data-node="output"
:class="
cn(
'absolute top-0 left-0 cursor-grab touch-none will-change-transform select-none active:cursor-grabbing',
dragging === 'output' && 'z-30 cursor-grabbing'
)
"
:style="nodeStyle('output')"
@pointerdown="onPointerDown('output', $event)"
>
<HeroGraphNode :label="t('hero.node.output', locale)">
<HeroOutputFrame :controls :locale class="aspect-square" />
</HeroGraphNode>
</div>
</div>
</div>
<!-- Mobile / tablet: a compact connected graph that fits one screen the
IMAGE selector forks into COLOR + LIGHTING, which merge into the live
OUTPUT. Connectors are decorative SVGs aligned to the 2-column grid. -->
<div class="flex flex-col items-center px-5 pt-3 pb-8 lg:hidden">
<HeroHeadline :locale compact />
<div class="mt-3 w-full max-w-sm sm:max-w-md">
<HeroGraphNode :label="t('hero.node.image', locale)" accent>
<HeroImagePicker
:variants="imageVariants"
:active-id="variantId"
:locale
hide-preview
thumb-class="h-14"
@select="(id) => (variantId = id)"
/>
</HeroGraphNode>
<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 22 25 14 25 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M50 3 C 50 22 75 14 75 34"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span
class="bg-primary-comfy-yellow absolute top-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
<span
class="absolute bottom-0 left-1/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute bottom-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
</div>
<div class="grid grid-cols-2 items-stretch gap-2">
<HeroGraphNode :label="t('hero.node.color', locale)">
<HeroColorNode :controls :locale />
</HeroGraphNode>
<HeroGraphNode :label="t('hero.node.lighting', locale)">
<HeroLightingNode :controls :locale />
</HeroGraphNode>
</div>
<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="M25 2 C 25 18 50 14 50 33"
stroke="rgba(255,255,255,0.22)"
stroke-width="1.5"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
<path
d="M75 2 C 75 18 50 14 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/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="absolute top-0 left-3/4 size-1.5 -translate-x-1/2 rounded-full bg-white/40"
/>
<span
class="bg-primary-comfy-yellow absolute bottom-0 left-1/2 size-1.5 -translate-x-1/2 rounded-full"
/>
</div>
<HeroGraphNode :label="t('hero.node.output', locale)" accent>
<HeroOutputFrame :controls :locale class="h-[150px]" />
</HeroGraphNode>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
const {
label,
accent = false,
active = false,
class: customClass = ''
} = defineProps<{
label: string
accent?: boolean
active?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'bg-transparency-ink-t80 rounded-2xl border p-3 transition-colors duration-500 ease-out',
accent ? 'border-primary-comfy-yellow' : 'border-white/10',
active && 'border-primary-comfy-yellow/70',
customClass
)
"
>
<span
class="text-primary-warm-gray flex items-center gap-1.5 pl-1 text-[0.7rem] font-medium tracking-[0.22em] uppercase"
>
<span
:class="
cn(
'size-1.5 rounded-full',
accent || active ? 'bg-primary-comfy-yellow' : 'bg-white/25'
)
"
/>
{{ label }}
</span>
<div class="mt-2">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
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 hidden text-sm/relaxed sm:block' : 'mt-8 text-base'
)
"
>
{{ t('hero.subtitle', locale) }}
</p>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { ImageVariant } from './heroGraphData'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
variants,
activeId,
locale = 'en',
previewSrc,
previewAlt,
previewTestId = 'hero-active-image',
hint,
hidePreview = false,
thumbClass = 'aspect-square'
} = defineProps<{
variants: readonly ImageVariant[]
activeId: string
locale?: Locale
// Override the large preview with the result the active input produces, so the
// mobile card can lead with the OUTPUT while the thumbnails stay the selector.
previewSrc?: string
previewAlt?: string
previewTestId?: string
hint?: string
// Render only the thumbnail strip, e.g. beneath a standalone output frame.
hidePreview?: boolean
// Sizing for each thumbnail; defaults to square, overridable for tight rows.
thumbClass?: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const active = computed(
() => variants.find((v) => v.id === activeId) ?? variants[0]
)
const preview = computed(() => ({
src: previewSrc ?? active.value.src,
alt: previewAlt ?? t(active.value.altKey, locale)
}))
// Hover (mouse) and focus (keyboard) both swap the active variant. Click stays
// as the activation path for touch, where there is no hover.
function selectOnHover(id: string, e: PointerEvent) {
if (e.pointerType === 'mouse') emit('select', id)
}
</script>
<template>
<div>
<div
v-if="!hidePreview"
class="relative aspect-square w-full overflow-hidden rounded-xl"
>
<Transition name="hero-glitch">
<img
:key="preview.src"
:src="preview.src"
:alt="preview.alt"
:data-testid="previewTestId"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<p v-if="hint" class="text-primary-warm-gray pl-1 text-xs/relaxed">
{{ hint }}
</p>
<div
class="mt-2 flex gap-2"
role="group"
:aria-label="t('hero.image.pickerLabel', locale)"
>
<button
v-for="variant in variants"
:key="variant.id"
type="button"
:aria-pressed="variant.id === activeId"
:aria-label="t(variant.altKey, locale)"
:class="
cn(
'focus-visible:outline-primary-comfy-yellow relative flex-1 overflow-hidden rounded-md transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2',
thumbClass,
variant.id === activeId
? 'ring-primary-comfy-yellow opacity-100 ring-2'
: 'opacity-50 hover:opacity-90'
)
"
@pointerenter="selectOnHover(variant.id, $event)"
@focus="emit('select', variant.id)"
@click="emit('select', variant.id)"
>
<img
:src="variant.src"
alt=""
aria-hidden="true"
draggable="false"
class="size-full object-cover"
/>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref } from 'vue'
import { lightModes } from './useHeroControls'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { controls, locale = 'en' } = defineProps<{
controls: HeroControls
locale?: Locale
}>()
const { lightModeId, lightIntensity, lightDir, setLightFromUnit } = controls
const intensityLabel = computed(
() => `${t('hero.light.intensity', locale)} ${lightIntensity.value}%`
)
const dotStyle = computed(() => ({
left: `${lightDir.value.x * 100}%`,
top: `${lightDir.value.y * 100}%`
}))
const pad = ref<HTMLElement>()
const dragging = ref(false)
function applyFromEvent(e: PointerEvent) {
const el = pad.value
if (!el) return
const r = el.getBoundingClientRect()
setLightFromUnit(
(e.clientX - r.left) / r.width,
(e.clientY - r.top) / r.height
)
}
function onDown(e: PointerEvent) {
dragging.value = true
pad.value?.setPointerCapture(e.pointerId)
applyFromEvent(e)
}
function onMove(e: PointerEvent) {
if (dragging.value) applyFromEvent(e)
}
function onUp(e: PointerEvent) {
dragging.value = false
pad.value?.releasePointerCapture?.(e.pointerId)
}
</script>
<template>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3">
<div
ref="pad"
class="relative size-12 shrink-0 touch-none rounded-full bg-white/5 ring-1 ring-white/10 ring-inset"
:aria-label="t('hero.light.direction', locale)"
@pointerdown.stop.prevent="onDown"
@pointermove.stop="onMove"
@pointerup.stop="onUp"
@pointercancel.stop="onUp"
>
<div
class="hero-light-glow pointer-events-none absolute inset-0 rounded-full"
/>
<div
class="bg-primary-comfy-yellow pointer-events-none absolute size-3 -translate-1/2 rounded-full shadow-[0_0_10px_2px_rgb(242_255_89/0.6)]"
:style="dotStyle"
/>
</div>
<label class="flex flex-1 flex-col gap-1">
<span
class="text-primary-warm-gray text-[0.6rem] font-medium tracking-wide uppercase"
>
{{ intensityLabel }}
</span>
<input
v-model.number="lightIntensity"
type="range"
min="0"
max="100"
:aria-label="intensityLabel"
class="accent-primary-comfy-yellow h-1 w-full cursor-pointer"
@pointerdown.stop
@click.stop
/>
</label>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
v-for="m in lightModes"
:key="m.id"
type="button"
:aria-pressed="m.id === lightModeId"
:class="
cn(
'rounded-md px-1.5 py-1 text-[0.6rem] font-medium uppercase transition-colors',
m.id === lightModeId
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-white/5 text-primary-comfy-canvas hover:bg-white/10'
)
"
@pointerdown.stop
@click.stop="lightModeId = m.id"
>
{{ t(m.labelKey, locale) }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import type { HeroControls } from './useHeroControls'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const {
controls,
locale = 'en',
class: customClass = ''
} = defineProps<{
controls: HeroControls
locale?: Locale
class?: HTMLAttributes['class']
}>()
const {
variant,
bakedRender,
colorIntensity,
outputFilter,
colorLayerStyle,
lightLayerStyle,
lightMode
} = controls
const metaText = computed(() => {
const grade = bakedRender.value
? t('hero.output.rendered', locale)
: t('hero.output.grade', locale)
return `${grade} · ${t('hero.output.colorActive', locale)} · ${t(lightMode.value.labelKey, locale)} ${t('hero.output.lightingSuffix', locale)}`
})
</script>
<template>
<div :class="cn('relative w-full overflow-hidden rounded-xl', customClass)">
<div class="relative size-full" :style="{ filter: outputFilter }">
<Transition name="hero-glitch">
<img
:key="variant.output.src"
:src="variant.output.src"
:alt="t(variant.output.altKey, locale)"
data-testid="hero-output-image"
draggable="false"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
<!-- Real ComfyUI render for the selected combo; the REMIX slider blends
it over the base output like a strength control. -->
<Transition name="hero-glitch">
<img
v-if="bakedRender"
:key="bakedRender.src"
:src="bakedRender.src"
alt=""
aria-hidden="true"
data-testid="hero-baked-render"
draggable="false"
class="absolute inset-0 size-full object-cover"
:style="{ opacity: colorIntensity / 100 }"
/>
</Transition>
</div>
<div
class="pointer-events-none absolute inset-0 transition-opacity duration-500"
:style="colorLayerStyle"
/>
<div
class="pointer-events-none absolute inset-0 mix-blend-screen transition-opacity duration-500"
:style="lightLayerStyle"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-linear-to-t from-black/70 to-transparent px-3 pt-6 pb-2"
>
<span class="truncate font-mono text-[0.6rem] text-white/70">
{{ metaText }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span
class="hero-live-dot bg-primary-comfy-yellow size-1.5 rounded-full"
/>
<span
class="text-[0.6rem] font-medium tracking-wide whitespace-nowrap text-white/70 uppercase"
>
{{ t('hero.output.live', locale) }}
</span>
</span>
</div>
</div>
</template>

View File

@@ -1,55 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import HeroGraph from './HeroGraph.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">
<HeroGraph :locale />
</section>
</template>

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { resolveBakedRender } from './heroGraphData'
describe('resolveBakedRender', () => {
const keys = new Set([
'portrait/cyberpunk',
'portrait/cyberpunk/neon',
'vase/film'
])
it('prefers the exact preset + light-mode render', () => {
expect(resolveBakedRender('portrait', 'cyberpunk', 'neon', keys)).toEqual({
src: '/images/hero/output-portrait-cyberpunk-neon.webp',
includesLight: true
})
})
it('falls back to the preset-only render for unlisted light modes', () => {
expect(resolveBakedRender('portrait', 'cyberpunk', 'rim', keys)).toEqual({
src: '/images/hero/output-portrait-cyberpunk.webp',
includesLight: false
})
})
it('returns null when no render exists for the combo', () => {
expect(resolveBakedRender('deer', 'dream', 'soft', keys)).toBeNull()
expect(resolveBakedRender('vase', 'cyberpunk', 'neon', keys)).toBeNull()
})
})

View File

@@ -0,0 +1,98 @@
import type { TranslationKey } from '../../i18n/translations'
interface NodeImage {
src: string
altKey: TranslationKey
}
export interface ImageVariant extends NodeImage {
id: string
// Names the input in baked-render filenames (output-<key>-<preset>….webp).
key: string
// The result this input produces, shown in the OUTPUT node. Swapping the
// input cascades its matching output through the graph.
output: NodeImage
}
export const imageVariants = [
{
id: 'v1',
key: 'portrait',
src: '/images/hero/input-portrait.png',
altKey: 'hero.image.variant1',
output: {
src: '/images/hero/output-cyberpunk.png',
altKey: 'hero.output.variant1'
}
},
{
id: 'v2',
key: 'vase',
src: '/images/hero/input-vase.png',
altKey: 'hero.image.variant2',
output: {
src: '/images/hero/output-vase.png',
altKey: 'hero.output.variant2'
}
},
{
id: 'v3',
key: 'deer',
src: '/images/hero/input-deer.png',
altKey: 'hero.image.variant3',
output: {
src: '/images/hero/output-deer.png',
altKey: 'hero.output.variant3'
}
},
{
id: 'v4',
key: 'mirror',
src: '/images/hero/input-mirror.png',
altKey: 'hero.image.variant4',
output: {
src: '/images/hero/output-mirror.png',
altKey: 'hero.output.variant4'
}
}
] as const satisfies readonly ImageVariant[]
export const textureImage: NodeImage = {
src: '/images/hero/input-vase.png',
altKey: 'hero.image.texture'
}
// Real ComfyUI renders for control combinations, listed as they are generated
// and dropped into public/images/hero/. Keys are `<variant>/<preset>` or
// `<variant>/<preset>/<lightMode>`; the matching file is
// `output-<variant>-<preset>[-<lightMode>].webp`. Combos not listed here fall
// back to the base output with the CSS preview grade.
const bakedRenderKeys: ReadonlySet<string> = new Set()
export interface BakedRender {
src: string
// True when the render already contains the selected light mode, so the CSS
// light overlay only needs to hint at direction instead of faking the look.
includesLight: boolean
}
export function resolveBakedRender(
variantKey: string,
presetId: string,
lightModeId: string,
keys: ReadonlySet<string> = bakedRenderKeys
): BakedRender | null {
if (keys.has(`${variantKey}/${presetId}/${lightModeId}`)) {
return {
src: `/images/hero/output-${variantKey}-${presetId}-${lightModeId}.webp`,
includesLight: true
}
}
if (keys.has(`${variantKey}/${presetId}`)) {
return {
src: `/images/hero/output-${variantKey}-${presetId}.webp`,
includesLight: false
}
}
return null
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
NODE_W,
STAGE_H,
STAGE_W,
clampNodePosition,
homePositions
} from './heroGraphLayout'
describe('clampNodePosition', () => {
it('leaves positions inside the stage untouched', () => {
expect(clampNodePosition('color', { x: 400, y: 300 }, 180)).toEqual({
x: 400,
y: 300
})
})
it('stops nodes at the stage edges instead of letting them escape', () => {
expect(clampNodePosition('color', { x: -80, y: -50 }, 180)).toEqual({
x: 0,
y: 0
})
expect(clampNodePosition('color', { x: 5000, y: 5000 }, 180)).toEqual({
x: STAGE_W - NODE_W.color,
y: STAGE_H - 180
})
})
it('keeps the node bottom above the stage floor so it never gets cut off', () => {
const height = 540
const { y } = clampNodePosition('output', { x: 1080, y: 700 }, height)
expect(y + height).toBeLessThanOrEqual(STAGE_H)
})
it('accepts every home position unchanged', () => {
for (const [id, home] of Object.entries(homePositions)) {
const nodeId = id as keyof typeof homePositions
expect(clampNodePosition(nodeId, home, 100)).toEqual(home)
expect(home.x + NODE_W[nodeId]).toBeLessThanOrEqual(STAGE_W)
}
})
})

View File

@@ -0,0 +1,37 @@
import { clamp } from 'es-toolkit'
import type { NodeId, Point } from './heroGraphWires'
export const STAGE_W = 1600
export const STAGE_H = 780
export const NODE_W: Record<NodeId, number> = {
image: 300,
texture: 190,
color: 210,
lighting: 210,
output: 500
}
// Inputs stack on the left, the control chain runs under the centred headline,
// and the OUTPUT sits fully inside the right edge so nothing bleeds offscreen.
export const homePositions: Record<NodeId, Point> = {
image: { x: 24, y: 40 },
texture: { x: 60, y: 545 },
color: { x: 396, y: 486 },
lighting: { x: 650, y: 486 },
output: { x: 1080, y: 126 }
}
// Drags are confined to an invisible bounding box — the stage rect — so every
// node stops at the edge instead of getting cut off.
export function clampNodePosition(
id: NodeId,
point: Point,
height: number
): Point {
return {
x: clamp(point.x, 0, STAGE_W - NODE_W[id]),
y: clamp(point.y, 0, STAGE_H - height)
}
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NodeId, Rect } from './heroGraphWires'
import { computeWires, connections, spline } from './heroGraphWires'
// 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', () => {
// A short rightward run with a large vertical rise: must still leave/enter
// along x so it reads left-to-right, not as a top-down drop.
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)
})
it('departs and arrives vertically for stacked ports', () => {
const { sx, sy, c1x, c1y, c2x, c2y, ex, ey } = controlPoints(
spline({ x: 100, y: 0 }, { x: 140, y: 400 }, 'v')
)
expect(c1x).toBe(sx)
expect(c2x).toBe(ex)
expect(c1y).toBeGreaterThan(sy)
expect(c2y).toBeLessThan(ey)
})
})
describe('connections', () => {
it('feeds the color remixer from texture, not directly from image', () => {
const pairs = connections.map((c) => `${c.from}->${c.to}`)
expect(pairs).toContain('texture->color')
expect(pairs).not.toContain('image->color')
})
it('chains image → texture → color → lighting → output', () => {
expect(connections.map((c) => `${c.from}->${c.to}`)).toEqual([
'image->texture',
'texture->color',
'color->lighting',
'lighting->output'
])
})
})
describe('computeWires', () => {
const anchors: Record<NodeId, Rect> = {
image: { x: 60, y: 28, w: 300, h: 395 },
texture: { x: 96, y: 500, w: 200, h: 225 },
color: { x: 470, y: 470, w: 150, h: 180 },
lighting: { x: 720, y: 500, w: 168, h: 179 },
output: { x: 1000, y: 110, w: 760, h: 611 }
}
it('only emits wires for nodes that have been measured', () => {
expect(computeWires({ image: anchors.image }).length).toBe(0)
expect(computeWires(anchors).length).toBe(connections.length)
})
it('marks the image → texture wire as the accent wire', () => {
const wires = computeWires(anchors)
expect(wires[0].accent).toBe(true)
expect(wires.slice(1).every((w) => !w.accent)).toBe(true)
})
})

View File

@@ -0,0 +1,110 @@
export type NodeId = 'image' | 'texture' | 'color' | 'lighting' | '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
accent: boolean
}
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 })
const bottomPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y + r.h })
const topPort =
(f = 0.5): Port =>
(r) => ({ x: r.x + r.w * f, y: r.y })
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: side ports (right→left)
// depart horizontally, stacked ports (bottom→top) depart vertically. Keeping the
// tangent on the port axis stops a wire between side ports from reading as a
// top-down drop when the vertical gap happens to exceed the horizontal one.
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: NodeId
to: NodeId
fromPort: Port
toPort: Port
axis: Axis
accent?: boolean
}
export const connections: Connection[] = [
{
from: 'image',
to: 'texture',
fromPort: bottomPort(0.4),
toPort: topPort(0.5),
axis: 'v',
accent: true
},
{
from: 'texture',
to: 'color',
fromPort: rightPort(0.5),
toPort: leftPort(0.5),
axis: 'h'
},
{
from: 'color',
to: 'lighting',
fromPort: rightPort(0.5),
toPort: leftPort(0.45),
axis: 'h'
},
{
from: 'lighting',
to: 'output',
fromPort: rightPort(0.4),
toPort: leftPort(0.4),
axis: 'h'
}
]
export function computeWires(anchors: Partial<Record<NodeId, 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,
accent: c.accent ?? false,
d: spline(from, dest, c.axis)
}
]
})
}

View File

@@ -0,0 +1,244 @@
import { clamp } from 'es-toolkit'
import { computed, onMounted, ref, watchEffect } from 'vue'
import type { CSSProperties } from 'vue'
import { imageVariants, resolveBakedRender } from './heroGraphData'
import type { Point } from './heroGraphWires'
import type { TranslationKey } from '../../i18n/translations'
type ColorPresetId = 'cyberpunk' | 'film' | 'dream' | 'editorial'
type LightModeId = 'soft' | 'rim' | 'neon' | 'studio'
type HeroNodeId = 'color' | 'lighting'
interface ColorPreset {
id: ColorPresetId
labelKey: TranslationKey
saturate: number
contrast: number
hue: number
blend: CSSProperties['mixBlendMode']
}
interface ColorSwatch {
id: string
labelKey: TranslationKey
// Space-separated RGB channels, ready for `rgb(<rgb> / <alpha>)`.
rgb: string
}
interface LightMode {
id: LightModeId
labelKey: TranslationKey
spread: number
core: number
rim: number
tint: string
}
export const colorPresets: ColorPreset[] = [
{
id: 'cyberpunk',
labelKey: 'hero.color.preset.cyberpunk',
saturate: 1.3,
contrast: 1.1,
hue: 8,
blend: 'overlay'
},
{
id: 'film',
labelKey: 'hero.color.preset.film',
saturate: 0.9,
contrast: 1.2,
hue: -6,
blend: 'overlay'
},
{
id: 'dream',
labelKey: 'hero.color.preset.dream',
saturate: 1.2,
contrast: 0.95,
hue: 22,
blend: 'soft-light'
},
{
id: 'editorial',
labelKey: 'hero.color.preset.editorial',
saturate: 0.78,
contrast: 1.08,
hue: 0,
blend: 'overlay'
}
]
export const colorSwatches: ColorSwatch[] = [
{ id: 'magenta', labelKey: 'hero.color.swatch.magenta', rgb: '255 0 140' },
{ id: 'cyan', labelKey: 'hero.color.swatch.cyan', rgb: '0 209 255' },
{ id: 'lime', labelKey: 'hero.color.swatch.lime', rgb: '170 255 90' },
{ id: 'amber', labelKey: 'hero.color.swatch.amber', rgb: '255 176 74' },
{ id: 'violet', labelKey: 'hero.color.swatch.violet', rgb: '150 110 255' }
]
export const lightModes: LightMode[] = [
{
id: 'soft',
labelKey: 'hero.light.mode.soft',
spread: 72,
core: 0.4,
rim: 0,
tint: '255 244 224'
},
{
id: 'rim',
labelKey: 'hero.light.mode.rim',
spread: 56,
core: 0.3,
rim: 0.55,
tint: '176 214 255'
},
{
id: 'neon',
labelKey: 'hero.light.mode.neon',
spread: 62,
core: 0.5,
rim: 0.3,
tint: '206 120 255'
},
{
id: 'studio',
labelKey: 'hero.light.mode.studio',
spread: 88,
core: 0.62,
rim: 0,
tint: '255 255 255'
}
]
// Centralizes the hero's interactive grade so the desktop graph and the mobile
// column drive the same OUTPUT overlays. Returns plain refs (mutated directly by
// the node controls) plus computed CSS the output frame binds inline.
export function useHeroControls() {
const variantId = ref<string>(imageVariants[0].id)
const variant = computed(
() =>
imageVariants.find((v) => v.id === variantId.value) ?? imageVariants[0]
)
const colorPresetId = ref<ColorPresetId>('cyberpunk')
const swatchId = ref(colorSwatches[0].id)
const colorIntensity = ref(72)
const lightModeId = ref<LightModeId>('neon')
const lightIntensity = ref(58)
const lightDir = ref<Point>({ x: 0.64, y: 0.3 })
const activeNode = ref<HeroNodeId | null>(null)
const colorPreset = computed(
() =>
colorPresets.find((p) => p.id === colorPresetId.value) ?? colorPresets[0]
)
const swatch = computed(
() => colorSwatches.find((s) => s.id === swatchId.value) ?? colorSwatches[0]
)
const lightMode = computed(
() => lightModes.find((m) => m.id === lightModeId.value) ?? lightModes[0]
)
// A real ComfyUI render for the selected combo, when one has been generated.
// While it is showing, the CSS grade steps back to a subtle preview layer so
// the output quality is the render's, not a filter approximation.
const bakedRender = computed(() =>
resolveBakedRender(
variant.value.key,
colorPresetId.value,
lightModeId.value
)
)
const outputFilter = computed(() => {
const l = lightIntensity.value / 100
const brightness = 1 + l * (lightMode.value.core - 0.3) * 0.45
if (bakedRender.value) return `brightness(${brightness.toFixed(3)})`
const c = colorIntensity.value / 100
const p = colorPreset.value
const saturate = 1 + (p.saturate - 1) * c
const contrast = 1 + (p.contrast - 1) * c
const hue = p.hue * c
return `saturate(${saturate.toFixed(3)}) contrast(${contrast.toFixed(3)}) hue-rotate(${hue.toFixed(1)}deg) brightness(${brightness.toFixed(3)})`
})
const colorLayerStyle = computed<CSSProperties>(() => ({
backgroundImage: `linear-gradient(125deg, rgb(${swatch.value.rgb} / 0.55), rgb(${swatch.value.rgb} / 0.06) 72%)`,
mixBlendMode: colorPreset.value.blend,
opacity: (colorIntensity.value / 100) * (bakedRender.value ? 0.15 : 0.5)
}))
const lightLayerStyle = computed<CSSProperties>(() => {
const m = lightMode.value
const x = (lightDir.value.x * 100).toFixed(1)
const y = (lightDir.value.y * 100).toFixed(1)
const layers = [
`radial-gradient(circle at ${x}% ${y}%, rgb(${m.tint} / 0.9), transparent ${m.spread}%)`
]
if (m.rim > 0) {
layers.push(
`linear-gradient(105deg, transparent 58%, rgb(${m.tint} / ${m.rim}))`
)
}
return {
backgroundImage: layers.join(', '),
opacity:
(lightIntensity.value / 100) *
(bakedRender.value?.includesLight ? 0.3 : 0.72)
}
})
// Warm the cache for the active input's other renders so preset/light swaps
// land instantly instead of flashing while the file loads.
onMounted(() => {
watchEffect(() => {
for (const preset of colorPresets) {
for (const mode of lightModes) {
const baked = resolveBakedRender(
variant.value.key,
preset.id,
mode.id
)
if (baked) new Image().src = baked.src
}
}
})
})
function setLightFromUnit(x: number, y: number) {
const dx = x - 0.5
const dy = y - 0.5
const dist = Math.hypot(dx, dy)
const max = 0.5
const scaled = dist > max ? max / dist : 1
lightDir.value = {
x: clamp(0.5 + dx * scaled, 0, 1),
y: clamp(0.5 + dy * scaled, 0, 1)
}
}
return {
variantId,
variant,
bakedRender,
colorPresetId,
swatchId,
colorIntensity,
lightModeId,
lightIntensity,
lightDir,
lightMode,
activeNode,
outputFilter,
colorLayerStyle,
lightLayerStyle,
setLightFromUnit
}
}
export type HeroControls = ReturnType<typeof useHeroControls>

View File

@@ -49,6 +49,86 @@ const translations = {
en: 'Run your first workflow',
'zh-CN': '运行你的第一个工作流'
},
'hero.node.image': { en: 'IMAGE', 'zh-CN': '图像' },
'hero.node.texture': { en: 'TEXTURE', 'zh-CN': '纹理' },
'hero.node.lighting': { en: 'LIGHTING', 'zh-CN': '光照' },
'hero.node.color': { en: 'COLOR REMIXER', 'zh-CN': '颜色重映射' },
'hero.node.output': { en: 'OUTPUT', 'zh-CN': '输出' },
'hero.image.variant1': {
en: 'Portrait under multicolor studio lighting',
'zh-CN': '多彩影棚灯光下的人像'
},
'hero.image.variant2': {
en: 'Vase rendered as a rainbow normal map',
'zh-CN': '以彩虹法线贴图渲染的花瓶'
},
'hero.image.variant3': {
en: 'Deer in a forest with chromatic aberration',
'zh-CN': '带色散效果的森林中的鹿'
},
'hero.image.variant4': {
en: 'Two people at a mirror, glitch lighting',
'zh-CN': '镜前两人,故障风格灯光'
},
'hero.image.texture': {
en: 'Texture pass: rainbow normal-map vase',
'zh-CN': '纹理处理:彩虹法线贴图花瓶'
},
'hero.output.variant1': {
en: 'Output: cyberpunk portrait among glowing CRT screens',
'zh-CN': '输出:发光 CRT 屏幕中的赛博朋克人像'
},
'hero.output.variant2': {
en: 'Output: ceramic vase relit on a plinth in a dim library',
'zh-CN': '输出:昏暗图书馆中重新打光、置于基座上的陶瓷花瓶'
},
'hero.output.variant3': {
en: 'Output: iridescent deer before a neon city skyline',
'zh-CN': '输出:霓虹城市天际线前的虹彩鹿'
},
'hero.output.variant4': {
en: 'Output: couple at a cracked mirror in neon bathroom light',
'zh-CN': '输出:霓虹浴室灯光下裂镜前的情侣'
},
'hero.image.pickerLabel': {
en: 'Choose input image variant',
'zh-CN': '选择输入图像变体'
},
'hero.image.hint': {
en: 'Tap a source image to remix the result',
'zh-CN': '点按源图像以重新生成结果'
},
'hero.color.preset.cyberpunk': { en: 'Cyberpunk', 'zh-CN': '赛博朋克' },
'hero.color.preset.film': { en: 'Film', 'zh-CN': '胶片' },
'hero.color.preset.dream': { en: 'Dream', 'zh-CN': '梦境' },
'hero.color.preset.editorial': { en: 'Editorial', 'zh-CN': '杂志' },
'hero.color.remix': { en: 'Remix', 'zh-CN': '重混' },
'hero.color.palette': { en: 'Choose accent color', 'zh-CN': '选择强调色' },
'hero.color.swatch.magenta': { en: 'Magenta', 'zh-CN': '品红' },
'hero.color.swatch.cyan': { en: 'Cyan', 'zh-CN': '青色' },
'hero.color.swatch.lime': { en: 'Lime', 'zh-CN': '青柠' },
'hero.color.swatch.amber': { en: 'Amber', 'zh-CN': '琥珀' },
'hero.color.swatch.violet': { en: 'Violet', 'zh-CN': '紫罗兰' },
'hero.light.mode.soft': { en: 'Soft', 'zh-CN': '柔光' },
'hero.light.mode.rim': { en: 'Rim', 'zh-CN': '轮廓光' },
'hero.light.mode.neon': { en: 'Neon', 'zh-CN': '霓虹' },
'hero.light.mode.studio': { en: 'Studio', 'zh-CN': '影棚' },
'hero.light.intensity': { en: 'Light', 'zh-CN': '光强' },
'hero.light.direction': { en: 'Light direction', 'zh-CN': '光照方向' },
'hero.output.grade': {
en: 'cinematic-grade_v2',
'zh-CN': 'cinematic-grade_v2'
},
'hero.output.colorActive': {
en: 'color remap active',
'zh-CN': '色彩重映射已启用'
},
'hero.output.lightingSuffix': { en: 'lighting', 'zh-CN': '光照' },
'hero.output.live': { en: 'Live preview', 'zh-CN': '实时预览' },
'hero.output.rendered': {
en: 'Rendered with ComfyUI',
'zh-CN': '由 ComfyUI 渲染'
},
// ProductShowcaseSection
'showcase.subtitle1': {

View File

@@ -215,6 +215,114 @@
opacity: 0;
}
/* Gentle chromatic crossfade used when hero node images swap. The incoming
frame eases in with a soft RGB split that resolves smoothly while the
outgoing frame fades beneath it — no positional jumps. Reduced-motion users
get a near-instant swap via the global override below. */
@keyframes hero-glitch-in {
0% {
opacity: 0;
filter: drop-shadow(7px 0 0 rgb(255 0 92 / 0.5))
drop-shadow(-7px 0 0 rgb(0 209 255 / 0.5));
}
60% {
opacity: 1;
filter: drop-shadow(3px 0 0 rgb(255 0 92 / 0.28))
drop-shadow(-3px 0 0 rgb(0 209 255 / 0.28));
}
100% {
opacity: 1;
filter: none;
}
}
.hero-glitch-enter-active,
.hero-glitch-leave-active {
position: absolute;
inset: 0;
}
.hero-glitch-enter-active {
animation: hero-glitch-in 0.55s ease-out both;
z-index: 1;
}
.hero-glitch-leave-active {
transition: opacity 0.55s ease;
}
.hero-glitch-leave-to {
opacity: 0;
}
/* ComfyUI-style canvas dot grid behind the hero graph. */
.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 from each upstream node toward
the OUTPUT while a control node is engaged. 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;
}
}
@keyframes hero-live-pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.hero-live-dot {
animation: hero-live-pulse 1.8s ease-in-out infinite;
}
/* Soft breathing glow inside the LIGHTING direction pad. */
.hero-light-glow {
background: radial-gradient(
circle at 50% 40%,
rgb(242 255 89 / 0.25),
transparent 65%
);
animation: hero-light-breathe 3s ease-in-out infinite;
}
@keyframes hero-light-breathe {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 0.9;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

3
pnpm-lock.yaml generated
View File

@@ -983,9 +983,6 @@ importers:
reka-ui:
specifier: 'catalog:'
version: 2.5.0(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
three:
specifier: 'catalog:'
version: 0.184.0
vue:
specifier: 'catalog:'
version: 3.5.34(typescript@5.9.3)