mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 03:37:48 +00:00
Compare commits
26 Commits
move-image
...
v1.48.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40fad0e75 | ||
|
|
01cbfa6a23 | ||
|
|
945a143626 | ||
|
|
193bbaba81 | ||
|
|
1eacb224a1 | ||
|
|
4ed2fe70f3 | ||
|
|
5da5ee5031 | ||
|
|
ceb5ae1eba | ||
|
|
9f880c78cb | ||
|
|
3b2eb50f3b | ||
|
|
2ef341dcd8 | ||
|
|
1815c7f7a4 | ||
|
|
287b9eb980 | ||
|
|
06b0471257 | ||
|
|
8120142f49 | ||
|
|
3164e6ab61 | ||
|
|
731512c655 | ||
|
|
c0ad1e98c2 | ||
|
|
bd9fab2d2f | ||
|
|
c7fe6a23ec | ||
|
|
df9b5bfa0a | ||
|
|
a6b7ce11aa | ||
|
|
d3b100be8d | ||
|
|
54b0c10148 | ||
|
|
2b540a5281 | ||
|
|
51156c5503 |
@@ -63,3 +63,14 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,3 +40,6 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -38,9 +38,11 @@ jobs:
|
||||
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]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,32 +278,49 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
else
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
fi
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main
|
||||
git checkout main || git checkout -f main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort
|
||||
git cherry-pick --abort || true
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -384,6 +401,10 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -416,19 +437,37 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -444,10 +483,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
158
apps/website/e2e/learning.spec.ts
Normal file
158
apps/website/e2e/learning.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
129
apps/website/scripts/validate-jsonld.ts
Normal file
129
apps/website/scripts/validate-jsonld.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
41
apps/website/src/components/blocks/HeroBackdrop01.stories.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
193
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -15,8 +16,7 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -11,6 +14,8 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -20,15 +25,32 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
31
apps/website/src/composables/useCurrentPath.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// 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).
|
||||
@@ -73,7 +74,7 @@ export function getBannerData(
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
href: localizeHref(link.href, locale),
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
|
||||
53
apps/website/src/config/pricing.ts
Normal file
53
apps/website/src/config/pricing.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
23
apps/website/src/config/routes.test.ts
Normal file
23
apps/website/src/config/routes.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -47,13 +47,26 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'enterpriseMsa'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -69,15 +82,19 @@ export const externalLinks = {
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||
@@ -1872,6 +1872,10 @@ const translations = {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.installMcp': {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
@@ -1909,21 +1913,27 @@ const translations = {
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.step1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
@@ -2180,6 +2190,13 @@ const translations = {
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
|
||||
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
|
||||
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
@@ -4051,7 +4068,6 @@ const translations = {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
@@ -4147,10 +4163,6 @@ const translations = {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
|
||||
@@ -7,14 +7,17 @@ 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 { isHrefActive } from '../composables/useCurrentPath'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -22,6 +25,10 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -30,52 +37,54 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
// A promo never advertises the page you are already on, so the banner is
|
||||
// suppressed when its CTA points at the current path.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVisible =
|
||||
evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -117,10 +126,7 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -140,7 +146,6 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
|
||||
@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,41 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../config/pricing'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,44 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'en')
|
||||
const description = t('cloudNodes.meta.description', 'en')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import FormSection from '../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="Contact — Comfy">
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,20 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from "../utils/jsonLd";
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
comfyUiApplicationNode(siteUrl),
|
||||
comfyUiSourceCodeNode(siteUrl),
|
||||
]}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
|
||||
@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: displayName,
|
||||
url,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
})
|
||||
const faqPage: JsonLdNode = {
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
@@ -97,54 +79,44 @@ const faqJsonLd = {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('models.breadcrumb.models', locale),
|
||||
url: absoluteUrl(Astro.site, '/p/supported-models'),
|
||||
},
|
||||
{ name: displayName },
|
||||
]}
|
||||
extraJsonLd={[software, faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
|
||||
@@ -2,10 +2,29 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const modelList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
models.map((model) => ({
|
||||
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
|
||||
})),
|
||||
)
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
@@ -26,6 +45,13 @@ const dirLabel: Record<string, string> = {
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[modelList]}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
|
||||
@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
|
||||
@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
|
||||
@@ -2,9 +2,44 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../../config/pricing'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,39 +4,47 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
const title = t('cloudNodes.meta.title', 'zh-CN')
|
||||
const description = t('cloudNodes.meta.description', 'zh-CN')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,7 +7,12 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
description: pack.description || undefined,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,9 +2,28 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import FormSection from '../../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout title="联系我们 — Comfy">
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<FormSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -19,68 +26,34 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
}
|
||||
---
|
||||
|
||||
@@ -88,25 +61,23 @@ const breadcrumbJsonLd = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
|
||||
@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
|
||||
@@ -9,16 +9,25 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
|
||||
212
apps/website/src/utils/jsonLd.test.ts
Normal file
212
apps/website/src/utils/jsonLd.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
import type { JsonLdGraph } from './jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPageGraph,
|
||||
collectGraphIds,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
productNode,
|
||||
softwareApplicationNode
|
||||
} from './jsonLd'
|
||||
|
||||
const siteUrl = 'https://comfy.org'
|
||||
const site = new URL('https://comfy.org/')
|
||||
|
||||
function typeNames(graph: JsonLdGraph): string[] {
|
||||
return graph['@graph'].map((node) => node['@type'])
|
||||
}
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('resolves internal paths to their trailing-slash canonical form', () => {
|
||||
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
|
||||
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
|
||||
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageContext', () => {
|
||||
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
|
||||
expect(pageContext(site, '/about/', undefined)).toEqual({
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
url: 'https://comfy.org/about/'
|
||||
})
|
||||
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemListNode', () => {
|
||||
it('counts items and omits per-item names when not supplied', () => {
|
||||
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
|
||||
{ url: 'https://jobs.example/1' },
|
||||
{ url: 'https://jobs.example/2', name: 'Designer' }
|
||||
])
|
||||
expect(node.numberOfItems).toBe(2)
|
||||
const items = node.itemListElement as Record<string, unknown>[]
|
||||
expect('name' in items[0]).toBe(false)
|
||||
expect(items[1].name).toBe('Designer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('softwareApplicationNode', () => {
|
||||
it('claims Comfy Org as author and publisher only when first-party', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: jsonLdId(siteUrl, 'software'),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
isFree: true
|
||||
})
|
||||
const orgRef = { '@id': organizationId(siteUrl) }
|
||||
expect(node.author).toEqual(orgRef)
|
||||
expect(node.publisher).toEqual(orgRef)
|
||||
expect(node.offers).toEqual({
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: orgRef
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name Comfy Org as seller on a third-party free offer', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isFree: true
|
||||
})
|
||||
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
|
||||
})
|
||||
|
||||
it('credits a known third-party author without claiming to publish it', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
authorName: 'Jane Dev'
|
||||
})
|
||||
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims no author or publisher for third-party software with no author', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.author).toBeUndefined()
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameAs encyclopedic references', () => {
|
||||
it('links the organization to its Wikidata entity', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/`, name: 'Home' }
|
||||
)
|
||||
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
|
||||
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
|
||||
})
|
||||
|
||||
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.sameAs).toEqual([
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sameAs for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.sameAs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('productNode', () => {
|
||||
it('gives every offer a currency and price', () => {
|
||||
const node = productNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/pricing/#product',
|
||||
name: 'Comfy Cloud',
|
||||
url: 'https://comfy.org/cloud/pricing/',
|
||||
offers: [{ name: 'Standard', price: '20' }]
|
||||
})
|
||||
const offers = node.offers as Record<string, unknown>[]
|
||||
expect(offers[0].price).toBe('20')
|
||||
expect(offers[0].priceCurrency).toBe('USD')
|
||||
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfyUiSourceCodeNode', () => {
|
||||
it('links the source code to the ComfyUI application via targetProduct', () => {
|
||||
const node = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{
|
||||
url,
|
||||
name: 'Pricing',
|
||||
type: 'CollectionPage',
|
||||
mainEntityId: jsonLdId(url, 'itemlist'),
|
||||
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
|
||||
},
|
||||
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
|
||||
)
|
||||
|
||||
it('always includes the site-wide organization, website and page entity', () => {
|
||||
expect(typeNames(graph)).toContain('Organization')
|
||||
expect(typeNames(graph)).toContain('WebSite')
|
||||
expect(typeNames(graph)).toContain('CollectionPage')
|
||||
})
|
||||
|
||||
it('produces a graph where every @id reference resolves', () => {
|
||||
const { defined, references } = collectGraphIds(graph)
|
||||
for (const reference of references) {
|
||||
expect(defined.has(reference)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeJsonLd on a built graph', () => {
|
||||
it('neutralizes a </script> breakout in a page name', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
|
||||
)
|
||||
const serialized = escapeJsonLd(graph)
|
||||
expect(serialized).not.toContain('</script>')
|
||||
expect(serialized).toContain('\\u003c')
|
||||
})
|
||||
})
|
||||
377
apps/website/src/utils/jsonLd.ts
Normal file
377
apps/website/src/utils/jsonLd.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export type WebPageType =
|
||||
| 'WebPage'
|
||||
| 'AboutPage'
|
||||
| 'ContactPage'
|
||||
| 'CollectionPage'
|
||||
|
||||
export interface Crumb {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const sameAs = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin,
|
||||
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
|
||||
externalLinks.wikidataComfyOrg
|
||||
]
|
||||
|
||||
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
|
||||
const comfyUiSameAs = [
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
]
|
||||
|
||||
function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function absoluteUrl(site: URL | undefined, path: string): string {
|
||||
const resolved = new URL(path, site ?? 'https://comfy.org').href
|
||||
return resolved.endsWith('/') ? resolved : `${resolved}/`
|
||||
}
|
||||
|
||||
export function pageContext(
|
||||
site: URL | undefined,
|
||||
pathname: string,
|
||||
currentLocale: string | undefined
|
||||
): PageContext & { url: string } {
|
||||
return {
|
||||
siteUrl: siteUrlFrom(site),
|
||||
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
url: absoluteUrl(site, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonLdId(pageUrl: string, fragment: string): string {
|
||||
return `${pageUrl}#${fragment}`
|
||||
}
|
||||
|
||||
export function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
|
||||
}
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': jsonLdId(pageUrl, 'breadcrumb'),
|
||||
itemListElement: crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return isLast || !crumb.url
|
||||
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
|
||||
: {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: crumb.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListNode(
|
||||
pageUrl: string,
|
||||
name: string,
|
||||
items: { url: string; name?: string }[]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': jsonLdId(pageUrl, 'itemlist'),
|
||||
name,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
...(item.name ? { name: item.name } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
interface WebPageInput {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
url: string
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
|
||||
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
|
||||
return {
|
||||
'@type': type,
|
||||
'@id': jsonLdId(input.url, 'webpage'),
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isPartOf: { '@id': websiteId(input.siteUrl) },
|
||||
primaryImageOfPage: input.imageUrl
|
||||
? { '@type': 'ImageObject', url: input.imageUrl }
|
||||
: undefined,
|
||||
breadcrumb: hasCrumbs
|
||||
? { '@id': jsonLdId(input.url, 'breadcrumb') }
|
||||
: undefined,
|
||||
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
|
||||
inLanguage: input.locale
|
||||
}
|
||||
}
|
||||
|
||||
export interface SoftwareAppInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
applicationCategory: string
|
||||
firstParty?: boolean
|
||||
applicationSubCategory?: string
|
||||
description?: string
|
||||
operatingSystem?: string
|
||||
image?: string
|
||||
softwareVersion?: string
|
||||
license?: string
|
||||
codeRepository?: string
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
const orgRef = { '@id': organizationId(input.siteUrl) }
|
||||
const author = input.firstParty
|
||||
? orgRef
|
||||
: input.authorName
|
||||
? { '@type': 'Person', name: input.authorName }
|
||||
: undefined
|
||||
return {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
applicationCategory: input.applicationCategory,
|
||||
applicationSubCategory: input.applicationSubCategory,
|
||||
description: input.description,
|
||||
operatingSystem: input.operatingSystem,
|
||||
image: input.image,
|
||||
softwareVersion: input.softwareVersion,
|
||||
license: input.license,
|
||||
codeRepository: input.codeRepository,
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: input.firstParty ? orgRef : undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceCodeInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
codeRepository: string
|
||||
programmingLanguage?: string
|
||||
targetProductId?: string
|
||||
}
|
||||
|
||||
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
codeRepository: input.codeRepository,
|
||||
programmingLanguage: input.programmingLanguage,
|
||||
targetProduct: input.targetProductId
|
||||
? { '@id': input.targetProductId }
|
||||
: undefined,
|
||||
author: { '@id': organizationId(input.siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: comfyUiSoftwareId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
targetProductId: comfyUiSoftwareId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
interface OfferInput {
|
||||
name: string
|
||||
price: string | number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
offers: OfferInput[]
|
||||
}
|
||||
|
||||
export function productNode(input: ProductInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Product',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
brand: { '@id': organizationId(input.siteUrl) },
|
||||
offers: input.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
url: offer.url,
|
||||
seller: { '@id': organizationId(input.siteUrl) },
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageGraphInput {
|
||||
url: string
|
||||
name: string
|
||||
type?: WebPageType
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
export function buildPageGraph(
|
||||
ctx: PageContext,
|
||||
page: PageGraphInput,
|
||||
...extraNodes: (JsonLdNode | null | undefined)[]
|
||||
): JsonLdGraph {
|
||||
const { type = 'WebPage', ...rest } = page
|
||||
const input: WebPageInput = {
|
||||
...rest,
|
||||
siteUrl: ctx.siteUrl,
|
||||
locale: ctx.locale
|
||||
}
|
||||
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
|
||||
return buildGraph(
|
||||
organizationNode(ctx.siteUrl),
|
||||
websiteNode(ctx.siteUrl),
|
||||
webPageNode(input, type),
|
||||
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
|
||||
...extraNodes
|
||||
)
|
||||
}
|
||||
|
||||
export function collectGraphIds(value: unknown): {
|
||||
defined: Set<string>
|
||||
references: string[]
|
||||
} {
|
||||
const defined = new Set<string>()
|
||||
const references: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>
|
||||
const id = record['@id']
|
||||
if (typeof id === 'string') {
|
||||
if (Object.keys(record).length === 1) references.push(id)
|
||||
else defined.add(id)
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return { defined, references }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadVideo",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
|
||||
@@ -33,6 +33,27 @@ export function member(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
|
||||
* this the mint fails and auth cannot resolve the active workspace.
|
||||
*/
|
||||
export async function mockWorkspaceTokenMint(
|
||||
page: Page,
|
||||
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
|
||||
) {
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
@@ -46,17 +67,7 @@ export async function mockWorkspace(
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, ws)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
@@ -81,6 +85,7 @@ async function mockCloudBoot(
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
279
browser_tests/tests/cloudSecrets.spec.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -476,6 +476,37 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test(
|
||||
'Closing minimap after subgraph navigation keeps Vue render in sync',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Minimap unmount must not clobber the Vue wrapper layered above it.
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
.click()
|
||||
|
||||
const subgraphFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -39,4 +45,34 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
GetAllSettingsResponse,
|
||||
GetSettingByIdResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const outputVideoHash = 'cloud-video-hash.mp4'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const missingMediaObservationMs = 1_000
|
||||
const missingMediaPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudOutputVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-video-hash-001',
|
||||
name: 'ComfyUI_00001_.mp4',
|
||||
hash: outputVideoHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
const completedSurveySetting: GetSettingByIdResponse = {
|
||||
value: { usage: 'personal' }
|
||||
}
|
||||
const allSettings: GetAllSettingsResponse = {}
|
||||
const body = route
|
||||
.request()
|
||||
.url()
|
||||
.includes('/api/settings/onboarding_survey')
|
||||
? completedSurveySetting
|
||||
: allSettings
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
const cloudOutputTest = createCloudAssetsFixture([
|
||||
cloudOutputAsset,
|
||||
cloudOutputVideoAsset
|
||||
]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function isOutputAssetsRequest(url: string) {
|
||||
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
|
||||
}
|
||||
|
||||
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 && isOutputAssetsRequest(response.url())
|
||||
)
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
let sawCachedMissingMedia = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cachedMissingMedia =
|
||||
await getCachedMissingMediaWarningNames(comfyPage)
|
||||
sawCachedMissingMedia =
|
||||
sawCachedMissingMedia || !!cachedMissingMedia?.length
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
!sawCachedMissingMedia &&
|
||||
Date.now() - startedAt >= missingMediaObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
|
||||
intervals: [missingMediaPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -424,7 +490,7 @@ ossTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves subfoldered output video media from flat output asset hashes',
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_video_subfolder'
|
||||
)
|
||||
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 89 KiB |
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
120
docs/adr/0011-derived-credential-lifecycle.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 11. Derived Credential Lifecycle for Cloud Auth
|
||||
|
||||
Date: 2026-07-09
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
Cloud authentication derives several short-lived credentials from a single
|
||||
source of truth — the Firebase identity (ID token):
|
||||
|
||||
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
|
||||
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
|
||||
(`useSessionCookie`),
|
||||
- and consumer state gated on those credentials, such as **subscription status**
|
||||
(`useSubscription`).
|
||||
|
||||
A recurring class of production bugs traces back to how these derived credentials
|
||||
are kept fresh rather than to any single code path:
|
||||
|
||||
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
|
||||
Its refresh relies on a `setTimeout` timer that browsers throttle in background
|
||||
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
|
||||
call 401s until reload.
|
||||
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
|
||||
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
|
||||
personal Firebase token, so requests authenticated as the wrong identity.
|
||||
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
|
||||
burst on wake/network-swap fans out into concurrent, undeduped subscription
|
||||
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
|
||||
return 401/empty, flapping `subscriptionStatus` and the run button.
|
||||
|
||||
These are not independent defects. They are symptoms of one design shape: **each
|
||||
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
|
||||
one-shot events rather than the source identity, with no coalescing of concurrent
|
||||
refreshes and with silent fallback to a different identity or a stale value on
|
||||
failure.** Any credential built this way can go stale, stampede, or downgrade.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat every derived credential as a pure function of the Firebase identity, and
|
||||
require all of them to obey the same lifecycle invariants. New auth code must
|
||||
satisfy these; existing code migrates toward them incrementally.
|
||||
|
||||
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
|
||||
JWT and session cookie are derivations of it, never independent state that can
|
||||
drift from it.
|
||||
|
||||
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
|
||||
or a definitive failure — never a known-expired one. Validity is checked at the
|
||||
point of use (expiry-aware), not assumed because a background timer _should_
|
||||
have refreshed. Timers may be an optimization, never the guarantee.
|
||||
|
||||
3. **Single-flight.** Concurrent requests for the same credential share one
|
||||
in-flight mint/refresh. A refresh burst collapses to a single network call.
|
||||
|
||||
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
|
||||
obtained, fail the request. Never silently substitute a different identity or
|
||||
scope (e.g. personal token for a workspace request).
|
||||
|
||||
5. **Bounded reactive retry.** Invalidation is driven by the source identity
|
||||
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
|
||||
derived credential triggers at most one re-mint and one retry, then surfaces
|
||||
the error.
|
||||
|
||||
6. **Explicit scope.** A credential names the identity/workspace it is for.
|
||||
Coalesced results are verified against the requested scope before use.
|
||||
|
||||
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
|
||||
single-flight, fail-closed, and reconciles a revoked workspace instead of
|
||||
downgrading; subscription-status and session-cookie creation are now
|
||||
single-flight so a refresh burst can no longer flap them. It intentionally does
|
||||
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
|
||||
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
|
||||
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
|
||||
re-mint + single retry).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- **Layer more defensive checks per call site.** Rejected: this is what produced
|
||||
the current state — correctness that depends on every caller remembering to
|
||||
guard is the defect, not the fix.
|
||||
- **A single reactive credential store subscribing to Firebase, replacing all
|
||||
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
|
||||
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
|
||||
these invariants incrementally instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Whole categories of failure become structurally hard rather than individually
|
||||
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
|
||||
requests (4).
|
||||
- New auth code has a single checklist to satisfy, and reviewers a single rubric
|
||||
to apply.
|
||||
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
|
||||
reasoning about auth changes.
|
||||
|
||||
### Negative
|
||||
|
||||
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
|
||||
transient conditions now show errors instead of degrading quietly, so
|
||||
transient-vs-permanent classification must be correct.
|
||||
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
|
||||
lazy and a backgrounded tab still relies on the next read to heal, leaving a
|
||||
visible gap against FE-613's reactive ideal.
|
||||
- Existing lifecycles remain non-uniform during migration, so the mental model is
|
||||
"target vs. current" until the reactive credential store exists.
|
||||
|
||||
## Notes
|
||||
|
||||
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
|
||||
shares the philosophy of designing invariants that make illegal states
|
||||
unrepresentable rather than guarding against them per call site.
|
||||
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.
|
||||
@@ -20,6 +20,7 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
Documentation for unit tests is organized into three guides:
|
||||
Documentation for unit tests is organized into four guides:
|
||||
|
||||
- [Component Testing](./component-testing.md) - How to test Vue components
|
||||
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
|
||||
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
|
||||
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
|
||||
|
||||
## Testing Structure
|
||||
|
||||
|
||||
9
docs/testing/litegraph-testing.md
Normal file
9
docs/testing/litegraph-testing.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# LiteGraph Testing Guide
|
||||
|
||||
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
|
||||
|
||||
## Shared Factories
|
||||
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
|
||||
|
||||
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.6",
|
||||
"version": "1.48.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -114,6 +114,7 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"minisearch": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
|
||||
@@ -414,15 +414,15 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
it('returns true for image/video/audio/3D/text', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
expect(isPreviewableMediaType('text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
it('returns false for other', () => {
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -677,12 +677,7 @@ export function getMediaTypeFromFilename(
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
return mediaType !== 'other'
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -282,6 +282,9 @@ catalogs:
|
||||
markdown-table:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
minisearch:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
mixpanel-browser:
|
||||
specifier: ^2.71.0
|
||||
version: 2.71.0
|
||||
@@ -591,6 +594,9 @@ importers:
|
||||
marked:
|
||||
specifier: ^15.0.11
|
||||
version: 15.0.11
|
||||
minisearch:
|
||||
specifier: 'catalog:'
|
||||
version: 7.2.0
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
@@ -7024,6 +7030,9 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minisearch@7.2.0:
|
||||
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -8884,8 +8893,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11668,7 +11677,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -15817,6 +15826,8 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mixpanel-browser@2.71.0:
|
||||
@@ -18138,7 +18149,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -103,6 +103,7 @@ catalog:
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
|
||||
3
public/assets/images/gemini.svg
Normal file
3
public/assets/images/gemini.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
|
||||
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
4
public/assets/images/runway.svg
Normal file
4
public/assets/images/runway.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
|
||||
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
|
||||
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -4,37 +4,67 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -122,16 +152,6 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -172,7 +195,8 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<!-- Sort Options -->
|
||||
<div>
|
||||
<SingleSelect
|
||||
v-model="sortBy"
|
||||
v-model="sortSelection"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
@@ -556,7 +556,8 @@ const {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn,
|
||||
sortBy,
|
||||
sortSelection,
|
||||
hasActiveQuery,
|
||||
activeModels,
|
||||
activeUseCases,
|
||||
filteredTemplates,
|
||||
@@ -565,14 +566,13 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
resetFilters
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Raw search input bound to the search box. The actual `searchQuery` consumed
|
||||
* by the filtering composable is only updated via `applySearchQuery` after the
|
||||
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
|
||||
* debounce settles, keeping search/grid re-renders off the keystroke critical path.
|
||||
*/
|
||||
const searchInput = ref(searchQuery.value)
|
||||
|
||||
@@ -595,15 +595,13 @@ watch(searchQuery, (value) => {
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
const isPopularSort = sortSelection.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
sortSelection.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
sortSelection.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
@@ -616,7 +614,7 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
watch(sortSelection, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
// Only show selected items that exist in the current scope
|
||||
@@ -726,8 +724,15 @@ const runsOnFilterLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
...(hasActiveQuery.value
|
||||
? [
|
||||
{
|
||||
name: t('templateWorkflows.sort.relevance', 'Relevance'),
|
||||
value: 'relevance'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
@@ -789,7 +794,7 @@ watch(
|
||||
[
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortBy,
|
||||
sortSelection,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn
|
||||
@@ -839,8 +844,7 @@ const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -449,6 +449,12 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('focus-outside never dismisses when dismissOnFocusOutside is false', () => {
|
||||
const event = makeEvent(document.body)
|
||||
onRekaFocusOutside(event, { dismissOnFocusOutside: false })
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
|
||||
const portal = document.createElement('div')
|
||||
portal.setAttribute('role', 'dialog')
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
dialogStore.activeKey === item.key
|
||||
)
|
||||
"
|
||||
@focus-outside="onRekaFocusOutside"
|
||||
@focus-outside="
|
||||
(e) => onRekaFocusOutside(e, item.dialogComponentProps)
|
||||
"
|
||||
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
|
||||
>
|
||||
<template v-if="item.dialogComponentProps.headless">
|
||||
|
||||
@@ -53,7 +53,22 @@ export function onRekaPointerDownOutside(
|
||||
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
|
||||
// non-modal Reka dialog would dismiss itself the moment a nested dialog
|
||||
// receives focus.
|
||||
export function onRekaFocusOutside(event: OutsideEvent) {
|
||||
//
|
||||
// A container dialog (e.g. Settings) that hosts nested confirm/edit dialogs can
|
||||
// also lose focus to an ordinary app element — not just a portal — when a
|
||||
// nested dialog closes and the element it focused was removed (deleting the
|
||||
// selected row). That programmatic focus shift is not a dismiss intent, so such
|
||||
// a dialog opts out of focus-outside dismissal entirely via
|
||||
// `dismissOnFocusOutside: false`; it still dismisses on escape or an outside
|
||||
// pointer.
|
||||
export function onRekaFocusOutside(
|
||||
event: OutsideEvent,
|
||||
options: { dismissOnFocusOutside?: boolean } = {}
|
||||
) {
|
||||
if (options.dismissOnFocusOutside === false) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isInsideOverlay(event.detail.originalEvent.target)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
<ColorPicker
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -21,12 +29,6 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
/>
|
||||
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
|
||||
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
|
||||
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +76,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultText from './ResultText.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
21
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<article
|
||||
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
|
||||
>
|
||||
<span v-if="hasError" class="text-muted-foreground">
|
||||
{{ $t('g.textFailedToLoad') }}
|
||||
</span>
|
||||
<template v-else>{{ textContent }}</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { result } = defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const { textContent, hasError } = useTextFileContent(() => result)
|
||||
</script>
|
||||
@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
const { alpha = true } = defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
hsva.value = readHsva(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
@@ -37,6 +39,7 @@ const { t } = useI18n()
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-if="alpha"
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
@@ -72,7 +75,7 @@ const { t } = useI18n()
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#fff']
|
||||
palette: ['#ffffff']
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes palette entries and drops invalid colors', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
|
||||
'#ff0000',
|
||||
'#aabbcc'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
|
||||
@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: unknown): string | null {
|
||||
if (typeof color !== 'string') return null
|
||||
const hex = color.trim().toLowerCase()
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
|
||||
if (short) {
|
||||
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
|
||||
}
|
||||
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
|
||||
}
|
||||
|
||||
function normalizePalette(palette: unknown): string[] {
|
||||
return Array.isArray(palette)
|
||||
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
|
||||
: []
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
palette: normalizePalette(meta.palette)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
const { appState, outputState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown },
|
||||
outputState: {
|
||||
outputs: undefined as unknown,
|
||||
nodeOutputs: null as { value: Record<string, unknown> } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const nodeOutputs = ref<Record<string, unknown>>({})
|
||||
outputState.nodeOutputs = nodeOutputs
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs,
|
||||
nodePreviewImages: ref({}),
|
||||
getNodeImageUrls: () => undefined,
|
||||
getNodeOutputs: () => outputState.outputs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
@@ -27,6 +45,9 @@ const ctx = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
|
||||
return el
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
interface MockNode {
|
||||
widgets: { name: string; value: unknown }[]
|
||||
findInputSlot: (name: string) => number
|
||||
getInputNode: () => null
|
||||
isInputConnected?: () => boolean
|
||||
}
|
||||
|
||||
function makeNode(): MockNode {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'last_incoming', value: [] }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const lastIncomingOf = (node: MockNode) =>
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value
|
||||
|
||||
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -96,6 +132,8 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
const modelBoxes = (c: Captured) => c.modelValue.value
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
...over
|
||||
})
|
||||
|
||||
function makeConnectedNode(): MockNode {
|
||||
return {
|
||||
...makeNode(),
|
||||
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
|
||||
isInputConnected: () => true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
outputState.outputs = undefined
|
||||
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
expect(modelBoxes(c)[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions', async () => {
|
||||
it('clears all regions and invalidates the applied upstream input', async () => {
|
||||
const node = makeNode()
|
||||
setLastIncomingOf(node, [box()])
|
||||
appState.node = node
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(lastIncomingOf(node)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes incoming bboxes input', () => {
|
||||
it('adopts cached outputs on mount without overwriting existing edits', () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('does not re-apply an already applied output after a remount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
setLastIncomingOf(node, incoming)
|
||||
appState.node = node
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
})
|
||||
|
||||
it('ignores incoming output when the input is not connected', () => {
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('repopulates from the next run after clearing the canvas', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
})
|
||||
|
||||
it('does not apply output updates while the input is disconnected', async () => {
|
||||
let connected = true
|
||||
appState.node = {
|
||||
...makeConnectedNode(),
|
||||
isInputConnected: () => connected
|
||||
}
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
connected = false
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not apply incoming boxes while the user is drawing', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(50, 50))
|
||||
|
||||
outputState.outputs = {
|
||||
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
|
||||
}
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
c.onDocPointerUp(pe(50, 50))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(205)
|
||||
})
|
||||
|
||||
it('applies incoming boxes when outputs stream in after mount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
|
||||
const node = makeConnectedNode()
|
||||
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
|
||||
appState.node = node
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
|
||||
const changed = [box({ x: 64, width: 128 })]
|
||||
outputState.outputs = { input_bboxes: changed }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)[0].width).toBe(128)
|
||||
expect(lastIncomingOf(node)).toEqual(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes grid snapping', () => {
|
||||
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].x).toBe(64)
|
||||
expect(modelBoxes(c)[0].width).toBe(256)
|
||||
})
|
||||
|
||||
it('does not snap when grid is disabled', async () => {
|
||||
const c = setup()
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(55, 55))
|
||||
c.onDocPointerUp(pe(55, 55))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(230)
|
||||
})
|
||||
|
||||
it('keeps the anchored edge fixed when resizing a single edge', async () => {
|
||||
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
|
||||
c.onPointerDown(pe(60, 30))
|
||||
c.onCanvasPointerMove(pe(80, 30))
|
||||
c.onDocPointerUp(pe(80, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].x).toBe(51)
|
||||
})
|
||||
|
||||
it('removes a box that a resize collapses to zero size', async () => {
|
||||
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
|
||||
c.onPointerDown(pe(37, 25))
|
||||
c.onCanvasPointerMove(pe(14, 25))
|
||||
c.onDocPointerUp(pe(14, 25))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
const GRID_PX = 32
|
||||
const MAX_GRID_CELLS = 64
|
||||
const DOT_ALPHA = 0.18
|
||||
const DOT_RADIUS = 1
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
const grid = ref(true)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function gridSpec() {
|
||||
const axisFraction = (size: number) =>
|
||||
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
|
||||
return {
|
||||
fx: axisFraction(widthValue.value),
|
||||
fy: axisFraction(heightValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
function snapFraction(value: number, step: number) {
|
||||
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
|
||||
}
|
||||
|
||||
function snapRegion(region: Region, mode: HitMode): Region {
|
||||
if (!grid.value) return region
|
||||
const { fx, fy } = gridSpec()
|
||||
if (mode === 'move') {
|
||||
return {
|
||||
...region,
|
||||
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
|
||||
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
|
||||
}
|
||||
}
|
||||
const snapLeft =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-l' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-bl'
|
||||
const snapRight =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-r' ||
|
||||
mode === 'resize-tr' ||
|
||||
mode === 'resize-br'
|
||||
const snapTop =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-t' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-tr'
|
||||
const snapBottom =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-b' ||
|
||||
mode === 'resize-bl' ||
|
||||
mode === 'resize-br'
|
||||
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
|
||||
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
|
||||
const x2 = snapRight
|
||||
? snapFraction(region.x + region.w, fx)
|
||||
: region.x + region.w
|
||||
const y2 = snapBottom
|
||||
? snapFraction(region.y + region.h, fy)
|
||||
: region.y + region.h
|
||||
return {
|
||||
...region,
|
||||
x: x1,
|
||||
y: y1,
|
||||
w: Math.max(0, x2 - x1),
|
||||
h: Math.max(0, y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { fx, fy } = gridSpec()
|
||||
if (fx <= 0 || fy <= 0) return
|
||||
const cols = Math.round(1 / fx)
|
||||
const rows = Math.round(1 / fy)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = DOT_ALPHA
|
||||
ctx.fillStyle = getComputedStyle(el).color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cx = Math.min(1, i * fx) * W
|
||||
for (let j = 0; j <= rows; j++) {
|
||||
const cy = Math.min(1, j * fy) * H
|
||||
ctx.moveTo(cx + DOT_RADIUS, cy)
|
||||
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
if (grid.value) drawDots(ctx, W, H)
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
if (b && (b.w < 0.005 || b.h < 0.005)) {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
setLastIncoming([])
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
watch(
|
||||
litegraphNode,
|
||||
(node) => {
|
||||
const props = node?.properties as { bboxGrid?: unknown } | undefined
|
||||
if (props && typeof props.bboxGrid === 'boolean')
|
||||
grid.value = props.bboxGrid
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(grid, (enabled) => {
|
||||
const props = litegraphNode.value?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (props) props.bboxGrid = enabled
|
||||
requestDraw()
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
function lastIncomingWidget() {
|
||||
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
|
||||
}
|
||||
|
||||
function lastIncomingValue(): BoundingBox[] {
|
||||
const value = lastIncomingWidget()?.value
|
||||
return Array.isArray(value) ? (value as BoundingBox[]) : []
|
||||
}
|
||||
|
||||
function setLastIncoming(boxes: BoundingBox[]) {
|
||||
const widget = lastIncomingWidget()
|
||||
if (!widget) return
|
||||
const next = cloneDeep(boxes)
|
||||
widget.value = next
|
||||
widget.callback?.(next)
|
||||
}
|
||||
|
||||
function applyIncomingBoxes(apply = true) {
|
||||
if (drawing.value) return
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('bboxes')
|
||||
if (slot < 0 || !node.isInputConnected(slot)) return
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node) as
|
||||
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
|
||||
| undefined
|
||||
const incoming = outputs?.input_bboxes
|
||||
if (!incoming?.length) return
|
||||
const applied = lastIncomingValue()
|
||||
if (isEqual(incoming, applied)) return
|
||||
if (!apply) {
|
||||
if (!applied.length && state.value.regions.length)
|
||||
setLastIncoming(incoming)
|
||||
return
|
||||
}
|
||||
state.value.regions = fromBoundingBoxes(
|
||||
incoming,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
activeIndex.value = state.value.regions.length ? 0 : -1
|
||||
setLastIncoming(incoming)
|
||||
syncState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => {
|
||||
updateBgImage()
|
||||
applyIncomingBoxes()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
applyIncomingBoxes(false)
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
syncState,
|
||||
grid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -13,17 +13,12 @@ afterEach(() => {
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
|
||||
return { modelValue, container, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
it('updates the color at an index', () => {
|
||||
const { modelValue, updateAt } = setup(['#a', '#b'])
|
||||
updateAt(1, '#123456')
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
it('ignores an update that does not change the color', () => {
|
||||
const { modelValue, updateAt } = setup(['#a'])
|
||||
const before = modelValue.value
|
||||
updateAt(0, '#a')
|
||||
expect(modelValue.value).toBe(before)
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
|
||||
@@ -5,30 +5,16 @@ import { ref } from 'vue'
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container,
|
||||
picker
|
||||
container
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
function updateAt(i: number, value: string) {
|
||||
if (modelValue.value[i] === value) return
|
||||
const next = modelValue.value.slice()
|
||||
next[pickerIndex.value] = v
|
||||
next[i] = value
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
|
||||
})
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
updateAt,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
|
||||
316
src/composables/templateSearchConfig.test.ts
Normal file
316
src/composables/templateSearchConfig.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SearchResult } from 'minisearch'
|
||||
|
||||
import {
|
||||
createTemplateSearchIndex,
|
||||
expandAbbreviation,
|
||||
expandQuery,
|
||||
rankByRelevanceThenUsage,
|
||||
searchTemplates,
|
||||
termFuzziness,
|
||||
tokenize
|
||||
} from '@/composables/templateSearchConfig'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const buildTemplate = (
|
||||
overrides: Partial<TemplateInfo> & { name: string }
|
||||
): TemplateInfo => ({
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('tokenize', () => {
|
||||
it('splits identifiers on hyphen and underscore', () => {
|
||||
expect(tokenize('video_ltx2_3_t2v')).toEqual([
|
||||
'video_ltx2_3_t2v',
|
||||
'video',
|
||||
'ltx2',
|
||||
'3',
|
||||
't2v'
|
||||
])
|
||||
})
|
||||
|
||||
it('splits a trailing version off its name so "wan 2.7" matches "wan2.7"', () => {
|
||||
expect(tokenize('wan2.7')).toEqual(['wan2.7', 'wan', '2.7'])
|
||||
})
|
||||
|
||||
it('keeps a mid-digit abbreviation whole', () => {
|
||||
expect(tokenize('t2v')).toEqual(['t2v'])
|
||||
})
|
||||
|
||||
it('emits a unigram and bigram for each char of an unspaced CJK run', () => {
|
||||
expect(tokenize('图像放大')).toEqual([
|
||||
'图',
|
||||
'像',
|
||||
'放',
|
||||
'大',
|
||||
'图像',
|
||||
'像放',
|
||||
'放大'
|
||||
])
|
||||
})
|
||||
|
||||
it('grams katakana including the prolonged-sound mark', () => {
|
||||
expect(tokenize('データ')).toEqual(['デ', 'ー', 'タ', 'デー', 'ータ'])
|
||||
})
|
||||
|
||||
it('treats Korean as a spaced script, not an unspaced CJK run', () => {
|
||||
expect(tokenize('업스케일')).toEqual(['업스케일'])
|
||||
})
|
||||
|
||||
it('grams the CJK part of a word glued to latin, keeping the whole word', () => {
|
||||
expect(tokenize('flux图像')).toEqual(['图', '像', '图像', 'flux图像'])
|
||||
})
|
||||
|
||||
it('lowercases and drops empty tokens', () => {
|
||||
expect(tokenize(' Flux Kontext ')).toEqual(['flux', 'kontext'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('termFuzziness', () => {
|
||||
it('is exact for short terms (≤3 chars)', () => {
|
||||
expect(termFuzziness('t2v')).toBe(false)
|
||||
expect(termFuzziness('cn')).toBe(false)
|
||||
})
|
||||
|
||||
it('is exact for any term containing a digit so versions do not blur', () => {
|
||||
expect(termFuzziness('2.5')).toBe(false)
|
||||
expect(termFuzziness('flux2')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows edits for longer alphabetic terms', () => {
|
||||
expect(termFuzziness('control')).not.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandAbbreviation', () => {
|
||||
it('expands cross-modality shorthand', () => {
|
||||
expect(expandAbbreviation('t2i')).toBe('text image')
|
||||
expect(expandAbbreviation('i2v')).toBe('image video')
|
||||
expect(expandAbbreviation('txt2img')).toBe('text image')
|
||||
})
|
||||
|
||||
it('expands same-modality transforms to editing', () => {
|
||||
expect(expandAbbreviation('img2img')).toBe('image edit')
|
||||
expect(expandAbbreviation('v2v')).toBe('video edit')
|
||||
})
|
||||
|
||||
it('expands known acronyms', () => {
|
||||
expect(expandAbbreviation('cn')).toBe('controlnet')
|
||||
})
|
||||
|
||||
it('returns null for unknown tokens and unknown modalities', () => {
|
||||
expect(expandAbbreviation('flux')).toBeNull()
|
||||
expect(expandAbbreviation('x2y')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandQuery', () => {
|
||||
it('expands shorthand tokens within a multi-word query', () => {
|
||||
expect(expandQuery('wan i2v')).toBe('wan image video')
|
||||
})
|
||||
|
||||
it('returns null when nothing expands', () => {
|
||||
expect(expandQuery('flux upscale')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchTemplates', () => {
|
||||
const buildIndex = (templates: TemplateInfo[]) =>
|
||||
createTemplateSearchIndex(templates)
|
||||
|
||||
it('returns an empty array for a blank query without touching the index', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'a', title: 'Alpha' })])
|
||||
expect(searchTemplates(index, ' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a prefix ("vid" → "video")', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'video', title: 'Video Generator' }),
|
||||
buildTemplate({ name: 'audio', title: 'Audio Studio' })
|
||||
])
|
||||
expect(searchTemplates(index, 'vid')).toContain('video')
|
||||
expect(searchTemplates(index, 'vid')).not.toContain('audio')
|
||||
})
|
||||
|
||||
it('tolerates a typo in a longer term ("contorlnet")', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'cn',
|
||||
title: 'Union ControlNet',
|
||||
tags: ['ControlNet']
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'contorlnet')).toContain('cn')
|
||||
})
|
||||
|
||||
it('does not fuzzy-match a long word onto its shorter substring', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'real', title: 'SeedVR2 Image Upscale' }),
|
||||
buildTemplate({
|
||||
name: 'junk',
|
||||
title: 'Anime Text to Image',
|
||||
description: 'configure CFG scale and steps'
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')).toContain('real')
|
||||
expect(searchTemplates(index, 'upscale')).not.toContain('junk')
|
||||
})
|
||||
|
||||
it('matches a CJK term inside an unspaced CJK title', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'zh_upscale', title: '图像放大' }), // "image upscale"
|
||||
buildTemplate({ name: 'zh_video', title: '视频补帧' }) // "video interpolation"
|
||||
])
|
||||
// 放大 = "upscale"
|
||||
expect(searchTemplates(index, '放大')).toContain('zh_upscale')
|
||||
expect(searchTemplates(index, '放大')).not.toContain('zh_video')
|
||||
})
|
||||
|
||||
it('matches a single CJK character that ends an unspaced run', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'zh', title: '图像放大' })])
|
||||
// 大 is only the trailing half of the last bigram; the unigram reaches it.
|
||||
expect(searchTemplates(index, '大')).toContain('zh')
|
||||
})
|
||||
|
||||
it('requires all words to match (AND) before falling back to OR', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'both',
|
||||
title: 'Flux Upscale',
|
||||
models: ['Flux'],
|
||||
tags: ['Upscale']
|
||||
}),
|
||||
buildTemplate({ name: 'flux_only', title: 'Flux Text to Image' })
|
||||
])
|
||||
expect(searchTemplates(index, 'flux upscale')[0]).toBe('both')
|
||||
})
|
||||
|
||||
it('breaks a near-tie by higher usage', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'low', title: 'Alpha Upscale', usage: 1 }),
|
||||
buildTemplate({ name: 'high', title: 'Beta Upscale', usage: 5000 })
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')[0]).toBe('high')
|
||||
})
|
||||
|
||||
it('does not let usage override a clearly stronger text match', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'exact', title: 'Outpaint', usage: 1 }),
|
||||
buildTemplate({
|
||||
name: 'weak',
|
||||
title: 'Portrait',
|
||||
description: 'has an outpaint option somewhere',
|
||||
usage: 9000
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'outpaint')[0]).toBe('exact')
|
||||
})
|
||||
|
||||
it('deduplicates literal and expansion matches, keeping the literal first', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'literal', title: 'Wan T2V', tags: ['T2V'] }),
|
||||
buildTemplate({ name: 'expanded', title: 'Text to Video Studio' })
|
||||
])
|
||||
const results = searchTemplates(index, 't2v')
|
||||
expect(results[0]).toBe('literal')
|
||||
expect(new Set(results).size).toBe(results.length)
|
||||
})
|
||||
|
||||
it('indexes localized title/description over raw english', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'localized',
|
||||
title: 'raw',
|
||||
localizedTitle: 'aquarela',
|
||||
description: 'raw',
|
||||
localizedDescription: 'pintura'
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'aquarela')).toEqual(['localized'])
|
||||
expect(searchTemplates(index, 'pintura')).toEqual(['localized'])
|
||||
})
|
||||
|
||||
it('falls back to the name when a template has no title or description', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'flux_kontext_edit' })])
|
||||
expect(searchTemplates(index, 'kontext')).toEqual(['flux_kontext_edit'])
|
||||
})
|
||||
|
||||
it('ranks an editing template above text-to-image for "img2img"', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'text_to_image',
|
||||
title: 'Qwen Text to Image',
|
||||
tags: ['Text to Image']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'image_edit',
|
||||
title: 'Qwen Image Edit',
|
||||
tags: ['Image Edit']
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'img2img')[0]).toBe('image_edit')
|
||||
})
|
||||
|
||||
it('ranks a title match above a tag match above a description-only match', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'in_description',
|
||||
title: 'Something Else',
|
||||
description: 'mentions upscale in passing'
|
||||
}),
|
||||
buildTemplate({ name: 'in_tag', title: 'Something', tags: ['Upscale'] }),
|
||||
buildTemplate({ name: 'in_title', title: 'Upscale Studio' })
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')).toEqual([
|
||||
'in_title',
|
||||
'in_tag',
|
||||
'in_description'
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks an exact title above a title with extra words', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'with_extra', title: 'ControlNet Guidance' }),
|
||||
buildTemplate({ name: 'exact', title: 'ControlNet' })
|
||||
])
|
||||
expect(searchTemplates(index, 'controlnet')[0]).toBe('exact')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankByRelevanceThenUsage', () => {
|
||||
const hit = (id: string, score: number, usage: number): SearchResult =>
|
||||
({ id, score, usage }) as unknown as SearchResult
|
||||
|
||||
// Scores 0.93/0.965/1.0 with usages 100/50/1 form an intransitive cycle under
|
||||
// a pairwise relative-band compare (A>B, B>C, but A<C), which makes Array.sort
|
||||
// input-order-dependent. Bucketing must give one stable order for any input.
|
||||
it('produces a stable order for an intransitive cluster', () => {
|
||||
const a = hit('a', 0.93, 100)
|
||||
const b = hit('b', 0.965, 50)
|
||||
const c = hit('c', 1.0, 1)
|
||||
|
||||
const order = (hits: SearchResult[]) =>
|
||||
rankByRelevanceThenUsage(hits).map((h) => h.id)
|
||||
|
||||
const expected = order([a, b, c])
|
||||
expect(order([c, b, a])).toEqual(expected)
|
||||
expect(order([b, a, c])).toEqual(expected)
|
||||
expect(order([c, a, b])).toEqual(expected)
|
||||
})
|
||||
|
||||
it('breaks ties within a band by usage but not across bands', () => {
|
||||
const strong = hit('strong', 1.0, 1)
|
||||
const nearStrong = hit('near', 0.98, 500)
|
||||
const weak = hit('weak', 0.5, 9000)
|
||||
|
||||
const ids = rankByRelevanceThenUsage([weak, strong, nearStrong]).map(
|
||||
(h) => h.id
|
||||
)
|
||||
// near (higher usage, same band as strong) leads; weak stays last on score.
|
||||
expect(ids).toEqual(['near', 'strong', 'weak'])
|
||||
})
|
||||
})
|
||||
204
src/composables/templateSearchConfig.ts
Normal file
204
src/composables/templateSearchConfig.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import MiniSearch from 'minisearch'
|
||||
import type { SearchResult } from 'minisearch'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
// MiniSearch serializes the index but not the search options, so the tokenizer
|
||||
// and field list live here and are used at both index and query time.
|
||||
|
||||
const SEARCH_FIELDS = [
|
||||
'title',
|
||||
'description',
|
||||
'tags',
|
||||
'models',
|
||||
'name'
|
||||
] as const
|
||||
|
||||
// Usage only reorders hits within this fraction of the top score, so popularity
|
||||
// never overrides a clearly-better text match. 5% tuned empirically.
|
||||
const USAGE_TIEBREAK_BAND = 0.05
|
||||
|
||||
// Script-matched so spaced neighbors like Korean fall to the word tokenizer.
|
||||
const CJK = /[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]/u
|
||||
const CJK_RUN = new RegExp(`${CJK.source}+`, 'gu')
|
||||
|
||||
// Unigrams + bigrams so any substring of an unspaced run lands on a token.
|
||||
function cjkGrams(word: string): string[] {
|
||||
const grams: string[] = []
|
||||
for (const run of word.match(CJK_RUN) ?? []) {
|
||||
const characters = run.split('')
|
||||
grams.push(...characters)
|
||||
for (let i = 1; i < characters.length; i++) {
|
||||
grams.push(characters[i - 1] + characters[i])
|
||||
}
|
||||
}
|
||||
return grams
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits sub-parts so a term matches however it's typed: `-`/`_` splits, a
|
||||
* trailing version (`wan2.7` → `wan`, `2.7`), and CJK character grams.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens = new Set<string>()
|
||||
for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
|
||||
for (const gram of cjkGrams(word)) tokens.add(gram)
|
||||
// A pure-CJK run has no whole-word token — its grams already cover it.
|
||||
if (CJK.test(word) && !/[a-z0-9]/.test(word)) continue
|
||||
tokens.add(word)
|
||||
for (const part of word.split(/[-_]/)) {
|
||||
if (part) tokens.add(part)
|
||||
}
|
||||
const version = word.match(/^([a-z][a-z.]*?)(\d+(?:\.\d+)*)$/)
|
||||
if (version) {
|
||||
tokens.add(version[1].replace(/\.$/, ''))
|
||||
tokens.add(version[2])
|
||||
}
|
||||
}
|
||||
return [...tokens]
|
||||
}
|
||||
|
||||
// Exact for ≤3-char and digit-bearing terms; otherwise 20% of length, so a typo
|
||||
// is forgiven but `upscale` can't fuzzy-match the shorter `scale`.
|
||||
export function termFuzziness(term: string): number | false {
|
||||
return term.length <= 3 || /\d/.test(term) ? false : 0.2
|
||||
}
|
||||
|
||||
function searchOptions(combineWith: 'AND' | 'OR' = 'AND') {
|
||||
// Description demoted below default so an incidental prose mention never
|
||||
// outranks a real title/model match.
|
||||
return {
|
||||
boost: { title: 3, models: 2, tags: 2, description: 0.5 },
|
||||
prefix: true,
|
||||
fuzzy: termFuzziness,
|
||||
combineWith,
|
||||
tokenize
|
||||
}
|
||||
}
|
||||
|
||||
// `{X}2{Y}` shorthand expanded by structure (t2i, txt2img, …) rather than one
|
||||
// entry per spelling.
|
||||
const MODALITY_STEMS: Record<string, string> = {
|
||||
t: 'text',
|
||||
txt: 'text',
|
||||
text: 'text',
|
||||
i: 'image',
|
||||
img: 'image',
|
||||
image: 'image',
|
||||
v: 'video',
|
||||
vid: 'video',
|
||||
video: 'video',
|
||||
a: 'audio',
|
||||
s: 'audio',
|
||||
m: 'music'
|
||||
}
|
||||
|
||||
const SAME_MODALITY_EDIT: Record<string, string> = {
|
||||
image: 'image edit',
|
||||
video: 'video edit',
|
||||
audio: 'audio edit'
|
||||
}
|
||||
|
||||
const ACRONYMS: Record<string, string> = {
|
||||
cn: 'controlnet'
|
||||
}
|
||||
|
||||
export function expandAbbreviation(token: string): string | null {
|
||||
const lower = token.trim().toLowerCase()
|
||||
if (ACRONYMS[lower]) return ACRONYMS[lower]
|
||||
|
||||
const match = lower.match(/^([a-z]+)2([a-z]+)$/)
|
||||
if (!match) return null
|
||||
const left = MODALITY_STEMS[match[1]]
|
||||
const right = MODALITY_STEMS[match[2]]
|
||||
if (!left || !right) return null
|
||||
if (left === right) return SAME_MODALITY_EDIT[left] ?? left
|
||||
return `${left} ${right}`
|
||||
}
|
||||
|
||||
/** Expands shorthand tokens (`wan i2v` → `wan image video`); null if none expand. */
|
||||
export function expandQuery(query: string): string | null {
|
||||
let changed = false
|
||||
const out = query
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
const expansion = expandAbbreviation(token.toLowerCase())
|
||||
if (expansion) changed = true
|
||||
return expansion ?? token
|
||||
})
|
||||
.join(' ')
|
||||
return changed ? out : null
|
||||
}
|
||||
|
||||
export function createTemplateSearchIndex(
|
||||
templates: TemplateInfo[]
|
||||
): MiniSearch<TemplateInfo> {
|
||||
const index = new MiniSearch<TemplateInfo>({
|
||||
idField: 'name',
|
||||
fields: [...SEARCH_FIELDS],
|
||||
// Returned on each hit so the tiebreak can read usage without a second lookup.
|
||||
storeFields: ['usage'],
|
||||
// Index the localized strings the card actually shows, so a match explains
|
||||
// a visible result.
|
||||
extractField: (template, field) => {
|
||||
if (field === 'title') return template.localizedTitle ?? template.title
|
||||
if (field === 'description') {
|
||||
return template.localizedDescription ?? template.description ?? ''
|
||||
}
|
||||
const value = template[field as keyof TemplateInfo]
|
||||
return Array.isArray(value) ? value.join(' ') : ((value as string) ?? '')
|
||||
},
|
||||
tokenize,
|
||||
searchOptions: searchOptions('AND')
|
||||
})
|
||||
index.addAll(templates)
|
||||
return index
|
||||
}
|
||||
|
||||
// Rank by relevance, with usage breaking ties inside a score band. Scores are
|
||||
// bucketed so the ordering is a stable total order (a pairwise relative-band
|
||||
// compare is intransitive). log1p dampens heavy-tailed usage.
|
||||
export function rankByRelevanceThenUsage(hits: SearchResult[]): SearchResult[] {
|
||||
const bandSize =
|
||||
hits.reduce((max, hit) => Math.max(max, hit.score), 0) * USAGE_TIEBREAK_BAND
|
||||
const bucket = (score: number) =>
|
||||
bandSize > 0 ? Math.round(score / bandSize) : 0
|
||||
return [...hits].sort((a, b) => {
|
||||
if (bucket(a.score) !== bucket(b.score)) return b.score - a.score
|
||||
return Math.log1p(Number(b.usage ?? 0)) - Math.log1p(Number(a.usage ?? 0))
|
||||
})
|
||||
}
|
||||
|
||||
/** Ordered template names for a query: literal matches first, then dedup'd expansion matches. */
|
||||
export function searchTemplates(
|
||||
index: MiniSearch<TemplateInfo>,
|
||||
query: string
|
||||
): string[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const andThenOr = (q: string): SearchResult[] => {
|
||||
const and = index.search(q, searchOptions('AND'))
|
||||
const hits = and.length > 0 ? and : index.search(q, searchOptions('OR'))
|
||||
return rankByRelevanceThenUsage(hits)
|
||||
}
|
||||
|
||||
const ordered: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const collect = (hits: SearchResult[]) => {
|
||||
for (const hit of hits) {
|
||||
const id = String(hit.id)
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(andThenOr(trimmed))
|
||||
const expanded = expandQuery(trimmed)
|
||||
if (expanded) collect(andThenOr(expanded))
|
||||
|
||||
return ordered
|
||||
}
|
||||
@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
|
||||
)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Save3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
|
||||
})
|
||||
|
||||
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
|
||||
typeof useCanvasStore
|
||||
>
|
||||
)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
|
||||
})
|
||||
|
||||
it('attaches event listeners before running queued ready callbacks', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
let listenersAttachedWhenCallbackRan = false
|
||||
|
||||
composable.waitForLoad3d(() => {
|
||||
listenersAttachedWhenCallbackRan =
|
||||
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(listenersAttachedWhenCallbackRan).toBe(true)
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundColor: getActivePinia()
|
||||
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
: '#282828',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
handleEvents('add')
|
||||
|
||||
const callbacks = pendingCallbacks.get(node)
|
||||
|
||||
if (callbacks && load3d) {
|
||||
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
|
||||
@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
@@ -24,11 +23,11 @@ const defaultSettingStore = {
|
||||
}
|
||||
|
||||
const defaultRankingStore = {
|
||||
computeDefaultScore: vi.fn(() => 0),
|
||||
computePopularScore: vi.fn(() => 0),
|
||||
getUsageScore: vi.fn(() => 0),
|
||||
computeDefaultScore: vi.fn(
|
||||
(_date?: string, _rank?: number, usage: number = 0) => usage
|
||||
),
|
||||
computeFreshness: vi.fn(() => 0.5),
|
||||
isLoaded: { value: false }
|
||||
largestUsageScore: 0
|
||||
}
|
||||
|
||||
const mockSystemStatsStore = {
|
||||
@@ -51,9 +50,10 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn(() => mockSystemStatsStore)
|
||||
}))
|
||||
|
||||
const trackTemplateFilterChanged = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn(),
|
||||
trackTemplateFilterChanged,
|
||||
trackSearchQuery: vi.fn()
|
||||
}))
|
||||
}))
|
||||
@@ -62,20 +62,12 @@ vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getFuseOptions: mockGetFuseOptions
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
|
||||
mockSystemStatsStore.systemStats.system.os = 'linux'
|
||||
mockGetFuseOptions.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -248,117 +240,423 @@ describe('useTemplateFiltering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const customFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.5 },
|
||||
{ name: 'description', weight: 0.5 }
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true
|
||||
const usageRankedTemplates = () =>
|
||||
ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'low',
|
||||
title: 'Low',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'high',
|
||||
title: 'High',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
usage: 900
|
||||
}
|
||||
])
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
it('ranks "recommended" via computeDefaultScore', async () => {
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(
|
||||
usageRankedTemplates()
|
||||
)
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
|
||||
await loadFuseOptions()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
expect(defaultRankingStore.computeDefaultScore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toBeDefined()
|
||||
})
|
||||
it('ranks "popular" by raw usage without the recommended score', async () => {
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(
|
||||
usageRankedTemplates()
|
||||
)
|
||||
|
||||
it('does not update fuseOptions when getFuseOptions returns null', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
sortBy.value = 'popular'
|
||||
await nextTick()
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(null)
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
expect(defaultRankingStore.computeDefaultScore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await loadFuseOptions()
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('handles errors when getFuseOptions fails', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await expect(loadFuseOptions()).rejects.toThrow('Network error')
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('recreates Fuse instance when fuseOptions change', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'searchable-template',
|
||||
description: 'This is a searchable template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'another-template',
|
||||
description: 'Another template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { loadFuseOptions, searchQuery, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const customFuseOptions = {
|
||||
keys: [{ name: 'name', weight: 1.0 }],
|
||||
threshold: 0.2,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
it('filters to ComfyUI templates via the Runs On filter', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'open',
|
||||
title: 'Open',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
openSource: true
|
||||
},
|
||||
{
|
||||
name: 'partner',
|
||||
title: 'Partner',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
openSource: false
|
||||
}
|
||||
])
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
const { selectedRunsOn, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
selectedRunsOn.value = ['ComfyUI']
|
||||
await nextTick()
|
||||
|
||||
await loadFuseOptions()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'open'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts alphabetically by the localized title shown on the card', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'z-raw',
|
||||
title: 'Apple', // raw title would sort first
|
||||
localizedTitle: 'Zebra', // but the card shows this
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'a-raw',
|
||||
title: 'Zulu',
|
||||
localizedTitle: 'Ant',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
sortBy.value = 'alphabetical'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'a-raw', // Ant
|
||||
'z-raw' // Zebra
|
||||
])
|
||||
})
|
||||
|
||||
it('A-Z trims whitespace, groups numbers after letters, and orders them naturally', async () => {
|
||||
const make = (name: string, title: string): TemplateInfo => ({
|
||||
name,
|
||||
title,
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
})
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
make('ten', '1.10 Model'),
|
||||
make('two', '1.2 Model'),
|
||||
make('spaced', ' Apple'), // leading space must not jump to the top
|
||||
make('zebra', 'Zebra')
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
sortBy.value = 'alphabetical'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'spaced', // " Apple" trimmed → sorts as a letter, first
|
||||
'zebra',
|
||||
'two', // numbers grouped after letters; 1.2 before 1.10 (numeric)
|
||||
'ten'
|
||||
])
|
||||
})
|
||||
|
||||
describe('Search relevance (MiniSearch)', () => {
|
||||
const names = (templates: { name: string }[]) =>
|
||||
templates.map((template) => template.name)
|
||||
|
||||
const buildTemplate = (
|
||||
overrides: Partial<TemplateInfo> & { name: string }
|
||||
): TemplateInfo => ({
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
...overrides
|
||||
})
|
||||
|
||||
async function searchFor(templates: TemplateInfo[], query: string) {
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = query
|
||||
await nextTick()
|
||||
return composable
|
||||
}
|
||||
|
||||
it('matches "img2img" via abbreviation expansion', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'z_image_t2i',
|
||||
title: 'Z-Image: Text to Image',
|
||||
tags: ['Text to Image']
|
||||
}),
|
||||
buildTemplate({ name: 'video_gen', title: 'LTX Text to Video' })
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'img2img')
|
||||
|
||||
expect(names(filteredTemplates.value)).toContain('z_image_t2i')
|
||||
expect(names(filteredTemplates.value)).not.toContain('video_gen')
|
||||
})
|
||||
|
||||
it('matches multi-word cross-field queries like "flux upscale"', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'flux_upscale',
|
||||
title: 'Flux.1 Creative Upscale',
|
||||
models: ['Flux.1'],
|
||||
tags: ['Image Upscale']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'flux_txt2img',
|
||||
title: 'Flux.1 Text to Image',
|
||||
models: ['Flux.1']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'seedvr_upscale',
|
||||
title: 'SeedVR2 Upscale',
|
||||
tags: ['Image Upscale']
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'flux upscale')
|
||||
|
||||
expect(names(filteredTemplates.value)[0]).toBe('flux_upscale')
|
||||
})
|
||||
|
||||
it('breaks near-tied relevance by usage, dampened so it cannot override a better match', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'low_usage_upscale',
|
||||
title: 'Alpha Image Upscale',
|
||||
tags: ['Image Upscale'],
|
||||
usage: 5
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'high_usage_upscale',
|
||||
title: 'Beta Image Upscale',
|
||||
tags: ['Image Upscale'],
|
||||
usage: 5000
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'upscale')
|
||||
|
||||
// Near-identical text scores → the far more used template ranks first.
|
||||
expect(names(filteredTemplates.value)[0]).toBe('high_usage_upscale')
|
||||
})
|
||||
|
||||
it('keeps relevance order even when a usage sort is persisted', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'exact_match',
|
||||
title: 'Outpaint Studio',
|
||||
tags: ['Outpaint'],
|
||||
usage: 1
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'popular_weak_match',
|
||||
title: 'Portrait Generator',
|
||||
description: 'supports outpaint as a minor feature',
|
||||
usage: 9000
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.sortBy.value = 'popular'
|
||||
composable.searchQuery.value = 'outpaint'
|
||||
await nextTick()
|
||||
|
||||
searchQuery.value = 'searchable'
|
||||
// Search defaults to relevance regardless of the persisted browse sort,
|
||||
// so the exact title match wins over the high-usage weak match.
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe('exact_match')
|
||||
})
|
||||
|
||||
it('lets the user override relevance with another sort while searching', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'exact_low_usage',
|
||||
title: 'Outpaint Studio',
|
||||
usage: 1
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'weak_high_usage',
|
||||
title: 'Portrait',
|
||||
description: 'outpaint mentioned once',
|
||||
usage: 9000
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'outpaint'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe(
|
||||
'exact_low_usage'
|
||||
)
|
||||
|
||||
composable.sortSelection.value = 'popular'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('popular')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe(
|
||||
'weak_high_usage'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the browse sort when the search is cleared and keeps relevance ephemeral', async () => {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'popular'
|
||||
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
// Browse sort is untouched by the search; relevance is never persisted.
|
||||
expect(composable.sortSelection.value).toBe('popular')
|
||||
expect(composable.sortBy.value).toBe('popular')
|
||||
})
|
||||
|
||||
it('keeps a browse sort chosen mid-search ephemeral', async () => {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'newest'
|
||||
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
// Simulates the nav coordinator picking Popular during a search.
|
||||
composable.sortSelection.value = 'popular'
|
||||
await nextTick()
|
||||
expect(composable.sortBy.value).toBe('newest') // persisted sort untouched
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('newest')
|
||||
})
|
||||
|
||||
it('returns no results for a query that matches nothing', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'flux_image', title: 'Flux Image' })
|
||||
]
|
||||
|
||||
const { filteredTemplates, filteredCount } = await searchFor(
|
||||
templates,
|
||||
'zzzznomatch'
|
||||
)
|
||||
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
expect(filteredCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the localized title the card displays', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'localized_only',
|
||||
title: 'raw english',
|
||||
localizedTitle: 'aquarela'
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'aquarela')
|
||||
|
||||
expect(names(filteredTemplates.value)).toEqual(['localized_only'])
|
||||
})
|
||||
|
||||
it('reports the visible sort to telemetry, not the persisted browse sort', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'popular'
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
// Searching shows relevance, so telemetry must report relevance, not popular.
|
||||
expect(trackTemplateFilterChanged).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sort_by: 'relevance' })
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves relevance order after a model filter narrows the results', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'strong',
|
||||
title: 'Flux Upscale Pro',
|
||||
models: ['Flux'],
|
||||
tags: ['Upscale']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'weak',
|
||||
title: 'Flux Portrait',
|
||||
models: ['Flux'],
|
||||
description: 'mentions upscale once'
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'upscale'
|
||||
composable.selectedModels.value = ['Flux']
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.length).toBeGreaterThan(0)
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
// The filter keeps the search order; the stronger match stays first.
|
||||
expect(names(composable.filteredTemplates.value)).toEqual([
|
||||
'strong',
|
||||
'weak'
|
||||
])
|
||||
})
|
||||
|
||||
it('narrows to the query then restores the full set when cleared', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'alpha_one', title: 'Alpha' }),
|
||||
buildTemplate({ name: 'beta_two', title: 'Beta' })
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'alpha'
|
||||
await nextTick()
|
||||
expect(names(composable.filteredTemplates.value)).toEqual(['alpha_one'])
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
expect(names(composable.filteredTemplates.value)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('records a non-negative largest usage score after an empty-result search', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'gamma', title: 'Gamma', usage: 3 }),
|
||||
buildTemplate({ name: 'delta', title: 'Delta', usage: 7 })
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'zzzznomatch')
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
|
||||
// Empty results must yield 0, not -Infinity, which would corrupt
|
||||
// usage-normalized ranking scores.
|
||||
expect(defaultRankingStore.largestUsageScore).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
createTemplateSearchIndex,
|
||||
searchTemplates
|
||||
} from '@/composables/templateSearchConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
@@ -12,7 +14,40 @@ import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
type TemplateBrowseSort =
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
|
||||
type TemplateSortMode = TemplateBrowseSort | 'relevance'
|
||||
|
||||
/** The title shown on the card, trimmed for stable sorting. */
|
||||
function displayTitle(template: TemplateInfo): string {
|
||||
return (
|
||||
template.localizedTitle ||
|
||||
template.title ||
|
||||
template.name ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** A→Z by displayed title, with number-prefixed titles grouped after letters. */
|
||||
function compareAlphabetical(a: TemplateInfo, b: TemplateInfo): number {
|
||||
const titleA = displayTitle(a)
|
||||
const titleB = displayTitle(b)
|
||||
const numericA = /^\d/.test(titleA)
|
||||
const numericB = /^\d/.test(titleB)
|
||||
if (numericA !== numericB) return numericA ? 1 : -1
|
||||
return titleA.localeCompare(titleB, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a template is visible for the given set of distributions.
|
||||
@@ -26,20 +61,6 @@ function isTemplateVisibleForDistributions(
|
||||
return distributions.some((d) => template.includeOnDistributions!.includes(d))
|
||||
}
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'models', weight: 0.3 }
|
||||
],
|
||||
threshold: 0.33,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
@@ -57,17 +78,9 @@ export function useTemplateFiltering(
|
||||
const selectedRunsOn = ref<string[]>(
|
||||
settingStore.get('Comfy.Templates.SelectedRunsOn')
|
||||
)
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
>(settingStore.get('Comfy.Templates.SortBy'))
|
||||
|
||||
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
|
||||
const sortBy = ref<TemplateBrowseSort>(
|
||||
settingStore.get('Comfy.Templates.SortBy')
|
||||
)
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
@@ -101,8 +114,8 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const fuse = computed(
|
||||
() => new Fuse(visibleTemplates.value, fuseOptions.value)
|
||||
const searchIndex = computed(() =>
|
||||
createTemplateSearchIndex(visibleTemplates.value)
|
||||
)
|
||||
|
||||
const availableModels = computed(() => {
|
||||
@@ -154,15 +167,36 @@ export function useTemplateFiltering(
|
||||
)
|
||||
)
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
const hasActiveQuery = computed(() => searchQuery.value.trim().length > 0)
|
||||
const searchSort = ref<TemplateSortMode>('relevance')
|
||||
watch(hasActiveQuery, (searching) => {
|
||||
if (searching) searchSort.value = 'relevance'
|
||||
})
|
||||
const activeSort = computed(() =>
|
||||
hasActiveQuery.value ? searchSort.value : sortBy.value
|
||||
)
|
||||
|
||||
const sortSelection = computed<TemplateSortMode>({
|
||||
get: () => activeSort.value,
|
||||
set: (value) => {
|
||||
// relevance is search-only; a browse sort chosen mid-search stays ephemeral.
|
||||
if (value === 'relevance' || hasActiveQuery.value)
|
||||
searchSort.value = value
|
||||
else sortBy.value = value
|
||||
}
|
||||
})
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
if (!hasActiveQuery.value) {
|
||||
return visibleTemplates.value
|
||||
}
|
||||
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
const templatesByName = new Map(
|
||||
visibleTemplates.value.map((template) => [template.name, template])
|
||||
)
|
||||
return searchTemplates(searchIndex.value, searchQuery.value)
|
||||
.map((name) => templatesByName.get(name))
|
||||
.filter((template): template is TemplateInfo => template !== undefined)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
@@ -224,8 +258,9 @@ export function useTemplateFiltering(
|
||||
watch(
|
||||
filteredByRunsOn,
|
||||
(templates) => {
|
||||
rankingStore.largestUsageScore = Math.max(
|
||||
...templates.map((t) => t.usage || 0)
|
||||
rankingStore.largestUsageScore = templates.reduce(
|
||||
(max, template) => Math.max(max, template.usage ?? 0),
|
||||
0
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -234,7 +269,9 @@ export function useTemplateFiltering(
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByRunsOn.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
switch (activeSort.value) {
|
||||
case 'relevance':
|
||||
return templates
|
||||
case 'recommended':
|
||||
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
return templates.sort((a, b) => {
|
||||
@@ -251,18 +288,9 @@ export function useTemplateFiltering(
|
||||
return scoreB - scoreA
|
||||
})
|
||||
case 'popular':
|
||||
// User-driven: usage × 0.9 + freshness × 0.1
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
return templates.sort((a, b) => (b.usage ?? 0) - (a.usage ?? 0))
|
||||
case 'alphabetical':
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
return templates.sort(compareAlphabetical)
|
||||
case 'newest':
|
||||
return templates.sort((a, b) => {
|
||||
const dateA = new Date(a.date || '1970-01-01')
|
||||
@@ -292,6 +320,7 @@ export function useTemplateFiltering(
|
||||
selectedUseCases.value = []
|
||||
selectedRunsOn.value = []
|
||||
sortBy.value = 'default'
|
||||
searchSort.value = 'relevance'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
@@ -317,22 +346,15 @@ export function useTemplateFiltering(
|
||||
selected_models: selectedModels.value,
|
||||
selected_use_cases: selectedUseCases.value,
|
||||
selected_runs_on: selectedRunsOn.value,
|
||||
sort_by: sortBy.value,
|
||||
sort_by: activeSort.value,
|
||||
filtered_count: filteredCount.value,
|
||||
total_count: totalCount.value
|
||||
})
|
||||
}, 500)
|
||||
|
||||
const loadFuseOptions = async () => {
|
||||
const fetchedOptions = await api.getFuseOptions()
|
||||
if (fetchedOptions) {
|
||||
fuseOptions.value = fetchedOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for filter changes and track them
|
||||
watch(
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, activeSort],
|
||||
() => {
|
||||
// Only track if at least one filter is active (to avoid tracking initial state)
|
||||
const hasActiveFilters =
|
||||
@@ -340,7 +362,7 @@ export function useTemplateFiltering(
|
||||
selectedModels.value.length > 0 ||
|
||||
selectedUseCases.value.length > 0 ||
|
||||
selectedRunsOn.value.length > 0 ||
|
||||
sortBy.value !== 'default'
|
||||
activeSort.value !== 'default'
|
||||
|
||||
if (hasActiveFilters) {
|
||||
debouncedTrackFilterChange()
|
||||
@@ -389,6 +411,8 @@ export function useTemplateFiltering(
|
||||
selectedUseCases,
|
||||
selectedRunsOn,
|
||||
sortBy,
|
||||
sortSelection,
|
||||
hasActiveQuery,
|
||||
|
||||
// Computed - Active filters (actually applied)
|
||||
activeModels,
|
||||
@@ -410,7 +434,6 @@ export function useTemplateFiltering(
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeRunsOnFilter,
|
||||
loadFuseOptions
|
||||
removeRunsOnFilter
|
||||
}
|
||||
}
|
||||
|
||||
71
src/composables/useTextFileContent.test.ts
Normal file
71
src/composables/useTextFileContent.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTextFileContent } from '@/composables/useTextFileContent'
|
||||
|
||||
function stubFetch(response: Partial<Response> | Error) {
|
||||
const mock =
|
||||
response instanceof Error
|
||||
? vi.fn().mockRejectedValue(response)
|
||||
: vi.fn().mockResolvedValue(response)
|
||||
vi.stubGlobal('fetch', mock)
|
||||
return mock
|
||||
}
|
||||
|
||||
describe(useTextFileContent, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns inline content without fetching', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent } = useTextFileContent(() => ({
|
||||
content: 'inline text',
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('inline text'))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches text from the url when no inline content is present', async () => {
|
||||
const fetchMock = stubFetch({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('fetched text')
|
||||
})
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(textContent.value).toBe('fetched text'))
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://example.com/file.txt')
|
||||
expect(hasError.value).toBe(false)
|
||||
})
|
||||
|
||||
it('flags an error for a non-ok response', async () => {
|
||||
stubFetch({ ok: false })
|
||||
const { textContent, hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/missing.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
expect(textContent.value).toBe('')
|
||||
})
|
||||
|
||||
it('flags an error when the fetch rejects', async () => {
|
||||
stubFetch(new Error('network down'))
|
||||
const { hasError } = useTextFileContent(() => ({
|
||||
url: 'http://example.com/file.txt'
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => expect(hasError.value).toBe(true))
|
||||
})
|
||||
|
||||
it('resolves empty content when there is no source', async () => {
|
||||
const fetchMock = stubFetch(new Error('should not be called'))
|
||||
const { textContent, isLoading } = useTextFileContent(() => undefined)
|
||||
|
||||
await vi.waitFor(() => expect(isLoading.value).toBe(false))
|
||||
expect(textContent.value).toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
40
src/composables/useTextFileContent.ts
Normal file
40
src/composables/useTextFileContent.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface TextSource {
|
||||
content?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function useTextFileContent(
|
||||
source: MaybeRefOrGetter<TextSource | undefined>
|
||||
) {
|
||||
const isLoading = ref(false)
|
||||
const hasError = ref(false)
|
||||
|
||||
const textContent = computedAsync(
|
||||
async () => {
|
||||
hasError.value = false
|
||||
const { content, url } = toValue(source) ?? {}
|
||||
if (content !== undefined) return content
|
||||
if (!url) return ''
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
hasError.value = true
|
||||
return ''
|
||||
}
|
||||
return await response.text()
|
||||
},
|
||||
'',
|
||||
{
|
||||
evaluating: isLoading,
|
||||
onError: () => {
|
||||
hasError.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { textContent, isLoading, hasError }
|
||||
}
|
||||
@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
{ name: 'other', hidden: false, options: {} },
|
||||
{ name: 'last_incoming', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('always hides the internal last_incoming widget', () => {
|
||||
for (const connected of [true, false]) {
|
||||
const node = makeNode(connected)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[3].hidden).toBe(true)
|
||||
expect(node.widgets[3].options.hidden).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
const INTERNAL_WIDGETS = new Set(['last_incoming'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const setWidgetHidden = (
|
||||
widget: NonNullable<typeof node.widgets>[number],
|
||||
hidden: boolean
|
||||
) => {
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
|
||||
@@ -21,6 +21,7 @@ if (!isCloud) {
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './previewAny'
|
||||
import './saveText'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)
|
||||
|
||||
@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
save3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
load3DExt: extByName('Comfy.Load3D'),
|
||||
preview3DExt: extByName('Comfy.Preview3D'),
|
||||
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
|
||||
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,14 +273,15 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('restores the saved model from the output folder when opened from history', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['3d\\ComfyUI_00001.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001.glb',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['mesh.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder, not temp', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
comfyClass: 'Save3DAdvanced',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
|
||||
})
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted loads the saved file from the output folder', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'Save3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
'PreviewPointCloud',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
function applyPreview3DAdvancedResult(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
result: NonNullable<Preview3DAdvancedOutput['result']>,
|
||||
loadFolder: LoadFolder,
|
||||
comfyClass: string
|
||||
): void {
|
||||
const filePath = result[0]
|
||||
if (!filePath) return
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!cameraState && !modelTransform) return
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
function createPreview3DAdvancedExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
return {
|
||||
name: extensionName,
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as Preview3DAdvancedOutput).result
|
||||
if (!result?.[0]) continue
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
load3d,
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
await nextTick()
|
||||
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to restore camera state for ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
if (!result?.[0]) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Preview3DAdvanced',
|
||||
'Comfy.Preview3DAdvanced',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Save3DAdvanced',
|
||||
'Comfy.Save3DAdvanced',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user