Compare commits
12 Commits
feat/home-
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e617228f51 | ||
|
|
994ec7ba6f | ||
|
|
7fc2375067 | ||
|
|
aa38b5f478 | ||
|
|
11671dac16 | ||
|
|
df78c566a5 | ||
|
|
64fad7e2c4 | ||
|
|
efabd24de7 | ||
|
|
e106a0ad8d | ||
|
|
af64a2397f | ||
|
|
6e63cf07b9 | ||
|
|
73edbc1fa7 |
197
.github/workflows/backport-auto-merge.yaml
vendored
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
42
.github/workflows/cla.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -18,45 +17,13 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,10 +39,9 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 56 KiB |
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -30,14 +28,11 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<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>
|
||||
@@ -1,38 +0,0 @@
|
||||
<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,12 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import HeroWorkflow from './HeroWorkflow.vue'
|
||||
import { ref } from '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="hero-dot-grid relative overflow-hidden bg-primary-comfy-ink">
|
||||
<HeroWorkflow :locale />
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
<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>
|
||||
@@ -1,53 +0,0 @@
|
||||
<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>
|
||||
@@ -1,107 +0,0 @@
|
||||
<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>
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
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>
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -29,7 +27,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -39,11 +37,10 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<a
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -54,27 +51,23 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -15,10 +14,11 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
}
|
||||
]
|
||||
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,18 +100,23 @@ const cardClass =
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="cardClass"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -126,6 +134,11 @@ const cardClass =
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -155,14 +168,10 @@ const cardClass =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
href: routes.launches,
|
||||
badge: 'new'
|
||||
},
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
|
||||
@@ -53,32 +53,6 @@ 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': {
|
||||
@@ -958,9 +932,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -1022,10 +996,6 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
@@ -2217,7 +2187,6 @@ const translations = {
|
||||
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -4009,12 +3978,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,14 +5,6 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -42,15 +34,6 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -141,25 +124,6 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -173,16 +137,8 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main>
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -12,6 +13,7 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -12,6 +13,7 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,14 +70,10 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--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;
|
||||
@@ -97,14 +93,6 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates.
|
||||
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
|
||||
keep them in sync. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
@@ -227,62 +215,6 @@
|
||||
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,
|
||||
@@ -316,7 +248,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
top: 0.19em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
apps/website/src/templates/drops/SubscribeBanner.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||