diff --git a/.claude/skills/add-model-page/SKILL.md b/.claude/skills/add-model-page/SKILL.md
new file mode 100644
index 0000000000..0b7fe4b81e
--- /dev/null
+++ b/.claude/skills/add-model-page/SKILL.md
@@ -0,0 +1,173 @@
+---
+name: add-model-page
+description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
+---
+
+# add-model-page
+
+add, update, or remove model pages in the ComfyUI website.
+
+## Trigger phrases
+
+- `Add a model page for `
+- `Update the model page for `
+- `Remove from model pages`
+
+## Phase 1 — Parse the request
+
+Extract:
+
+- **action**: `add` | `update` | `remove`
+- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
+
+Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
+Example: `flux1_dev.safetensors` → `flux1-dev`
+
+## Architecture overview
+
+Models come from two sources merged at build time:
+
+| File | Purpose |
+| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
+| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
+| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
+
+To regenerate the JSON from workflow_templates:
+
+```bash
+pnpm tsx apps/website/scripts/generate-models.ts
+```
+
+This writes `apps/website/src/config/generated-models.json` directly.
+Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
+
+---
+
+## Phase 2 — Gather model data (ADD / UPDATE)
+
+Run the generator to get fresh data, then find the model:
+
+```bash
+pnpm tsx apps/website/scripts/generate-models.ts
+jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
+ apps/website/src/config/generated-models.json
+```
+
+The JSON fields are:
+
+- `slug` — URL slug
+- `name` — exact filename or display name for partner nodes
+- `huggingFaceUrl` — download URL (empty for partner nodes)
+- `directory` — `diffusion_models` | `loras` | … | `partner_nodes`
+- `workflowCount` — integer
+- `displayName` — human-readable name
+
+If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
+`generate-models.ts` and re-run. Otherwise tell the user.
+
+---
+
+## Phase 3 — Check for existing entry
+
+```bash
+jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
+ apps/website/src/config/generated-models.json
+```
+
+- Match found + action is `add` → switch to UPDATE flow automatically
+- No match + action is `update` → stop and tell the user
+
+---
+
+## Phase 4A — ADD: new partner/API model not in workflow_templates
+
+For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
+`apps/website/scripts/generate-models.ts`:
+
+```typescript
+mymodel: { name: 'My Model', slug: 'my-model' },
+```
+
+Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
+in `generated-models.json` automatically.
+
+If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
+
+```typescript
+'my-model': {
+ docsUrl: 'https://docs.comfy.org/tutorials/...',
+ blogUrl: 'https://blog.comfy.org/...',
+ hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
+ featured: true
+}
+```
+
+No changes to `models.ts` or `translations.ts` are needed.
+
+---
+
+## Phase 4B — UPDATE: edit existing entry
+
+Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
+featured). For `displayName` or `directory` changes, edit the entry directly in
+`generated-models.json` (until the next generator run would overwrite it — then
+fix the source in `generate-models.ts`).
+
+---
+
+## Phase 4C — REMOVE: delete entry
+
+Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
+pointing to the replacement). No translation file changes needed.
+
+---
+
+## Phase 5 — Verify TypeScript
+
+```bash
+pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
+```
+
+Fix any type errors before proceeding. Common issues:
+
+- `ModelDirectory` type not matching a new `directory` value — add it to the union
+- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
+
+---
+
+## Phase 6 — Create PR
+
+```bash
+BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
+git checkout -b $BRANCH
+git add apps/website/src/config/generated-models.json \
+ apps/website/scripts/generate-models.ts \
+ apps/website/src/config/model-metadata.ts
+git commit -m "feat(models): add model page for MODEL-SLUG"
+git push -u origin $BRANCH
+gh pr create \
+ --title "Add model page: MODEL-SLUG" \
+ --body "$(cat <<'EOF'
+Adds a new model page entry for MODEL-SLUG.
+
+## Changes
+- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
+- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
+EOF
+)"
+```
+
+For UPDATE use branch `update-model-page-MODEL-SLUG`.
+For REMOVE use `remove-model-page-MODEL-SLUG`.
+
+---
+
+## Error states
+
+| Situation | Response |
+| ------------------------------- | ---------------------------------------------------------------- |
+| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
+| Slug already exists (add) | Switch to update flow automatically |
+| Slug not found (update/remove) | Stop and ask user to confirm |
+| Typecheck fails | Fix the error before pushing |
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 20af82bec6..35c5d31183 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -19,15 +19,26 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
- Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
- Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
+ Use only PR metadata already available in the review context:
+ - the PR title
+ - commit subjects in this PR
+ - The files changed in this PR relative to the PR base (equivalent to `base...head`)
+ - the PR description.
+ Do not rely on shell commands.
+ Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
+ If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
- Pass if at least one of the following is true:
- 1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
- 2. The PR changes at least one file under `browser_tests/`.
- 3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
+ Fail if all of the following are true:
+ 1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
+ 2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
+ 3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
+
+ Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
+ The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
+
+ Pass otherwise.
+ When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |
diff --git a/.github/workflows/model-page-discovery.yaml b/.github/workflows/model-page-discovery.yaml
new file mode 100644
index 0000000000..457c7546aa
--- /dev/null
+++ b/.github/workflows/model-page-discovery.yaml
@@ -0,0 +1,123 @@
+name: Model Page Discovery
+
+on:
+ schedule:
+ - cron: '0 9 * * 1'
+ workflow_dispatch:
+
+jobs:
+ discover:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+
+ steps:
+ - name: Fetch model labels from hub API
+ id: hub
+ shell: bash
+ run: |
+ set -euo pipefail
+ curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
+ echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
+
+ - name: Checkout ComfyUI_frontend
+ uses: actions/checkout@v6
+ with:
+ sparse-checkout: apps/website/src/config/generated-models.json
+
+ - name: Compare against existing models
+ id: compare
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
+
+ EXISTING_SLUGS=$(node -e "
+ const fs = require('fs');
+ const models = JSON.parse(
+ fs.readFileSync(
+ 'apps/website/src/config/generated-models.json',
+ 'utf8'
+ )
+ );
+ console.log(JSON.stringify(models.map(m => m.slug)));
+ " 2>/dev/null || echo '[]')
+
+ ADDED_SLUGS=$(node -e "
+ const hub = $HUB_SLUGS;
+ const existing = new Set($EXISTING_SLUGS);
+ console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
+ ")
+
+ COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
+ echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
+ echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
+
+ if [ "\$COUNT" -eq 0 ]; then
+ echo "No new models found."
+ else
+ echo "Found \$COUNT new model(s)"
+ fi
+
+ - name: Check for existing open discovery issue
+ id: existing_issue
+ if: steps.compare.outputs.new_count != '0'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ COUNT=$(gh issue list \
+ --repo "$GITHUB_REPOSITORY" \
+ --state open \
+ --search 'in:title "New models detected"' \
+ --json number \
+ --jq 'length')
+ echo "open_count=$COUNT" >> $GITHUB_OUTPUT
+
+ - name: Open GitHub issue for new models
+ if: |
+ steps.compare.outputs.new_count != '0' &&
+ steps.existing_issue.outputs.open_count == '0'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
+ NEW_COUNT: ${{ steps.compare.outputs.new_count }}
+ shell: bash
+ run: |
+ SLUG_LIST=$(node -e "
+ const slugs = $NEW_SLUGS;
+ console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
+ ")
+
+ gh issue create \
+ --repo "$GITHUB_REPOSITORY" \
+ --title "New models detected — add to model pages" \
+ --body "## $NEW_COUNT new model(s) found in hub
+
+ The weekly model discovery scan found model labels on the hub not yet in
+ \`apps/website/src/config/generated-models.json\`.
+
+ ### New slugs ($NEW_COUNT)
+
+ $SLUG_LIST
+
+ ### Next steps
+
+ 1. Review which of these warrant an SEO model page
+ 2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
+ 3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
+
+ ---
+ *Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
+
+ - name: Skip — open issue already exists
+ if: |
+ steps.compare.outputs.new_count != '0' &&
+ steps.existing_issue.outputs.open_count != '0'
+ run: echo "An open discovery issue already exists — skipping creation."
+
+ - name: No new models found
+ if: steps.compare.outputs.new_count == '0'
+ run: echo "No new models found — nothing to do."
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 19700f80c2..66142ab5ca 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -85,6 +85,15 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
+ "vitest/consistent-each-for": [
+ "error",
+ {
+ "test": "for",
+ "it": "for",
+ "describe": "for",
+ "suite": "for"
+ }
+ ],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",
diff --git a/apps/desktop-ui/src/i18n.ts b/apps/desktop-ui/src/i18n.ts
index 52db23e091..fa3367286d 100644
--- a/apps/desktop-ui/src/i18n.ts
+++ b/apps/desktop-ui/src/i18n.ts
@@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
+import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -167,7 +168,7 @@ const messages: Record = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
- locale: navigator.language.split('-')[0] || 'en',
+ locale: getDefaultLocale(),
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.
diff --git a/apps/website/README.md b/apps/website/README.md
index 047940ed02..94468614c4 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -113,6 +113,12 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
+## Cloud nodes integration
+
+`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
+
+Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
+
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -146,3 +152,4 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
+- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot
diff --git a/apps/website/e2e/careers.spec.ts b/apps/website/e2e/careers.spec.ts
index 6f97c99f86..22263c1bfc 100644
--- a/apps/website/e2e/careers.spec.ts
+++ b/apps/website/e2e/careers.spec.ts
@@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
- test('each role links to jobs.ashbyhq.com', async ({ page }) => {
+ test('clicking a department button scrolls to and activates that section', async ({
+ page
+ }) => {
+ const rolesSection = page.getByTestId('careers-roles')
+ await rolesSection.scrollIntoViewIfNeeded()
+ await expect(rolesSection).toBeVisible()
+
+ const allCount = await page.getByTestId('careers-role-link').count()
+
+ const engineeringButton = page.getByRole('button', {
+ name: 'ENGINEERING',
+ exact: true
+ })
+
+ // RolesSection is hydrated via `client:visible`. Once the button responds
+ // to a click by flipping aria-pressed, Vue is hydrated and the rest of
+ // the locator logic is in effect.
+ await expect(async () => {
+ await engineeringButton.click()
+ await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
+ timeout: 1_000
+ })
+ }).toPass({ timeout: 10_000 })
+
+ const engineeringSection = page.locator('#careers-dept-engineering')
+ await expect(engineeringSection).toBeInViewport()
+
+ expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
+ })
+})
+
+test.describe('Careers page role links', () => {
+ test('each role links to the Ashby job description page, not the application form', async ({
+ page
+ }) => {
+ await page.goto('/careers')
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
+ expect(href).not.toMatch(/\/application\/?$/)
}
})
-
- test('ENGINEERING category filter narrows the role list', async ({
- page
- }) => {
- const allCount = await page.getByTestId('careers-role-link').count()
- await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
- const engineeringLocator = page.getByTestId('careers-role-link')
- await expect(engineeringLocator.first()).toBeVisible()
- const engineeringCount = await engineeringLocator.count()
- expect(engineeringCount).toBeLessThanOrEqual(allCount)
- expect(engineeringCount).toBeGreaterThan(0)
- })
})
test.describe('Careers page (zh-CN) @smoke', () => {
diff --git a/apps/website/e2e/cloud-nodes.spec.ts b/apps/website/e2e/cloud-nodes.spec.ts
new file mode 100644
index 0000000000..9f99bf453b
--- /dev/null
+++ b/apps/website/e2e/cloud-nodes.spec.ts
@@ -0,0 +1,169 @@
+import { expect } from '@playwright/test'
+
+import { test } from './fixtures/blockExternalMedia'
+
+test.describe('Cloud nodes page @smoke', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/cloud/supported-nodes')
+ })
+
+ test('has correct title', async ({ page }) => {
+ await expect(page).toHaveTitle(
+ 'Custom-node packs on Comfy Cloud — supported by default'
+ )
+ })
+
+ test('renders at least one pack card', async ({ page }) => {
+ const cards = page.getByTestId('cloud-node-pack-card')
+ await expect(cards.first()).toBeVisible()
+ expect(await cards.count()).toBeGreaterThan(0)
+ })
+
+ test('search input filters cards down', async ({ page }) => {
+ const cards = page.getByTestId('cloud-node-pack-card')
+ const initialCount = await cards.count()
+
+ await page.getByTestId('cloud-nodes-search').fill('impact')
+ const filteredCards = page.getByTestId('cloud-node-pack-card')
+ await expect(filteredCards.first()).toBeVisible()
+ const filteredCount = await filteredCards.count()
+
+ expect(filteredCount).toBeGreaterThan(0)
+ expect(filteredCount).toBeLessThanOrEqual(initialCount)
+ })
+
+ test('renders banner image or fallback element', async ({ page }) => {
+ const banners = page.getByTestId('cloud-node-pack-banner')
+ await expect(banners.first()).toBeVisible()
+ })
+
+ test('opens pack detail page from first card', async ({ page }) => {
+ const firstCard = page.getByTestId('cloud-node-pack-card').first()
+ await expect(firstCard).toBeVisible()
+
+ await firstCard.locator('a').first().click()
+
+ await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
+ await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
+ })
+
+ test('direct pack detail route renders node entries', async ({ page }) => {
+ await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
+ await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
+ await expect(
+ page.getByTestId('cloud-node-pack-detail-node').first()
+ ).toBeVisible()
+ })
+
+ test('search with no matches shows empty state', async ({ page }) => {
+ await page
+ .getByTestId('cloud-nodes-search')
+ .fill('zzzz-this-pack-does-not-exist')
+ await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
+ await expect(page.getByText('No matching packs')).toBeVisible()
+ })
+
+ test('clearing search restores the full list', async ({ page }) => {
+ const cards = page.getByTestId('cloud-node-pack-card')
+ const initialCount = await cards.count()
+
+ await page.getByTestId('cloud-nodes-search').fill('impact')
+ await expect(cards.first()).toBeVisible()
+
+ await page.getByTestId('cloud-nodes-search').fill('')
+ await expect(cards).toHaveCount(initialCount)
+ })
+
+ test('search matches against node display names, not just pack names', async ({
+ page
+ }) => {
+ await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
+ await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
+ await expect(
+ page.getByTestId('cloud-node-pack-card-link').first()
+ ).toContainText('Impact Pack')
+ })
+
+ test('switching sort to A → Z reorders cards alphabetically', async ({
+ page
+ }) => {
+ await page.locator('#cloud-nodes-sort').selectOption('az')
+ const firstName = await page
+ .getByTestId('cloud-node-pack-card-link')
+ .first()
+ .textContent()
+ expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
+ })
+
+ test('list grid carries a localized aria-label', async ({ page }) => {
+ await expect(
+ page.getByRole('list', {
+ name: 'Custom-node packs supported on Comfy Cloud'
+ })
+ ).toBeVisible()
+ })
+
+ test('clicking the back link returns to the index from a detail page', async ({
+ page
+ }) => {
+ await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
+ await page.getByRole('link', { name: 'Back to all packs' }).click()
+ await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
+ await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
+ })
+
+ test('detail page renders publisher and external repo link', async ({
+ page
+ }) => {
+ await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
+ const repoLink = page.getByRole('link', {
+ name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
+ })
+ await expect(repoLink).toBeVisible()
+ await expect(repoLink).toHaveAttribute('rel', /noopener/)
+ })
+
+ test('unknown pack slug 404s', async ({ page }) => {
+ const response = await page.goto(
+ '/cloud/supported-nodes/this-pack-does-not-exist'
+ )
+ expect(response?.status()).toBe(404)
+ })
+
+ test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
+ const jsonLd = page.locator('script[type="application/ld+json"]')
+ const ldBlocks = await jsonLd.allTextContents()
+ expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
+ })
+
+ test('JSON-LD payload escapes <-sequences', async ({ page }) => {
+ const ldBlocks = await page
+ .locator('script[type="application/ld+json"]')
+ .allTextContents()
+ for (const block of ldBlocks) {
+ expect(block).not.toContain(' {
+ test('renders localized title and packs', async ({ page }) => {
+ await page.goto('/zh-CN/cloud/supported-nodes')
+ await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
+ await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
+ await expect(
+ page.getByTestId('cloud-node-pack-banner').first()
+ ).toBeVisible()
+ })
+
+ test('opens pack detail page from first card', async ({ page }) => {
+ await page.goto('/zh-CN/cloud/supported-nodes')
+ const firstCard = page.getByTestId('cloud-node-pack-card').first()
+ await expect(firstCard).toBeVisible()
+
+ await firstCard.locator('a').first().click()
+
+ await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
+ await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
+ })
+})
diff --git a/apps/website/e2e/content-section.spec.ts b/apps/website/e2e/content-section.spec.ts
new file mode 100644
index 0000000000..6d1a6d9265
--- /dev/null
+++ b/apps/website/e2e/content-section.spec.ts
@@ -0,0 +1,61 @@
+import { expect } from '@playwright/test'
+
+import { test } from './fixtures/blockExternalMedia'
+
+const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
+const LAST_SECTION_HASH = '#contact'
+
+test.describe(
+ 'ContentSection scroll-spy @smoke',
+ {
+ annotation: [
+ {
+ type: 'issue',
+ description:
+ 'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
+ },
+ {
+ type: 'environment',
+ description:
+ '14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
+ }
+ ]
+ },
+ () => {
+ test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
+
+ test('activates the last badge when user scrolls to the bottom', async ({
+ page
+ }) => {
+ await page.goto('/privacy-policy')
+
+ const sidebarNav = page.getByRole('navigation', {
+ name: 'Category filter'
+ })
+ const badges = sidebarNav.getByRole('button')
+ const lastBadge = badges.last()
+
+ await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
+ await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
+
+ await page.evaluate(() =>
+ window.scrollTo(0, document.documentElement.scrollHeight)
+ )
+
+ await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
+ })
+
+ test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
+ page
+ }) => {
+ await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
+
+ const sidebarNav = page.getByRole('navigation', {
+ name: 'Category filter'
+ })
+ const lastBadge = sidebarNav.getByRole('button').last()
+
+ await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
+ })
+ }
+)
diff --git a/apps/website/e2e/demos.spec.ts b/apps/website/e2e/demos.spec.ts
index edd97f5daf..bbf333e3e6 100644
--- a/apps/website/e2e/demos.spec.ts
+++ b/apps/website/e2e/demos.spec.ts
@@ -1,27 +1,71 @@
import { expect, test } from '@playwright/test'
+import { demos, getNextDemo } from '../src/config/demos'
+import { t } from '../src/i18n/translations'
+
+const escapeRegExp = (value: string): string =>
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
test.describe('Demo pages @smoke', () => {
- test('demo detail page renders hero and embed', async ({ page }) => {
- await page.goto('/demos/image-to-video')
- await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
- await expect(page.getByRole('heading', { level: 1 })).toContainText(
- 'Create a Video from an Image'
- )
- const iframe = page.locator('iframe[title*="Interactive demo"]')
- await expect(iframe).toBeAttached()
- })
+ for (const demo of demos) {
+ const nextDemo = getNextDemo(demo.slug)
- test('demo detail page has transcript section', async ({ page }) => {
- await page.goto('/demos/image-to-video')
- await expect(
- page.getByRole('button', { name: /demo transcript/i })
- ).toBeVisible()
- })
+ test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
+ page
+ }) => {
+ await page.goto(`/demos/${demo.slug}`)
- test('demo detail page has next demo navigation', async ({ page }) => {
- await page.goto('/demos/image-to-video')
- await expect(page.getByText(/what's next/i)).toBeVisible()
- })
+ const heading = page.getByRole('heading', { level: 1 })
+ await expect(heading).toBeVisible()
+ await expect(heading).toContainText(t(demo.title, 'en'))
+
+ const ogImage = page.locator('head meta[property="og:image"]')
+ await expect(ogImage).toHaveAttribute(
+ 'content',
+ new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
+ )
+
+ const iframe = page.locator(
+ `iframe[title*="${t('demos.embed.label', 'en')}"]`
+ )
+ await expect(iframe).toBeAttached()
+ await expect(iframe).toHaveAttribute(
+ 'src',
+ new RegExp(escapeRegExp(demo.arcadeId))
+ )
+
+ await expect(
+ page.getByRole('button', { name: /demo transcript/i })
+ ).toBeVisible()
+
+ await expect(
+ page.getByText(t(nextDemo.title, 'en')).first()
+ ).toBeVisible()
+ const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
+ await expect(nextThumb).toBeAttached()
+ await expect(nextThumb).toBeVisible()
+ const naturalWidth = await nextThumb.evaluate(
+ (img) => (img as HTMLImageElement).naturalWidth
+ )
+ expect(naturalWidth).toBeGreaterThan(1)
+ })
+
+ test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
+ page
+ }) => {
+ await page.goto(`/zh-CN/demos/${demo.slug}`)
+
+ await expect(page).toHaveURL(/\/zh-CN\/demos\//)
+
+ const heading = page.getByRole('heading', { level: 1 })
+ await expect(heading).toContainText(t(demo.title, 'zh-CN'))
+ await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
+
+ await expect(
+ page.getByText(t(nextDemo.title, 'zh-CN')).first()
+ ).toBeVisible()
+ })
+ }
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
-
- test('zh-CN demo page renders localized content', async ({ page }) => {
- await page.goto('/zh-CN/demos/image-to-video')
- await expect(page.getByRole('heading', { level: 1 })).toContainText(
- '从图片创建视频'
- )
- const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
- await expect(nextDemoLink).toBeAttached()
- })
})
diff --git a/apps/website/e2e/pricing.spec.ts b/apps/website/e2e/pricing.spec.ts
new file mode 100644
index 0000000000..05520f0e5b
--- /dev/null
+++ b/apps/website/e2e/pricing.spec.ts
@@ -0,0 +1,56 @@
+import { expect } from '@playwright/test'
+
+import { test } from './fixtures/blockExternalMedia'
+
+test.describe('Pricing page @smoke', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/cloud/pricing')
+ })
+
+ test('shows the three paid tiers and Enterprise', async ({ page }) => {
+ const pricingGrid = page
+ .locator('section', {
+ has: page.getByRole('heading', { name: /Pricing/i })
+ })
+ .locator('.lg\\:grid')
+
+ for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
+ await expect(
+ pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
+ ).toBeVisible()
+ }
+
+ await expect(
+ page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
+ ).toBeVisible()
+ })
+
+ test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
+ page
+ }) => {
+ const pricingGrid = page
+ .locator('section', {
+ has: page.getByRole('heading', { name: /Pricing/i })
+ })
+ .locator('.lg\\:grid')
+
+ await expect(
+ pricingGrid.locator('span', { hasText: /^FREE$/ })
+ ).toHaveCount(0)
+ await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
+ 0
+ )
+ await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
+ })
+})
+
+test.describe('Cloud pricing teaser @smoke', () => {
+ test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
+ page
+ }) => {
+ await page.goto('/cloud')
+ await expect(
+ page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
+ ).toHaveCount(0)
+ })
+})
diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts
index c77e8e4ec7..6488170457 100644
--- a/apps/website/e2e/responsive.spec.ts
+++ b/apps/website/e2e/responsive.spec.ts
@@ -1,3 +1,4 @@
+import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
+
+ test.describe('SocialProofBar seamless marquee', () => {
+ test.use({ contextOptions: { reducedMotion: 'no-preference' } })
+
+ test('mobile forward marquee loops seamlessly', async ({ page }) => {
+ const geometry = await measureMarqueeLoopGeometry(
+ page,
+ '[data-testid="social-proof-mobile"] .animate-marquee'
+ )
+ expectSeamlessForwardLoop(geometry)
+ })
+
+ test('mobile reverse marquee loops seamlessly', async ({ page }) => {
+ const geometry = await measureMarqueeLoopGeometry(
+ page,
+ '[data-testid="social-proof-mobile"] .animate-marquee-reverse'
+ )
+ expectSeamlessReverseLoop(geometry)
+ })
+ })
})
+
+test.describe('Desktop SocialProofBar @smoke', () => {
+ test.use({ contextOptions: { reducedMotion: 'no-preference' } })
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ })
+
+ test('desktop marquee loops seamlessly', async ({ page }) => {
+ const geometry = await measureMarqueeLoopGeometry(
+ page,
+ '[data-testid="social-proof-desktop"] .animate-marquee'
+ )
+ expectSeamlessForwardLoop(geometry)
+ })
+})
+
+type MarqueeGeometry = {
+ copyWidths: number[]
+ startPositions: number[]
+ endPositions: number[]
+}
+
+async function measureMarqueeLoopGeometry(
+ page: Page,
+ selector: string
+): Promise {
+ await page.locator(selector).first().waitFor()
+ return page.evaluate((sel) => {
+ const tracks = Array.from(
+ document.querySelectorAll(sel)
+ ).slice(0, 2)
+ const firstAnimation = tracks[0]?.getAnimations()[0]
+ if (!firstAnimation) {
+ throw new Error(`No CSS animation found on ${sel}`)
+ }
+ const duration = firstAnimation.effect?.getTiming().duration
+ if (typeof duration !== 'number' || duration <= 1) {
+ throw new Error(
+ `Animation on ${sel} has unusable duration: ${String(duration)}`
+ )
+ }
+ const setAllTimes = (time: number) => {
+ for (const track of tracks) {
+ for (const anim of track.getAnimations()) {
+ anim.currentTime = time
+ }
+ }
+ void document.body.offsetWidth
+ }
+ const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
+ setAllTimes(0)
+ const startPositions = readX()
+ const copyWidths = tracks.map(
+ (track) => track.getBoundingClientRect().width
+ )
+ setAllTimes(duration - 0.1)
+ const endPositions = readX()
+ return { copyWidths, startPositions, endPositions }
+ }, selector)
+}
+
+function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
+ const { copyWidths } = geometry
+ expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
+ expect(copyWidths[0]).toBeGreaterThan(0)
+ expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
+}
+
+function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
+ expectTwoMatchingCopies(geometry)
+ // Copy 2 ends the cycle exactly where copy 1 started, so the restart
+ // (when copy 1 jumps back to its start position) is visually indistinguishable.
+ expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
+}
+
+function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
+ expectTwoMatchingCopies(geometry)
+ // Reverse marquee: copy 1 ends the cycle where copy 2 started.
+ expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
+}
diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png
index 20d8be7838..13f924097d 100644
Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png differ
diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png
index 718fb32ef7..70b26c7ca3 100644
Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png differ
diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png
index dcec9b89a8..d5129a7fb1 100644
Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png differ
diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png
index ba920f716c..d6f83d0fa2 100644
Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png differ
diff --git a/apps/website/package.json b/apps/website/package.json
index 4146bf0ec8..8a34eca2cc 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -15,11 +15,15 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
- "ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
+ "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"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
+ "@comfyorg/object-info-parser": "workspace:*",
+ "@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
diff --git a/apps/website/public/assets/images/fallback-gradient-avatar.svg b/apps/website/public/assets/images/fallback-gradient-avatar.svg
new file mode 100644
index 0000000000..90b860f619
--- /dev/null
+++ b/apps/website/public/assets/images/fallback-gradient-avatar.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/website/public/images/demos/community-workflows-og.png b/apps/website/public/images/demos/community-workflows-og.png
new file mode 100644
index 0000000000..7e46105c88
Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-og.png differ
diff --git a/apps/website/public/images/demos/community-workflows-thumb.webp b/apps/website/public/images/demos/community-workflows-thumb.webp
new file mode 100644
index 0000000000..0e2427c14c
Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-thumb.webp differ
diff --git a/apps/website/public/images/demos/image-to-video-og.png b/apps/website/public/images/demos/image-to-video-og.png
index 329dfe2e68..144ca94c41 100644
Binary files a/apps/website/public/images/demos/image-to-video-og.png and b/apps/website/public/images/demos/image-to-video-og.png differ
diff --git a/apps/website/public/images/demos/image-to-video-thumb.webp b/apps/website/public/images/demos/image-to-video-thumb.webp
index 329dfe2e68..24119e7b64 100644
Binary files a/apps/website/public/images/demos/image-to-video-thumb.webp and b/apps/website/public/images/demos/image-to-video-thumb.webp differ
diff --git a/apps/website/public/images/demos/workflow-templates-og.png b/apps/website/public/images/demos/workflow-templates-og.png
index 329dfe2e68..f13a49c448 100644
Binary files a/apps/website/public/images/demos/workflow-templates-og.png and b/apps/website/public/images/demos/workflow-templates-og.png differ
diff --git a/apps/website/public/images/demos/workflow-templates-thumb.webp b/apps/website/public/images/demos/workflow-templates-thumb.webp
index 329dfe2e68..7c2e78ab4a 100644
Binary files a/apps/website/public/images/demos/workflow-templates-thumb.webp and b/apps/website/public/images/demos/workflow-templates-thumb.webp differ
diff --git a/apps/website/scripts/generate-models.ts b/apps/website/scripts/generate-models.ts
new file mode 100644
index 0000000000..a546f608be
--- /dev/null
+++ b/apps/website/scripts/generate-models.ts
@@ -0,0 +1,396 @@
+import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const WORKFLOW_TEMPLATES_BASE =
+ 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
+
+const TEMPLATES_DIR = fileURLToPath(
+ new URL('../../../../workflow_templates/templates', import.meta.url)
+)
+
+const QUANT_SUFFIXES = [
+ '_fp8_e4m3fn_scaled',
+ '_fp8_e4m3fn',
+ '_fp8_scaled',
+ '_fp4_mixed',
+ '_fp8mixed',
+ '_fp8',
+ '_fp16',
+ '_fp4',
+ '_bf16',
+ '_int8'
+]
+
+interface RawModel {
+ name: string
+ url: string
+ directory: string
+}
+
+interface ModelData {
+ url: string
+ directory: string
+ templates: Set
+ firstTemplate?: string
+}
+
+interface OutputModel {
+ slug: string
+ name: string
+ huggingFaceUrl: string
+ directory: string
+ workflowCount: number
+ displayName: string
+ docsUrl?: string
+ thumbnailUrl?: string
+ canonicalSlug?: string
+}
+
+// Maps api_*.json filename prefix to a canonical display name and slug.
+// Add entries here as new partner integrations land in workflow_templates.
+const API_PROVIDER_MAP: Record = {
+ nano: { name: 'Nano Banana', slug: 'nano-banana' },
+ kling: { name: 'Kling AI', slug: 'kling-ai' },
+ kling2: { name: 'Kling AI', slug: 'kling-ai' },
+ meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
+ luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
+ runway: { name: 'Runway', slug: 'runway' },
+ vidu: { name: 'Vidu', slug: 'vidu' },
+ bfl: { name: 'Flux (API)', slug: 'flux-api' },
+ grok: { name: 'Grok Image', slug: 'grok-image' },
+ stability: { name: 'Stability AI', slug: 'stability-ai' },
+ bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
+ bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
+ google: { name: 'Gemini Image', slug: 'gemini-image' },
+ hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
+ ideogram: { name: 'Ideogram', slug: 'ideogram' },
+ pixverse: { name: 'Pixverse', slug: 'pixverse' },
+ rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
+ magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
+ bria: { name: 'Bria AI', slug: 'bria-ai' },
+ tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
+ tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
+ hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
+ recraft: { name: 'Recraft', slug: 'recraft' },
+ topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
+ moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
+ ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
+ openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
+ wan: { name: 'Wan (API)', slug: 'wan-api' },
+ wan2: { name: 'Wan (API)', slug: 'wan-api' },
+ veo2: { name: 'Veo 2', slug: 'veo-2' },
+ veo3: { name: 'Veo 3', slug: 'veo-3' },
+ flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
+ wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
+ wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
+}
+
+function stripExt(name: string): string {
+ return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
+}
+
+function stripQuant(base: string): string {
+ for (const suffix of QUANT_SUFFIXES) {
+ if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
+ }
+ return base
+}
+
+function makeSlug(name: string): string {
+ const base = stripExt(name)
+ return base
+ .toLowerCase()
+ .replace(/[_.]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+}
+
+function makeDisplayName(name: string): string {
+ const base = stripExt(name)
+ return base
+ .split(/[_-]/)
+ .map((part) => {
+ if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
+ if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
+ if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
+ return part.charAt(0).toUpperCase() + part.slice(1)
+ })
+ .join(' ')
+}
+
+function extractModels(
+ obj: unknown,
+ templateName: string,
+ models: Map
+): void {
+ if (obj === null || typeof obj !== 'object') return
+
+ if (Array.isArray(obj)) {
+ for (const item of obj) extractModels(item, templateName, models)
+ return
+ }
+
+ const record = obj as Record
+
+ if (Array.isArray(record['models'])) {
+ for (const m of record['models'] as unknown[]) {
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
+ const model = m as Record
+ if (typeof model['name'] !== 'string') continue
+
+ const name = model['name']
+ const url = typeof model['url'] === 'string' ? model['url'] : ''
+ const directory =
+ typeof model['directory'] === 'string' ? model['directory'] : ''
+
+ if (!models.has(name)) {
+ models.set(name, {
+ url,
+ directory,
+ templates: new Set(),
+ firstTemplate: templateName
+ })
+ }
+ models.get(name)!.templates.add(templateName)
+ }
+ }
+
+ for (const value of Object.values(record)) {
+ extractModels(value, templateName, models)
+ }
+}
+
+interface ApiModelData {
+ slug: string
+ name: string
+ directory: 'partner_nodes'
+ templateCount: number
+}
+
+function extractApiModels(files: string[]): ApiModelData[] {
+ const counts = new Map()
+ for (const file of files) {
+ if (!file.startsWith('api_')) continue
+ const prefix = file.slice(4).split('_')[0]
+ const entry = API_PROVIDER_MAP[prefix]
+ if (!entry) continue
+ counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
+ }
+ return [...counts.entries()].map(([slug, count]) => {
+ const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
+ return {
+ slug,
+ name: found.name,
+ directory: 'partner_nodes' as const,
+ templateCount: count
+ }
+ })
+}
+
+// Reads all locale index.json files to build a map of
+// raw model filename → tutorialUrl. Index entries name the template file;
+// that file's embedded model objects give the actual filenames.
+function buildTutorialUrlMap(templatesDir: string): Map {
+ const map = new Map()
+ const indexFiles = readdirSync(templatesDir).filter(
+ (f) =>
+ f.startsWith('index') &&
+ f.endsWith('.json') &&
+ !f.includes('schema') &&
+ !f.includes('logo')
+ )
+ // Collect template-name → tutorialUrl from all locale indexes (first wins)
+ const templateTutorialMap = new Map()
+ const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
+ for (const file of sorted) {
+ let data: unknown
+ try {
+ data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
+ } catch {
+ continue
+ }
+ if (!Array.isArray(data)) continue
+ for (const cat of data as unknown[]) {
+ if (typeof cat !== 'object' || cat === null) continue
+ const templates = (cat as Record)['templates']
+ if (!Array.isArray(templates)) continue
+ for (const t of templates) {
+ if (typeof t !== 'object' || t === null) continue
+ const entry = t as Record
+ const tutorialUrl =
+ typeof entry['tutorialUrl'] === 'string'
+ ? entry['tutorialUrl']
+ : undefined
+ const templateName =
+ typeof entry['name'] === 'string' ? entry['name'] : undefined
+ if (
+ tutorialUrl &&
+ templateName &&
+ !templateTutorialMap.has(templateName)
+ ) {
+ templateTutorialMap.set(templateName, tutorialUrl)
+ }
+ }
+ }
+ }
+
+ // For each template with a tutorialUrl, open the template file and map
+ // every embedded model filename to that tutorialUrl
+ for (const [templateName, tutorialUrl] of templateTutorialMap) {
+ const filePath = join(templatesDir, `${templateName}.json`)
+ let data: unknown
+ try {
+ data = JSON.parse(readFileSync(filePath, 'utf8'))
+ } catch {
+ continue
+ }
+
+ function extractModelNames(obj: unknown): void {
+ if (obj === null || typeof obj !== 'object') return
+ if (Array.isArray(obj)) {
+ for (const item of obj) extractModelNames(item)
+ return
+ }
+ const record = obj as Record
+ if (Array.isArray(record['models'])) {
+ for (const m of record['models'] as unknown[]) {
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
+ const model = m as Record
+ if (typeof model['name'] === 'string' && !map.has(model['name'])) {
+ map.set(model['name'], tutorialUrl)
+ }
+ }
+ }
+ for (const value of Object.values(record)) {
+ extractModelNames(value)
+ }
+ }
+
+ extractModelNames(data)
+ }
+
+ return map
+}
+
+function templateThumbnailUrl(
+ firstTemplate: string | undefined,
+ templatesDir: string
+): string | undefined {
+ if (!firstTemplate) return undefined
+ const base = firstTemplate.replace(/\.json$/, '')
+ const localPath = join(templatesDir, `${base}-1.webp`)
+ if (!existsSync(localPath)) return undefined
+ return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
+}
+
+function run(): void {
+ const models = new Map()
+
+ const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
+
+ for (const file of files) {
+ const filePath = join(TEMPLATES_DIR, file)
+ try {
+ const raw = readFileSync(filePath, 'utf8')
+ const data: unknown = JSON.parse(raw)
+ extractModels(data, file, models)
+ } catch (error) {
+ throw new Error(
+ `Failed to parse ${file}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ )
+ }
+ }
+
+ const apiModels = extractApiModels(files)
+ const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
+
+ const sorted = [...models.entries()].sort(
+ ([, a], [, b]) => b.templates.size - a.templates.size
+ )
+
+ // Build quant convergence map
+ const groups = new Map>()
+ for (const [name, data] of sorted) {
+ const base = stripExt(name)
+ const canonicalBase = stripQuant(base)
+ if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
+ groups.get(canonicalBase)!.push([name, data])
+ }
+
+ const canonicalMap = new Map()
+ for (const members of groups.values()) {
+ if (members.length > 1) {
+ const membersSorted = [...members].sort(
+ ([, a], [, b]) => b.templates.size - a.templates.size
+ )
+ const canonicalName = membersSorted[0][0]
+ canonicalMap.set(canonicalName, null)
+ for (const [name] of membersSorted.slice(1)) {
+ canonicalMap.set(name, canonicalName)
+ }
+ } else {
+ canonicalMap.set(members[0][0], null)
+ }
+ }
+
+ const output: OutputModel[] = sorted.map(([name, data]) => {
+ const canonicalRaw = canonicalMap.get(name) ?? null
+ const result: OutputModel = {
+ slug: makeSlug(name),
+ name,
+ huggingFaceUrl: data.url,
+ directory: data.directory,
+ workflowCount: data.templates.size,
+ displayName: makeDisplayName(name)
+ }
+ const docsUrl = tutorialUrlMap.get(name)
+ if (docsUrl) result.docsUrl = docsUrl
+ const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
+ if (thumb) result.thumbnailUrl = thumb
+ if (canonicalRaw !== null) {
+ result.canonicalSlug = makeSlug(canonicalRaw)
+ }
+ return result
+ })
+
+ const apiOutput: OutputModel[] = apiModels
+ .sort((a, b) => b.templateCount - a.templateCount)
+ .map((m) => ({
+ slug: m.slug,
+ name: m.name,
+ huggingFaceUrl: '',
+ directory: m.directory,
+ workflowCount: m.templateCount,
+ displayName: m.name
+ }))
+
+ const combined = [...apiOutput, ...output]
+
+ const withThumbs = combined.filter((m) => m.thumbnailUrl).length
+ process.stdout.write(
+ ` ${withThumbs}/${combined.length} models have thumbnails\n`
+ )
+
+ const defaultOut = join(
+ fileURLToPath(new URL('.', import.meta.url)),
+ '../src/config/generated-models.json'
+ )
+ const outputArg = process.argv[2] ?? defaultOut
+ const json = JSON.stringify(combined, null, 2) + '\n'
+
+ writeFileSync(outputArg, json, 'utf8')
+ process.stdout.write(
+ `Written ${combined.length} models ` +
+ `(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
+ )
+}
+
+try {
+ run()
+} catch (err) {
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
+ process.exit(1)
+}
diff --git a/apps/website/scripts/refresh-cloud-nodes-snapshot.ts b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts
new file mode 100644
index 0000000000..4817bb2168
--- /dev/null
+++ b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts
@@ -0,0 +1,32 @@
+import { renameSync, writeFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
+
+const snapshotPath = fileURLToPath(
+ new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
+)
+const tempPath = `${snapshotPath}.tmp`
+
+const outcome = await fetchCloudNodesForBuild()
+
+if (outcome.status !== 'fresh') {
+ const reason = 'reason' in outcome ? outcome.reason : '(none)'
+ console.error(
+ `Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
+ )
+ process.exit(1)
+}
+
+const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
+
+writeFileSync(tempPath, serialized, 'utf8')
+renameSync(tempPath, snapshotPath)
+
+const totalNodes = outcome.snapshot.packs.reduce(
+ (n, pack) => n + pack.nodes.length,
+ 0
+)
+process.stdout.write(
+ `Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
+)
diff --git a/apps/website/src/components/careers/RolesSection.vue b/apps/website/src/components/careers/RolesSection.vue
index db6053a3e1..f57a400f02 100644
--- a/apps/website/src/components/careers/RolesSection.vue
+++ b/apps/website/src/components/careers/RolesSection.vue
@@ -1,10 +1,13 @@
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
{{ dept.name }}
@@ -76,7 +130,7 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
+import type { Locale } from '../../i18n/translations'
+
+import { t } from '../../i18n/translations'
+import SectionLabel from '../common/SectionLabel.vue'
+
+const { locale = 'en' } = defineProps<{
+ locale?: Locale
+}>()
+
+
+
+
+
+
+ {{ t('cloudNodes.hero.label', locale) }}
+
+
+
+ {{ t('cloudNodes.hero.heading', locale) }}
+
+
+
+ {{ t('cloudNodes.hero.body', locale) }}
+
+
+
+
diff --git a/apps/website/src/components/cloud-nodes/NodeList.vue b/apps/website/src/components/cloud-nodes/NodeList.vue
new file mode 100644
index 0000000000..2b4653dc60
--- /dev/null
+++ b/apps/website/src/components/cloud-nodes/NodeList.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {{ t('cloudNodes.card.nodesHeading', locale) }}
+
+
+
+
+
+ {{ group.category }}
+
+
+
+ {{ node.displayName }}
+
+
+
+
+
+
diff --git a/apps/website/src/components/cloud-nodes/PackBanner.vue b/apps/website/src/components/cloud-nodes/PackBanner.vue
new file mode 100644
index 0000000000..77c983001a
--- /dev/null
+++ b/apps/website/src/components/cloud-nodes/PackBanner.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/src/components/cloud-nodes/PackCard.vue b/apps/website/src/components/cloud-nodes/PackCard.vue
new file mode 100644
index 0000000000..ecb22168b9
--- /dev/null
+++ b/apps/website/src/components/cloud-nodes/PackCard.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+ {{
+ pack.description ||
+ t('cloudNodes.card.unavailableDescription', locale)
+ }}
+
+
+
+
+
+ {{ t('cloudNodes.card.viewRepo', locale) }}
+
+
+ {{ t('cloudNodes.card.viewRepo', locale) }}
+
+
•
+
{{
+ nodeCountLabel(pack.nodes.length)
+ }}
+
+
+
+
+
+
diff --git a/apps/website/src/components/cloud-nodes/PackDetail.vue b/apps/website/src/components/cloud-nodes/PackDetail.vue
new file mode 100644
index 0000000000..b5cb777963
--- /dev/null
+++ b/apps/website/src/components/cloud-nodes/PackDetail.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+ {{ t('cloudNodes.detail.back', locale) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('cloudNodes.detail.publisher', locale) }}
+
+
+ {{ pack.publisher?.name || pack.publisher?.id || '—' }}
+
+
+
+
+
+ {{ t('cloudNodes.detail.downloads', locale) }}
+
+
+ {{ formatLocalizedNumber(pack.downloads, locale) }}
+
+
+
+
+
+ {{ t('cloudNodes.detail.stars', locale) }}
+
+
+ {{ formatLocalizedNumber(pack.githubStars, locale) }}
+
+
+
+
+
+ {{ t('cloudNodes.detail.latestVersion', locale) }}
+
+
+ {{ pack.latestVersion || '—' }}
+
+
+
+
+
+ {{ t('cloudNodes.detail.license', locale) }}
+
+
+ {{ pack.license || '—' }}
+
+
+
+
+
+ {{ t('cloudNodes.detail.lastUpdated', locale) }}
+
+
+ {{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
+
+
+
+
+
+
+ {{ t('cloudNodes.detail.nodesHeading', locale) }}
+
+
+
+
+ {{ group.category }}
+
+
+
+
+
+ {{ node.displayName }}
+
+
+ {{ t('cloudNodes.detail.experimental', locale) }}
+
+
+ {{ t('cloudNodes.detail.deprecated', locale) }}
+
+
+
+ {{ node.description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/src/components/cloud-nodes/PackGridSection.vue b/apps/website/src/components/cloud-nodes/PackGridSection.vue
new file mode 100644
index 0000000000..c8eea1a7db
--- /dev/null
+++ b/apps/website/src/components/cloud-nodes/PackGridSection.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+ {{ t('cloudNodes.hero.label', locale) }}
+
+
+ {{ t('cloudNodes.section.heading', locale) }}
+
+
+
+
+
+ {{ t('cloudNodes.search.label', locale) }}
+
+
+
+
+ {{ t('cloudNodes.sort.label', locale) }}
+
+
+
+ {{ t('cloudNodes.sort.downloads', locale) }}
+
+
+ {{ t('cloudNodes.sort.mostNodes', locale) }}
+
+ {{ t('cloudNodes.sort.az', locale) }}
+
+ {{ t('cloudNodes.sort.recentlyUpdated', locale) }}
+
+
+
+
+
+
+ {{ t('cloudNodes.empty.heading', locale) }}
+
+ {{ t('cloudNodes.empty.body', locale) }}
+
+
+
+
+
+
diff --git a/apps/website/src/components/common/ContentSection.vue b/apps/website/src/components/common/ContentSection.vue
index d79485cac6..a45794b915 100644
--- a/apps/website/src/components/common/ContentSection.vue
+++ b/apps/website/src/components/common/ContentSection.vue
@@ -1,7 +1,11 @@
diff --git a/apps/website/src/components/common/NodeBadge.vue b/apps/website/src/components/common/NodeBadge.vue
index b5c395602c..8a6e9e2857 100644
--- a/apps/website/src/components/common/NodeBadge.vue
+++ b/apps/website/src/components/common/NodeBadge.vue
@@ -26,7 +26,7 @@ const {
@@ -38,7 +38,7 @@ const {
v-if="i > 0"
src="/icons/node-union.svg"
alt=""
- class="-mx-px self-stretch"
+ class="-mx-px h-full w-auto self-stretch"
aria-hidden="true"
/>
diff --git a/apps/website/src/components/common/SocialProofBarSection.vue b/apps/website/src/components/common/SocialProofBarSection.vue
index 4cea331a62..e261a18591 100644
--- a/apps/website/src/components/common/SocialProofBarSection.vue
+++ b/apps/website/src/components/common/SocialProofBarSection.vue
@@ -14,23 +14,28 @@ const logos = [
'Ubisoft'
]
-const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
-const row1 = logos.slice(0, 6)
-const mobileRow1 = [...row1, ...row1]
-const row2 = logos.slice(6)
-const mobileRow2 = [...row2, ...row2]
+const mobileRow1Logos = logos.slice(0, 6)
+const mobileRow2Logos = logos.slice(6)
-
+
-
+
+
+
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
-
+
-
+
+
+
-
+
-
+
+
+
diff --git a/apps/website/src/components/demos/ArcadeEmbed.vue b/apps/website/src/components/demos/ArcadeEmbed.vue
index a599360a87..bd8e572af8 100644
--- a/apps/website/src/components/demos/ArcadeEmbed.vue
+++ b/apps/website/src/components/demos/ArcadeEmbed.vue
@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
+ aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
+ aspectRatio?: number
locale?: Locale
}>()
@@ -24,7 +26,8 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
+import { cn } from '@comfyorg/tailwind-utils'
+import BrandButton from '../common/BrandButton.vue'
+import { t } from '../../i18n/translations'
+
+const {
+ displayName,
+ huggingFaceUrl,
+ docsUrl,
+ blogUrl,
+ hubSlug,
+ workflowCount,
+ directory
+} = defineProps<{
+ displayName: string
+ huggingFaceUrl: string
+ docsUrl?: string
+ blogUrl?: string
+ hubSlug?: string
+ workflowCount: number
+ directory: string
+}>()
+
+const workflowsUrl = hubSlug
+ ? `https://www.comfy.org/workflows/model/${hubSlug}`
+ : null
+
+const dirDisplayMap: Record
= {
+ diffusion_models: 'Diffusion Model',
+ checkpoints: 'Checkpoint',
+ loras: 'LoRA',
+ controlnet: 'ControlNet',
+ clip_vision: 'CLIP Vision',
+ model_patches: 'Model Patch',
+ vae: 'VAE',
+ text_encoders: 'Text Encoder',
+ audio_encoders: 'Audio Encoder',
+ latent_upscale_models: 'Latent Upscale Model',
+ upscale_models: 'Upscale Model',
+ style_models: 'Style Model',
+ partner_nodes: 'Partner Node'
+}
+
+const eyebrow = dirDisplayMap[directory] ?? directory
+const isPartnerNode = directory === 'partner_nodes'
+
+
+
+
+
+
+ {{ eyebrow }}
+
+
+
+ {{ displayName }} in ComfyUI
+
+
+
+ {{
+ t('models.hero.workflowCount').replace(
+ '{count}',
+ String(workflowCount)
+ )
+ }}
+
+
+
+
+ {{ t('models.hero.primaryCta') }}
+
+
+
+ {{ t('models.hero.secondaryCta') }}
+
+
+
+ {{ t('models.hero.cloudCta') }}
+
+
+
+ {{ t('models.hero.tutorialCta') }}
+
+
+
+
+
+
+
diff --git a/apps/website/src/components/pricing/PriceSection.vue b/apps/website/src/components/pricing/PriceSection.vue
index c8690a530e..ccd8192a3c 100644
--- a/apps/website/src/components/pricing/PriceSection.vue
+++ b/apps/website/src/components/pricing/PriceSection.vue
@@ -7,6 +7,7 @@ import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
+import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
@@ -37,21 +38,23 @@ interface PricingPlan {
isEnterprise?: boolean
}
+const freePlan: PricingPlan = {
+ id: 'free',
+ labelKey: 'pricing.plan.free.label',
+ summaryKey: 'pricing.plan.free.summary',
+ priceKey: 'pricing.plan.free.price',
+ creditsKey: 'pricing.plan.free.credits',
+ estimateKey: 'pricing.plan.free.estimate',
+ ctaKey: 'pricing.plan.free.cta',
+ ctaHref: externalLinks.cloud,
+ features: [
+ { text: 'pricing.plan.free.feature1' },
+ { text: 'pricing.plan.free.feature2' }
+ ]
+}
+
const plans: PricingPlan[] = [
- {
- id: 'free',
- labelKey: 'pricing.plan.free.label',
- summaryKey: 'pricing.plan.free.summary',
- priceKey: 'pricing.plan.free.price',
- creditsKey: 'pricing.plan.free.credits',
- estimateKey: 'pricing.plan.free.estimate',
- ctaKey: 'pricing.plan.free.cta',
- ctaHref: externalLinks.cloud,
- features: [
- { text: 'pricing.plan.free.feature1' },
- { text: 'pricing.plan.free.feature2' }
- ]
- },
+ ...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
@@ -61,7 +64,9 @@ const plans: PricingPlan[] = [
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
- featureIntroKey: 'pricing.plan.standard.featureIntro',
+ featureIntroKey: SHOW_FREE_TIER
+ ? 'pricing.plan.standard.featureIntro'
+ : undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
@@ -150,9 +155,14 @@ const activePlanIndex = ref(0)
-
+
@@ -223,10 +233,18 @@ const activePlanIndex = ref(0)
-
- {{
- plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
- }}
+
+ {{ t(plan.featureIntroKey, locale) }}
+
+
+
diff --git a/apps/website/src/components/product/local/HeroSection.vue b/apps/website/src/components/product/local/HeroSection.vue
index 87a5530db7..8a2d5b7019 100644
--- a/apps/website/src/components/product/local/HeroSection.vue
+++ b/apps/website/src/components/product/local/HeroSection.vue
@@ -276,29 +276,6 @@ onUnmounted(() => {
fill="#211927"
/>
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/website/src/composables/useBannerImage.test.ts b/apps/website/src/composables/useBannerImage.test.ts
new file mode 100644
index 0000000000..bf23aca09e
--- /dev/null
+++ b/apps/website/src/composables/useBannerImage.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import { useBannerImage } from './useBannerImage'
+
+describe('useBannerImage', () => {
+ it('shows the default banner when neither url is provided', () => {
+ const { showDefaultBanner, imgSrc } = useBannerImage({})
+ expect(showDefaultBanner.value).toBe(true)
+ expect(imgSrc.value).toBeUndefined()
+ })
+
+ it('prefers bannerUrl over iconUrl when both are provided', () => {
+ const { showDefaultBanner, imgSrc } = useBannerImage({
+ bannerUrl: 'https://example.com/banner.png',
+ iconUrl: 'https://example.com/icon.png'
+ })
+ expect(showDefaultBanner.value).toBe(false)
+ expect(imgSrc.value).toBe('https://example.com/banner.png')
+ })
+
+ it('falls back to iconUrl when bannerUrl is missing', () => {
+ const { imgSrc } = useBannerImage({
+ iconUrl: 'https://example.com/icon.png'
+ })
+ expect(imgSrc.value).toBe('https://example.com/icon.png')
+ })
+
+ it('reactively updates when sources change', () => {
+ const banner = ref
(undefined)
+ const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
+
+ expect(showDefaultBanner.value).toBe(true)
+ banner.value = 'https://example.com/new.png'
+ expect(showDefaultBanner.value).toBe(false)
+ expect(imgSrc.value).toBe('https://example.com/new.png')
+ })
+
+ it('flips isImageError when onImageError is called', () => {
+ const { isImageError, onImageError } = useBannerImage({
+ bannerUrl: 'x'
+ })
+ expect(isImageError.value).toBe(false)
+ onImageError()
+ expect(isImageError.value).toBe(true)
+ })
+
+ it('exposes the default banner constant for consumers', () => {
+ const { DEFAULT_BANNER } = useBannerImage({})
+ expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
+ })
+})
diff --git a/apps/website/src/composables/useBannerImage.ts b/apps/website/src/composables/useBannerImage.ts
new file mode 100644
index 0000000000..0ceaf96aff
--- /dev/null
+++ b/apps/website/src/composables/useBannerImage.ts
@@ -0,0 +1,30 @@
+import { computed, ref, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
+
+interface UseBannerImageInput {
+ bannerUrl?: MaybeRefOrGetter
+ iconUrl?: MaybeRefOrGetter
+}
+
+export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
+ const isImageError = ref(false)
+
+ const showDefaultBanner = computed(
+ () => !toValue(bannerUrl) && !toValue(iconUrl)
+ )
+ const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
+
+ function onImageError() {
+ isImageError.value = true
+ }
+
+ return {
+ DEFAULT_BANNER,
+ isImageError,
+ showDefaultBanner,
+ imgSrc,
+ onImageError
+ }
+}
diff --git a/apps/website/src/composables/useFilteredPacks.test.ts b/apps/website/src/composables/useFilteredPacks.test.ts
new file mode 100644
index 0000000000..f3073f561f
--- /dev/null
+++ b/apps/website/src/composables/useFilteredPacks.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import type { Pack, PackNode } from '../data/cloudNodes'
+
+import { useFilteredPacks } from './useFilteredPacks'
+import type { PackSortMode } from './useFilteredPacks'
+
+function pack(overrides: Partial = {}): Pack {
+ return {
+ id: overrides.id ?? 'pack',
+ displayName: overrides.displayName ?? 'Pack',
+ nodes: overrides.nodes ?? [],
+ downloads: overrides.downloads,
+ lastUpdated: overrides.lastUpdated,
+ ...overrides
+ }
+}
+
+function node(name: string, displayName: string): PackNode {
+ return { name, displayName, category: 'x' }
+}
+
+describe('useFilteredPacks', () => {
+ const packs: readonly Pack[] = [
+ pack({
+ id: 'a',
+ displayName: 'Alpha',
+ downloads: 100,
+ lastUpdated: '2025-01-01T00:00:00Z',
+ nodes: [node('aa', 'Aardvark')]
+ }),
+ pack({
+ id: 'b',
+ displayName: 'Beta',
+ downloads: 300,
+ lastUpdated: '2025-06-01T00:00:00Z',
+ nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
+ }),
+ pack({
+ id: 'c',
+ displayName: 'Gamma',
+ downloads: 200,
+ lastUpdated: '2025-03-01T00:00:00Z',
+ nodes: [
+ node('cc', 'Cat'),
+ node('cc2', 'Crocodile'),
+ node('cc3', 'Capybara')
+ ]
+ })
+ ]
+
+ it('sorts by downloads desc by default', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'downloads' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('sorts most-nodes places highest count first', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'mostNodes' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
+ })
+
+ it('sorts A → Z by display name', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
+ 'Alpha',
+ 'Beta',
+ 'Gamma'
+ ])
+ })
+
+ it('sorts recently updated newest first', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'recentlyUpdated' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('treats invalid lastUpdated as 0', () => {
+ const broken = [
+ pack({ id: 'x', lastUpdated: 'nonsense' }),
+ pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
+ ]
+ const { filteredPacks } = useFilteredPacks({
+ packs: broken,
+ query: '',
+ sortMode: 'recentlyUpdated' as PackSortMode
+ })
+ expect(filteredPacks.value[0].id).toBe('y')
+ })
+
+ it('matches the search query against pack display names', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'beta',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
+ })
+
+ it('matches the search query against node display names', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'CAPYBARA',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
+ })
+
+ it('returns empty when nothing matches', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'zzz-no-such-thing',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value).toHaveLength(0)
+ })
+
+ it('reacts when the query ref changes', () => {
+ const query = ref('beta')
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query,
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value).toHaveLength(1)
+ query.value = ''
+ expect(filteredPacks.value).toHaveLength(3)
+ })
+})
diff --git a/apps/website/src/composables/useFilteredPacks.ts b/apps/website/src/composables/useFilteredPacks.ts
new file mode 100644
index 0000000000..0c26400123
--- /dev/null
+++ b/apps/website/src/composables/useFilteredPacks.ts
@@ -0,0 +1,53 @@
+import { computed, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+import type { Pack } from '../data/cloudNodes'
+
+export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
+
+interface UseFilteredPacksInput {
+ packs: MaybeRefOrGetter
+ query: MaybeRefOrGetter
+ sortMode: MaybeRefOrGetter
+}
+
+function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
+ if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
+ return pack.nodes.some((node) =>
+ node.displayName.toLowerCase().includes(normalizedQuery)
+ )
+}
+
+function safeTimestamp(value: string | undefined): number {
+ if (!value) return 0
+ const ts = Date.parse(value)
+ return Number.isNaN(ts) ? 0 : ts
+}
+
+export function useFilteredPacks(input: UseFilteredPacksInput) {
+ const filteredPacks = computed(() => {
+ const allPacks = toValue(input.packs)
+ const normalizedQuery = toValue(input.query).trim().toLowerCase()
+
+ const matching =
+ normalizedQuery.length === 0
+ ? [...allPacks]
+ : allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
+
+ const mode = toValue(input.sortMode)
+ if (mode === 'az') {
+ return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
+ }
+ if (mode === 'recentlyUpdated') {
+ return matching.sort(
+ (a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
+ )
+ }
+ if (mode === 'mostNodes') {
+ return matching.sort((a, b) => b.nodes.length - a.nodes.length)
+ }
+ return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
+ })
+
+ return { filteredPacks }
+}
diff --git a/apps/website/src/composables/useNodesByCategory.test.ts b/apps/website/src/composables/useNodesByCategory.test.ts
new file mode 100644
index 0000000000..642c598fb5
--- /dev/null
+++ b/apps/website/src/composables/useNodesByCategory.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import type { PackNode } from '../data/cloudNodes'
+
+import { useNodesByCategory } from './useNodesByCategory'
+
+function node(name: string, displayName: string, category: string): PackNode {
+ return { name, displayName, category }
+}
+
+describe('useNodesByCategory', () => {
+ it('groups nodes by category', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('A', 'A', 'cat-1'),
+ node('B', 'B', 'cat-2'),
+ node('C', 'C', 'cat-1')
+ ])
+ expect(groupedNodes.value).toHaveLength(2)
+ expect(groupedNodes.value[0]).toMatchObject({
+ category: 'cat-1',
+ nodes: [
+ expect.objectContaining({ name: 'A' }),
+ expect.objectContaining({ name: 'C' })
+ ]
+ })
+ })
+
+ it('sorts nodes alphabetically by display name within a category', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('z', 'Zulu', 'x'),
+ node('a', 'Alpha', 'x'),
+ node('m', 'Mike', 'x')
+ ])
+ expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
+ 'Alpha',
+ 'Mike',
+ 'Zulu'
+ ])
+ })
+
+ it('sorts categories alphabetically', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('a', 'A', 'beta'),
+ node('b', 'B', 'alpha'),
+ node('c', 'C', 'gamma')
+ ])
+ expect(groupedNodes.value.map((g) => g.category)).toEqual([
+ 'alpha',
+ 'beta',
+ 'gamma'
+ ])
+ })
+
+ it('falls back to a placeholder for missing categories', () => {
+ const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
+ expect(groupedNodes.value[0].category).toBe('—')
+ })
+
+ it('reacts to ref changes', () => {
+ const nodes = ref([node('a', 'A', 'x')])
+ const { groupedNodes } = useNodesByCategory(nodes)
+ expect(groupedNodes.value).toHaveLength(1)
+
+ nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
+ expect(groupedNodes.value).toHaveLength(2)
+ })
+})
diff --git a/apps/website/src/composables/useNodesByCategory.ts b/apps/website/src/composables/useNodesByCategory.ts
new file mode 100644
index 0000000000..f2bf407582
--- /dev/null
+++ b/apps/website/src/composables/useNodesByCategory.ts
@@ -0,0 +1,40 @@
+import { computed, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+import type { PackNode } from '../data/cloudNodes'
+
+const UNCATEGORIZED = '—'
+
+interface NodeCategoryGroup {
+ category: string
+ nodes: PackNode[]
+}
+
+export function useNodesByCategory(
+ nodes: MaybeRefOrGetter
+) {
+ const groupedNodes = computed(() => {
+ const byCategory = new Map()
+
+ for (const node of toValue(nodes)) {
+ const category = node.category || UNCATEGORIZED
+ const existing = byCategory.get(category)
+ if (existing) {
+ existing.push(node)
+ continue
+ }
+ byCategory.set(category, [node])
+ }
+
+ return [...byCategory.entries()]
+ .map(([category, items]) => ({
+ category,
+ nodes: [...items].sort((a, b) =>
+ a.displayName.localeCompare(b.displayName)
+ )
+ }))
+ .sort((a, b) => a.category.localeCompare(b.category))
+ })
+
+ return { groupedNodes }
+}
diff --git a/apps/website/src/config/demos.ts b/apps/website/src/config/demos.ts
index 4225e765c2..980a7f1dee 100644
--- a/apps/website/src/config/demos.ts
+++ b/apps/website/src/config/demos.ts
@@ -15,6 +15,14 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
+ /**
+ * Width / height of the Arcade demo's source recording (e.g. 1.93 for a
+ * landscape screencast). Sizes the embed container to match so rounded
+ * corners hug the content instead of empty letterbox space. Source from
+ * Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
+ * invert it). Defaults to 16/9 if omitted.
+ */
+ readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
- modifiedDate: '2026-04-19'
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
},
{
slug: 'workflow-templates',
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
- modifiedDate: '2026-04-19'
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
+ },
+ {
+ slug: 'community-workflows',
+ arcadeId: 'mqZh17oWDuWIyhK0xwEV',
+ category: 'demos.category.gettingStarted',
+ title: 'demos.community-workflows.title',
+ description: 'demos.community-workflows.description',
+ transcript: 'demos.community-workflows.transcript',
+ ogImage: '/images/demos/community-workflows-og.png',
+ thumbnail: '/images/demos/community-workflows-thumb.webp',
+ estimatedTime: 'demos.duration.2min',
+ durationIso: 'PT2M',
+ difficulty: 'beginner',
+ tags: ['getting-started', 'community', 'workflow', 'hub'],
+ publishedDate: '2026-05-04',
+ modifiedDate: '2026-05-04',
+ aspectRatio: 1.931
}
]
diff --git a/apps/website/src/config/features.ts b/apps/website/src/config/features.ts
new file mode 100644
index 0000000000..2247d75941
--- /dev/null
+++ b/apps/website/src/config/features.ts
@@ -0,0 +1 @@
+export const SHOW_FREE_TIER = false
diff --git a/apps/website/src/config/generated-models.json b/apps/website/src/config/generated-models.json
new file mode 100644
index 0000000000..7bcf654a75
--- /dev/null
+++ b/apps/website/src/config/generated-models.json
@@ -0,0 +1,1945 @@
+[
+ {
+ "slug": "kling-ai",
+ "name": "Kling AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 11,
+ "displayName": "Kling AI"
+ },
+ {
+ "slug": "openai-dall-e",
+ "name": "OpenAI DALL-E",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 10,
+ "displayName": "OpenAI DALL-E"
+ },
+ {
+ "slug": "vidu",
+ "name": "Vidu",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 8,
+ "displayName": "Vidu"
+ },
+ {
+ "slug": "seedance-bytedance",
+ "name": "Seedance (ByteDance)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 7,
+ "displayName": "Seedance (ByteDance)"
+ },
+ {
+ "slug": "stability-ai",
+ "name": "Stability AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 7,
+ "displayName": "Stability AI"
+ },
+ {
+ "slug": "wan-api",
+ "name": "Wan (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 6,
+ "displayName": "Wan (API)"
+ },
+ {
+ "slug": "flux-api",
+ "name": "Flux (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Flux (API)"
+ },
+ {
+ "slug": "runway",
+ "name": "Runway",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Runway"
+ },
+ {
+ "slug": "tripo-3d",
+ "name": "Tripo 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Tripo 3D"
+ },
+ {
+ "slug": "grok-image",
+ "name": "Grok Image",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Grok Image"
+ },
+ {
+ "slug": "luma-dream-machine",
+ "name": "Luma Dream Machine",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Luma Dream Machine"
+ },
+ {
+ "slug": "moonvalley",
+ "name": "Moonvalley",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Moonvalley"
+ },
+ {
+ "slug": "hailuo-minimax",
+ "name": "Hailuo MiniMax",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Hailuo MiniMax"
+ },
+ {
+ "slug": "magnific-ai",
+ "name": "Magnific AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Magnific AI"
+ },
+ {
+ "slug": "meshy-ai",
+ "name": "Meshy AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Meshy AI"
+ },
+ {
+ "slug": "pixverse",
+ "name": "Pixverse",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Pixverse"
+ },
+ {
+ "slug": "recraft",
+ "name": "Recraft",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Recraft"
+ },
+ {
+ "slug": "rodin-3d",
+ "name": "Rodin 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Rodin 3D"
+ },
+ {
+ "slug": "bria-ai",
+ "name": "Bria AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Bria AI"
+ },
+ {
+ "slug": "gemini-image",
+ "name": "Gemini Image",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Gemini Image"
+ },
+ {
+ "slug": "hunyuan-3d",
+ "name": "Hunyuan 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Hunyuan 3D"
+ },
+ {
+ "slug": "ltxv-api",
+ "name": "LTX Video (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "LTX Video (API)"
+ },
+ {
+ "slug": "topaz-labs",
+ "name": "Topaz Labs",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Topaz Labs"
+ },
+ {
+ "slug": "wavespeed",
+ "name": "Wavespeed",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Wavespeed"
+ },
+ {
+ "slug": "ideogram",
+ "name": "Ideogram",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Ideogram"
+ },
+ {
+ "slug": "nano-banana",
+ "name": "Nano Banana",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Nano Banana"
+ },
+ {
+ "slug": "veo-2",
+ "name": "Veo 2",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Veo 2"
+ },
+ {
+ "slug": "umt5-xxl-fp8-e4m3fn-scaled",
+ "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 34,
+ "displayName": "Umt5 Xxl FP8 e4m3fn scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan-2-1-vae",
+ "name": "wan_2.1_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 29,
+ "displayName": "Wan 2.1 Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "ae",
+ "name": "ae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
+ "directory": "vae",
+ "workflowCount": 26,
+ "displayName": "Ae",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-2-5-vl-7b-fp8-scaled",
+ "name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 25,
+ "displayName": "Qwen 2.5 Vl 7b FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "qwen-image-vae",
+ "name": "qwen_image_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 19,
+ "displayName": "Qwen Image Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "clip-l",
+ "name": "clip_l.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 14,
+ "displayName": "Clip L",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "t5xxl-fp16",
+ "name": "t5xxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 12,
+ "displayName": "T5xxl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "clip-vision-h",
+ "name": "clip_vision_h.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 12,
+ "displayName": "Clip Vision H",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "flux2-vae",
+ "name": "flux2-vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 9,
+ "displayName": "Flux2 Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "qwen-3-4b",
+ "name": "qwen_3_4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 8,
+ "displayName": "Qwen 3 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-fp8-e4m3fn",
+ "name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 8,
+ "displayName": "Qwen Image Edit 2509 FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "t5xxl-fp8-e4m3fn-scaled",
+ "name": "t5xxl_fp8_e4m3fn_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 8,
+ "displayName": "T5xxl FP8 e4m3fn scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp",
+ "canonicalSlug": "t5xxl-fp16"
+ },
+ {
+ "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-high-noise",
+ "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 7,
+ "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 High Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-low-noise",
+ "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 7,
+ "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 Low Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "gemma-3-12b-it-fp4-mixed",
+ "name": "gemma_3_12B_it_fp4_mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp4_mixed.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 7,
+ "displayName": "Gemma 3 12B It FP4 mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-spatial-upscaler-x2-1-0",
+ "name": "ltx-2-spatial-upscaler-x2-1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-spatial-upscaler-x2-1.0.safetensors",
+ "directory": "latent_upscale_models",
+ "workflowCount": 7,
+ "displayName": "Ltx 2 Spatial Upscaler X2 1.0",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Qwen Image Edit 2509 Lightning 4steps V1.0 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-1-vace-1-3b-fp16",
+ "name": "wan2.1_vace_1.3B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_1.3B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 6,
+ "displayName": "Wan2.1 Vace 1.3B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan21-causvid-bidirect2-t2v-1-3b-lora-rank32",
+ "name": "Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Wan21 CausVid Bidirect2 T2V 1 3B Lora Rank32",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "umt5-xxl-fp16",
+ "name": "umt5_xxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 6,
+ "displayName": "Umt5 Xxl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp",
+ "canonicalSlug": "umt5-xxl-fp8-e4m3fn-scaled"
+ },
+ {
+ "slug": "wan2-1-vace-14b-fp16",
+ "name": "wan2.1_vace_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 6,
+ "displayName": "Wan2.1 Vace 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan21-causvid-14b-t2v-lora-rank32",
+ "name": "Wan21_CausVid_14B_T2V_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_14B_T2V_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Wan21 CausVid 14B T2V Lora Rank32",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Wan2.2 I2v High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Wan2.2 I2v Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "vae-ft-mse-840000-ema-pruned",
+ "name": "vae-ft-mse-840000-ema-pruned.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors",
+ "directory": "vae",
+ "workflowCount": 5,
+ "displayName": "Vae Ft Mse 840000 Ema Pruned",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "lotus-depth-d-v1-1",
+ "name": "lotus-depth-d-v1-1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/lotus/resolve/main/lotus-depth-d-v1-1.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Lotus Depth D V1 1",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "clip-g-hidream",
+ "name": "clip_g_hidream.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_g_hidream.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Clip G Hidream",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "clip-l-hidream",
+ "name": "clip_l_hidream.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_l_hidream.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Clip L Hidream",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "llama-3-1-8b-instruct-fp8-scaled",
+ "name": "llama_3.1_8b_instruct_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/llama_3.1_8b_instruct_fp8_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Llama 3.1 8b Instruct FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "qwen-image-lightning-4steps-v1-0",
+ "name": "Qwen-Image-Lightning-4steps-V1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Qwen Image Lightning 4steps V1.0",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp"
+ },
+ {
+ "slug": "qwen-image-fp8-e4m3fn",
+ "name": "qwen_image_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Qwen Image FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp"
+ },
+ {
+ "slug": "lightx2v-i2v-14b-480p-cfg-step-distill-rank64-bf16",
+ "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Lightx2v I2V 14B 480p Cfg Step Distill Rank64 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-dev-fp8",
+ "name": "ltx-2-19b-dev-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev-fp8.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Dev FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-distilled-lora-384",
+ "name": "ltx-2-19b-distilled-lora-384.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-lora-384.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Distilled Lora 384",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-distilled",
+ "name": "ltx-2-19b-distilled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Distilled",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "z-image-turbo-bf16",
+ "name": "z_image_turbo_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 4,
+ "displayName": "Z Image Turbo BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image-turbo",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "ace-step-v1-3-5b",
+ "name": "ace_step_v1_3.5b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/resolve/main/all_in_one/ace_step_v1_3.5b.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Ace Step V1 3.5b",
+ "docsUrl": "https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/05_audio_ace_step_1_t2a_song_subgraphed-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-fp8-scaled",
+ "name": "sd3.5_large_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-fp8/resolve/main/sd3.5_large_fp8_scaled.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Sd3.5 Large FP8 scaled",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp"
+ },
+ {
+ "slug": "sd-xl-base-1-0",
+ "name": "sd_xl_base_1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Sd Xl Base 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-lora-camera-control-dolly-left",
+ "name": "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors",
+ "directory": "loras",
+ "workflowCount": 4,
+ "displayName": "Ltx 2 19b Lora Camera Control Dolly Left",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_i2v-1.webp"
+ },
+ {
+ "slug": "sigclip-vision-patch14-384",
+ "name": "sigclip_vision_patch14_384.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/sigclip_vision_384/resolve/main/sigclip_vision_patch14_384.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 3,
+ "displayName": "Sigclip Vision Patch14 384",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-dev",
+ "name": "flux1-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Flux1 Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_dev_checkpoint_example-1.webp"
+ },
+ {
+ "slug": "hunyuan-video-vae-bf16",
+ "name": "hunyuan_video_vae_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors?download=true",
+ "directory": "vae",
+ "workflowCount": 3,
+ "displayName": "Hunyuan Video Vae BF16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-bf16",
+ "name": "qwen_image_edit_2511_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Qwen Image Edit 2511 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp"
+ },
+ {
+ "slug": "flux2-dev-fp8mixed",
+ "name": "flux2_dev_fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/diffusion_models/flux2_dev_fp8mixed.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Flux2 Dev fp8mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "qwen-3-8b-fp8mixed",
+ "name": "qwen_3_8b_fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 3,
+ "displayName": "Qwen 3 8b fp8mixed",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp"
+ },
+ {
+ "slug": "wan2-2-vae",
+ "name": "wan2.2_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan2.2_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 3,
+ "displayName": "Wan2.2 Vae",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp"
+ },
+ {
+ "slug": "hunyuan-3d-v2-1",
+ "name": "hunyuan_3d_v2.1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.1_repackaged/resolve/main/hunyuan_3d_v2.1.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Hunyuan 3d V2.1",
+ "docsUrl": "https://docs.comfy.org/tutorials/3d/hunyuan3D-2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/04_hunyuan_3d_2.1_subgraphed-1.webp"
+ },
+ {
+ "slug": "flux1-dev-fp8",
+ "name": "flux1-dev-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Flux1 Dev FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-fill-dev",
+ "name": "flux1-fill-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-fill-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux1 Fill Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_fill_inpaint_example-1.webp"
+ },
+ {
+ "slug": "mistral-3-small-flux2-bf16",
+ "name": "mistral_3_small_flux2_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Mistral 3 Small Flux2 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-9b-fp8",
+ "name": "flux-2-klein-base-9b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-9b-fp8/resolve/main/flux-2-klein-base-9b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux 2 Klein Base 9b FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-9b-fp8",
+ "name": "flux-2-klein-9b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux 2 Klein 9b FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_distilled-1.webp"
+ },
+ {
+ "slug": "qwen-2-5-vl-fp16",
+ "name": "qwen_2.5_vl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/text_encoders/qwen_2.5_vl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Qwen 2.5 Vl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp"
+ },
+ {
+ "slug": "omnigen2-fp16",
+ "name": "omnigen2_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/omnigen2_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Omnigen2 FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-2512-fp8-e4m3fn",
+ "name": "qwen_image_2512_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Qwen Image 2512 FP8 e4m3fn",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Edit 2511 Lightning 4steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2511-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-vae",
+ "name": "qwen_image_layered_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/vae/qwen_image_layered_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Layered Vae",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp"
+ },
+ {
+ "slug": "sd-xl-refiner-1-0",
+ "name": "sd_xl_refiner_1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Sd Xl Refiner 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp"
+ },
+ {
+ "slug": "wan2-1-vae-bf16",
+ "name": "Wan2_1_VAE_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1_VAE_bf16.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Wan2 1 VAE BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-ati-14b-fp8-e4m3fn",
+ "name": "Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Wan2 1 I2V ATI 14B FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-ati",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "qwen-edit-2509-multiple-angles",
+ "name": "Qwen-Edit-2509-Multiple-angles.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Edit-2509-Multiple-angles.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Edit 2509 Multiple Angles",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-1_click_multiple_character_angles-v1.0-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-lightning-8steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Edit 2509 Lightning 8steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp"
+ },
+ {
+ "slug": "byt5-small-glyphxl-fp16",
+ "name": "byt5_small_glyphxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/text_encoders/byt5_small_glyphxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Byt5 Small Glyphxl FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo15-latent-upsampler-1080p",
+ "name": "hunyuanvideo15_latent_upsampler_1080p.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/latent_upscale_models/hunyuanvideo15_latent_upsampler_1080p.safetensors",
+ "directory": "latent_upscale_models",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo15 Latent Upsampler 1080p",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-1080p-sr-distilled-fp16",
+ "name": "hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo1.5 1080p Sr Distilled FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo15-vae-fp16",
+ "name": "hunyuanvideo15_vae_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/vae/hunyuanvideo15_vae_fp16.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo15 Vae FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-high-noise",
+ "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 High Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan21-wanmove-fp8-scaled-e4m3fn-kj",
+ "name": "Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/WanMove/Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Wan21 WanMove FP8 scaled e4m3fn KJ",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wanmove_480p-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-fp16",
+ "name": "hunyuan3d-dit-v2_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_image_to_model-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-mv-fp16",
+ "name": "hunyuan3d-dit-v2-mv_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 Mv FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-mv-turbo-fp16",
+ "name": "hunyuan3d-dit-v2-mv-turbo_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv-turbo_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 Mv Turbo FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model_turbo-1.webp"
+ },
+ {
+ "slug": "stable-audio-open-1-0",
+ "name": "stable-audio-open-1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-audio-open-1.0_repackaged/resolve/main/stable-audio-open-1.0.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Stable Audio Open 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/"
+ },
+ {
+ "slug": "t5-base",
+ "name": "t5-base.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/ComfyUI-Wiki/t5-base/resolve/main/t5-base.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "T5 Base",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/"
+ },
+ {
+ "slug": "v1-5-pruned-emaonly-fp16",
+ "name": "v1-5-pruned-emaonly-fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "V1 5 Pruned Emaonly FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/basic/text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/default-1.webp"
+ },
+ {
+ "slug": "uso-flux1-projector-v1",
+ "name": "uso-flux1-projector-v1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/model_patches/uso-flux1-projector-v1.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Uso Flux1 Projector V1",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "uso-flux1-dit-lora-v1",
+ "name": "uso-flux1-dit-lora-v1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/loras/uso-flux1-dit-lora-v1.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Uso Flux1 Dit Lora V1",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-krea-dev-fp8-scaled",
+ "name": "flux1-krea-dev_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/FLUX.1-Krea-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-krea-dev_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Krea Dev FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux1-krea-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "flux1-canny-dev",
+ "name": "flux1-canny-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-canny-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Canny Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_canny_model_example-1.webp"
+ },
+ {
+ "slug": "flux1-depth-dev-lora",
+ "name": "flux1-depth-dev-lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/loras/flux1-depth-dev-lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux1 Depth Dev Lora",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "flux1-dev-kontext-fp8-scaled",
+ "name": "flux1-dev-kontext_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-kontext-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-dev-kontext_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Dev Kontext FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp"
+ },
+ {
+ "slug": "flux1-redux-dev",
+ "name": "flux1-redux-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Flux1-Redux-Dev/resolve/main/flux1-redux-dev.safetensors",
+ "directory": "style_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Redux Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_redux_model_example-1.webp"
+ },
+ {
+ "slug": "flux1-schnell-fp8",
+ "name": "flux1-schnell-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Flux1 Schnell FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell-1.webp"
+ },
+ {
+ "slug": "flux1-schnell",
+ "name": "flux1-schnell.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Schnell",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell_full_text_to_image-1.webp"
+ },
+ {
+ "slug": "hidream-e1-1-bf16",
+ "name": "hidream_e1_1_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_1_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream E1 1 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "hidream-e1-full-bf16",
+ "name": "hidream_e1_full_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_full_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream E1 Full BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_full-1.webp"
+ },
+ {
+ "slug": "hidream-i1-dev-fp8",
+ "name": "hidream_i1_dev_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_dev_fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Dev FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_dev-1.webp"
+ },
+ {
+ "slug": "hidream-i1-fast-fp8",
+ "name": "hidream_i1_fast_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_fast_fp8.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Fast FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_fast-1.webp"
+ },
+ {
+ "slug": "hidream-i1-full-fp8",
+ "name": "hidream_i1_full_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_full_fp8.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Full FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_full-1.webp"
+ },
+ {
+ "slug": "llava-llama3-fp8-scaled",
+ "name": "llava_llama3_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp8_scaled.safetensors?download=true",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Llava Llama3 FP8 scaled",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "hunyuan-video-t2v-720p-bf16",
+ "name": "hunyuan_video_t2v_720p_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_t2v_720p_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuan Video T2v 720p BF16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-systms-infl8",
+ "name": "Qwen_Image_Edit_2511-SYSTMS_INFL8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/systms/SYSTMS-INFL8-LoRA-Qwen-Image-Edit-2511/resolve/main/SYSTMS_INFL8_LoRA_Qwen_Image_Edit_2511.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2511 SYSTMS INFL8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp"
+ },
+ {
+ "slug": "chroma-radiance-x0",
+ "name": "chroma-radiance-x0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-Radiance_Repackaged/resolve/main/split_files/diffusion_models/chroma-radiance-x0.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chroma Radiance X0",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma1_radiance_text_to_image-1.webp"
+ },
+ {
+ "slug": "chroma1-hd-fp8mixed",
+ "name": "Chroma1-HD-fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-HD_repackaged/resolve/main/split_files/diffusion_models/Chroma1-HD-fp8mixed.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chroma1 HD fp8mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma_text_to_image-1.webp"
+ },
+ {
+ "slug": "chronoedit-distill-lora",
+ "name": "chronoedit_distill_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/chronoedit_distill_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Chronoedit Distill Lora",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "chrono-edit-14b-fp16",
+ "name": "chrono_edit_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/chrono_edit_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chrono Edit 14B FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "flux-1-fill-dev-onereward-transformer-fp8",
+ "name": "flux.1-fill-dev-OneReward-transformer_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/OneReward_repackaged/resolve/main/split_files/diffusion_models/flux.1-fill-dev-OneReward-transformer_fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux.1 Fill Dev OneReward Transformer FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp"
+ },
+ {
+ "slug": "removal-timestep-alpha-2-1740",
+ "name": "removal_timestep_alpha-2-1740.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lrzjason/ObjectRemovalFluxFill/resolve/main/removal_timestep_alpha-2-1740.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Removal Timestep Alpha 2 1740",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp"
+ },
+ {
+ "slug": "flux-2-turbo-lora-comfyui",
+ "name": "Flux_2-Turbo-LoRA_comfyui.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/ByteZSzn/Flux.2-Turbo-ComfyUI/resolve/main/Flux_2-Turbo-LoRA_comfyui.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Turbo LoRA Comfyui",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "mistral-3-small-flux2-fp8",
+ "name": "mistral_3_small_flux2_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp8.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Mistral 3 Small Flux2 FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp",
+ "canonicalSlug": "mistral-3-small-flux2-bf16"
+ },
+ {
+ "slug": "flux2turbocomfyv2",
+ "name": "Flux2TurboComfyv2.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/loras/Flux2TurboComfyv2.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux2TurboComfyv2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-4b-fp8",
+ "name": "flux-2-klein-base-4b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-4b-fp8/resolve/main/flux-2-klein-base-4b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein Base 4b FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_base-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-4b-fp8",
+ "name": "flux-2-klein-4b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein 4b FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_distilled-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-4b",
+ "name": "flux-2-klein-base-4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-base-4b.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein Base 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-4b",
+ "name": "flux-2-klein-4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-4b.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp"
+ },
+ {
+ "slug": "netayumev35-pretrained-all-in-one",
+ "name": "NetaYumev35_pretrained_all_in_one.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/duongve/NetaYume-Lumina-Image-2.0/resolve/main/NetaYumev35_pretrained_all_in_one.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "NetaYumev35 Pretrained All In One",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/newbie-image/newbie-image-exp-0-1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_netayume_lumina_t2i-1.webp"
+ },
+ {
+ "slug": "newbie-image-exp0-1-bf16",
+ "name": "NewBie-Image-Exp0.1-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/diffusion_models/NewBie-Image-Exp0.1-bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "NewBie Image Exp0.1 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "gemma-3-4b-it-bf16",
+ "name": "gemma_3_4b_it_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/gemma_3_4b_it_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Gemma 3 4b It BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "jina-clip-v2-bf16",
+ "name": "jina_clip_v2_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/jina_clip_v2_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Jina Clip V2 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "ovis-image-bf16",
+ "name": "ovis_image_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/diffusion_models/ovis_image_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Ovis Image BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp"
+ },
+ {
+ "slug": "ovis-2-5",
+ "name": "ovis_2.5.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/text_encoders/ovis_2.5.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Ovis 2.5",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-image-lightning-8steps-v1-0",
+ "name": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Lightning 8steps V1.0",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp"
+ },
+ {
+ "slug": "wuli-qwen-image-2512-turbo-lora-2steps-v1-0-bf16",
+ "name": "Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Wuli-art/Qwen-Image-2512-Turbo-LoRA-2-Steps/resolve/main/Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wuli Qwen Image 2512 Turbo LoRA 2steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_2512_with_2stpes_lora-1.webp"
+ },
+ {
+ "slug": "qwen-image-canny-diffsynth-controlnet",
+ "name": "qwen_image_canny_diffsynth_controlnet.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/model_patches/qwen_image_canny_diffsynth_controlnet.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Canny Diffsynth Controlnet",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_controlnet_patch-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-fp8-e4m3fn",
+ "name": "qwen_image_edit_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit Lightning 4steps V1.0 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-relight",
+ "name": "Qwen-Image-Edit-2509-Relight.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Relight.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Relight",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2509_relight-1.webp"
+ },
+ {
+ "slug": "qwen-image-instantx-controlnet-union",
+ "name": "Qwen-Image-InstantX-ControlNet-Union.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Qwen Image InstantX ControlNet Union",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_controlnet-1.webp"
+ },
+ {
+ "slug": "qwen-image-instantx-controlnet-inpainting",
+ "name": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Qwen Image InstantX ControlNet Inpainting",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_inpainting_controlnet-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-bf16",
+ "name": "qwen_image_layered_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_layered_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Layered BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-control-bf16",
+ "name": "qwen_image_layered_control_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/DiffSynth-Studio/Qwen-Image-Layered-Control/resolve/main/qwen_image_layered_control_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Layered Control BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered_control-1.webp"
+ },
+ {
+ "slug": "qwen-image-union-diffsynth-lora",
+ "name": "qwen_image_union_diffsynth_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/loras/qwen_image_union_diffsynth_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Union Diffsynth Lora",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_union_control_lora-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-480p-14b-fp16",
+ "name": "wan2.1_i2v_480p_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_i2v_480p_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 I2v 480p 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_to_video_wan-1.webp"
+ },
+ {
+ "slug": "z-image-bf16",
+ "name": "z_image_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image/resolve/main/split_files/diffusion_models/z_image_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Z Image BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image-1.webp"
+ },
+ {
+ "slug": "pixel-art-style-z-image-turbo",
+ "name": "pixel_art_style_z_image_turbo.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Pixel Art Style Z Image Turbo",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo-1.webp"
+ },
+ {
+ "slug": "z-image-turbo-fun-controlnet-union",
+ "name": "Z-Image-Turbo-Fun-Controlnet-Union.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union/resolve/main/Z-Image-Turbo-Fun-Controlnet-Union.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Z Image Turbo Fun Controlnet Union",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo_fun_union_controlnet-1.webp"
+ },
+ {
+ "slug": "ltx-video-2b-v0-9-5",
+ "name": "ltx-video-2b-v0.9.5.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.5.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Ltx Video 2b V0.9.5",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_image_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-video-2b-v0-9",
+ "name": "ltx-video-2b-v0.9.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Ltx Video 2b V0.9",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_text_to_video-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-blur",
+ "name": "sd3.5_large_controlnet_blur.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_blur.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Blur",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-canny",
+ "name": "sd3.5_large_controlnet_canny.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_canny.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Canny",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_canny_controlnet_example-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-depth",
+ "name": "sd3.5_large_controlnet_depth.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_depth.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Depth",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_depth-1.webp"
+ },
+ {
+ "slug": "clip-vision-g",
+ "name": "clip_vision_g.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors?download=true",
+ "directory": "clip_vision",
+ "workflowCount": 1,
+ "displayName": "Clip Vision G",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/#revision",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_revision_text_prompts-1.webp"
+ },
+ {
+ "slug": "sd-xl-turbo-1-0-fp16",
+ "name": "sd_xl_turbo_1.0_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/sdxl-turbo/resolve/main/sd_xl_turbo_1.0_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Sd Xl Turbo 1.0 FP16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxlturbo_example-1.webp"
+ },
+ {
+ "slug": "wan21-t2v-14b-lightx2v-cfg-step-distill-lora-rank32",
+ "name": "Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan21 T2V 14B Lightx2v Cfg Step Distill Lora Rank32",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "umt5-xxl-enc-bf16",
+ "name": "umt5-xxl-enc-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/umt5-xxl-enc-bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Umt5 Xxl Enc BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "clip-vit-h-14-laion2b-s32b-b79k",
+ "name": "CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/CLIP-ViT-H-14-laion2B-s32B-b79K_repackaged/resolve/main/split_files/clip_vision/CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 1,
+ "displayName": "CLIP ViT H 14 Laion2B S32B B79K",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-anything2realalpha",
+ "name": "Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Anything2RealAlpha",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-light-migration",
+ "name": "Qwen-Image-Edit-2509-Light-Migration.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Light-Migration.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Light Migration",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-portrait_light_migration-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-fusion",
+ "name": "Qwen-Image-Edit-2509-Fusion.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Fusion.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Fusion",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-qwen_image_edit-crop_and_stitch-fusion-1.webp"
+ },
+ {
+ "slug": "wan2-1-t2v-1-3b-fp16",
+ "name": "wan2.1_t2v_1.3B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_1.3B_fp16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 T2v 1.3B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/text_to_video_wan-1.webp"
+ },
+ {
+ "slug": "svd-xt",
+ "name": "svd_xt.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-video-diffusion-img2vid-xt/resolve/main/svd_xt.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Svd Xt",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/video/#image-to-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/txt_to_image_to_video-1.webp"
+ },
+ {
+ "slug": "realesrgan-x4plus",
+ "name": "RealESRGAN_x4plus.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors",
+ "directory": "upscale_models",
+ "workflowCount": 1,
+ "displayName": "RealESRGAN X4plus",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ultility-gan_upscaler-1.webp"
+ },
+ {
+ "slug": "humo-17b-fp8-e4m3fn",
+ "name": "humo_17B_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/diffusion_models/humo_17B_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Humo 17B FP8 e4m3fn",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "whisper-large-v3-fp16",
+ "name": "whisper_large_v3_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/audio_encoders/whisper_large_v3_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Whisper Large V3 FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-720p-i2v-fp16",
+ "name": "hunyuanvideo1.5_720p_i2v_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_i2v_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuanvideo1.5 720p I2v FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-720p-t2v-fp16",
+ "name": "hunyuanvideo1.5_720p_t2v_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_t2v_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuanvideo1.5 720p T2v FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_t2v-1.webp"
+ },
+ {
+ "slug": "kandinsky5lite-i2v-5s",
+ "name": "kandinsky5lite_i2v_5s.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-I2V-Lite-5s/resolve/main/model/kandinsky5lite_i2v_5s.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Kandinsky5lite I2v 5s",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_i2v-1.webp"
+ },
+ {
+ "slug": "kandinsky5lite-t2v-sft-5s",
+ "name": "kandinsky5lite_t2v_sft_5s.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s/resolve/main/model/kandinsky5lite_t2v_sft_5s.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Kandinsky5lite T2v Sft 5s",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_t2v-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-canny-control",
+ "name": "ltx-2-19b-ic-lora-canny-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control/resolve/main/ltx-2-19b-ic-lora-canny-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Canny Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-depth-control",
+ "name": "ltx-2-19b-ic-lora-depth-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control/resolve/main/ltx-2-19b-ic-lora-depth-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Depth Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_depth_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-pose-control",
+ "name": "ltx-2-19b-ic-lora-pose-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Pose Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_pose_to_video-1.webp"
+ },
+ {
+ "slug": "wan2-1-t2v-14b-fp8-scaled",
+ "name": "wan2.1_t2v_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 T2v 14B FP8 scaled",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-rgba-lora",
+ "name": "wan_alpha_2.1_rgba_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/loras/wan_alpha_2.1_rgba_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Rgba Lora",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-vae-rgb-channel",
+ "name": "wan_alpha_2.1_vae_rgb_channel.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_rgb_channel.safetensors",
+ "directory": "vae",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Vae Rgb Channel",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-vae-alpha-channel",
+ "name": "wan_alpha_2.1_vae_alpha_channel.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_alpha_channel.safetensors",
+ "directory": "vae",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Vae Alpha Channel",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "lightx2v-t2v-14b-cfg-step-distill-v2-lora-rank64-bf16",
+ "name": "lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Lightx2v T2V 14B Cfg Step Distill V2 Lora Rank64 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-camera-v1-1-1-3b-bf16",
+ "name": "wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Camera V1.1 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_1.3B-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-camera-v1-1-14b-bf16",
+ "name": "wan2.1_fun_camera_v1.1_14B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_14B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Camera V1.1 14B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_14B-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-14b-480p-fp8-e4m3fn-scaled-kj",
+ "name": "Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/I2V/Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2 1 I2V 14B 480p FP8 e4m3fn scaled KJ",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wan2-1-infinitetalk-multi-fp16",
+ "name": "wan2.1_infiniteTalk_multi_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/model_patches/wan2.1_infiniteTalk_multi_fp16.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 InfiniteTalk Multi FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wav2vec2-chinese-base-fp16",
+ "name": "wav2vec2-chinese-base_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/wav2vec2_safetensors/resolve/main/wav2vec2-chinese-base_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Wav2vec2 Chinese Base FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wananimate-relight-lora-fp16",
+ "name": "WanAnimate_relight_lora_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/LoRAs/Wan22_relight/WanAnimate_relight_lora_fp16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "WanAnimate Relight Lora FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp"
+ },
+ {
+ "slug": "wan2-2-animate-14b-fp8-e4m3fn-scaled-kj",
+ "name": "Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/Wan22Animate/Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2 2 Animate 14B FP8 e4m3fn scaled KJ",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-camera-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Camera High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-camera-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Camera Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wav2vec2-large-english-fp16",
+ "name": "wav2vec2_large_english_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/audio_encoders/wav2vec2_large_english_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Wav2vec2 Large English FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-s2v-14b-fp8-scaled",
+ "name": "wan2.2_s2v_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_s2v_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 S2v 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-low-noise",
+ "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 Low Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-5b-bf16",
+ "name": "wan2.2_fun_control_5B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_5B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control 5B BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-5b-bf16",
+ "name": "wan2.2_fun_inpaint_5B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_5B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint 5B BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wan2-2-ti2v-5b-fp16",
+ "name": "wan2.2_ti2v_5B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Ti2v 5B FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_ti2v-1.webp"
+ },
+ {
+ "slug": "wan2-1-flf2v-720p-14b-fp16",
+ "name": "wan2.1_flf2v_720p_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_flf2v_720p_14B_fp16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Flf2v 720p 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-flf",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_flf2v_720_f16-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-control-1-3b-bf16",
+ "name": "wan2.1_fun_control_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_control_1.3B_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Control 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-inp-1-3b-bf16",
+ "name": "wan2.1_fun_inp_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_inp_1.3B_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Inp 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_inp-1.webp"
+ }
+]
diff --git a/apps/website/src/config/model-metadata.ts b/apps/website/src/config/model-metadata.ts
new file mode 100644
index 0000000000..07f8e1ec15
--- /dev/null
+++ b/apps/website/src/config/model-metadata.ts
@@ -0,0 +1,208 @@
+interface ModelOverride {
+ docsUrl?: string
+ blogUrl?: string
+ featured?: boolean
+ // Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
+ hubSlug?: string
+}
+
+export const modelMetadata: Record = {
+ 'nano-banana': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
+ hubSlug: 'nano-banana',
+ featured: true
+ },
+ 'kling-ai': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
+ hubSlug: 'kling',
+ featured: true
+ },
+ 'meshy-ai': {
+ docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
+ hubSlug: 'meshy',
+ featured: true
+ },
+ 'openai-dall-e': {
+ docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
+ hubSlug: 'openai',
+ featured: true
+ },
+ 'ltxv-api': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
+ hubSlug: 'ltx-2-3',
+ featured: true
+ },
+ 'wan-api': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'wan-2-2': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'wan-2-1': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'flux-1-kontext-dev': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
+ hubSlug: 'flux-1-kontext',
+ featured: true
+ },
+ 'flux1-dev': {
+ docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
+ hubSlug: 'flux-1',
+ featured: true
+ },
+ 'flux1-schnell': {
+ hubSlug: 'flux-1',
+ featured: true
+ },
+ 'hunyuan-video': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
+ hubSlug: 'hunyuan-video',
+ featured: true
+ },
+ 'hunyuan-3d': {
+ docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
+ hubSlug: 'hunyuan-3d',
+ featured: true
+ },
+ vidu: {
+ hubSlug: 'vidu',
+ featured: true
+ },
+ runway: {
+ hubSlug: 'runway',
+ featured: true
+ },
+ 'stability-ai': {
+ hubSlug: 'stability',
+ featured: true
+ },
+ 'seedance-bytedance': {
+ hubSlug: 'seedance',
+ featured: true
+ },
+ 'grok-image': {
+ hubSlug: 'grok',
+ featured: false
+ },
+ 'luma-dream-machine': {
+ hubSlug: 'luma',
+ featured: false
+ },
+ moonvalley: {
+ hubSlug: 'moonvalley',
+ featured: false
+ },
+ 'magnific-ai': {
+ hubSlug: 'magnific',
+ featured: false
+ },
+ pixverse: {
+ hubSlug: 'pixverse',
+ featured: false
+ },
+ 'rodin-3d': {
+ hubSlug: 'rodin',
+ featured: false
+ },
+ recraft: {
+ hubSlug: 'recraft',
+ featured: false
+ },
+ 'bria-ai': {
+ hubSlug: 'bria',
+ featured: false
+ },
+ 'topaz-labs': {
+ hubSlug: 'topaz',
+ featured: false
+ },
+ wavespeed: {
+ hubSlug: 'wavespeed',
+ featured: false
+ },
+ ideogram: {
+ hubSlug: 'ideogram',
+ featured: false
+ },
+ 'veo-2': {
+ hubSlug: 'veo',
+ featured: false
+ },
+ 'veo-3': {
+ hubSlug: 'veo',
+ featured: false
+ },
+ 'flux-2-api': {
+ hubSlug: 'flux-2',
+ featured: false
+ },
+ 'ace-step-v1-3-5b': {
+ docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
+ hubSlug: 'ace-step',
+ featured: false
+ },
+ 'hidream-i1-dev-fp8': {
+ docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
+ hubSlug: 'hidream',
+ featured: false
+ },
+ 'omnigen2-fp16': {
+ hubSlug: 'omnigen',
+ featured: false
+ },
+ 'sd-xl-base-1-0': {
+ hubSlug: 'sdxl',
+ featured: false
+ },
+ 'z-image-bf16': {
+ hubSlug: 'z-image',
+ featured: false
+ },
+ 'z-image-turbo-bf16': {
+ hubSlug: 'z-image',
+ featured: false
+ },
+ 'svd-xt': {
+ hubSlug: 'svd',
+ featured: false
+ },
+ 'flux1-dev-kontext-fp8-scaled': {
+ docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
+ hubSlug: 'flux-1-kontext',
+ featured: false
+ },
+ 'ltx-2-19b-dev-fp8': {
+ hubSlug: 'ltx-2',
+ featured: false
+ },
+ 'ltx-2-19b-distilled': {
+ hubSlug: 'ltx-2',
+ featured: false
+ },
+ 'flux1-fill-dev': {
+ hubSlug: 'flux-1',
+ featured: false
+ },
+ 'flux-2-klein-base-9b-fp8': {
+ hubSlug: 'flux-2',
+ featured: false
+ },
+ 'qwen-image-fp8-e4m3fn': {
+ hubSlug: 'qwen',
+ featured: false
+ },
+ 'qwen-image-edit-2509-fp8-e4m3fn': {
+ hubSlug: 'qwen',
+ featured: false
+ }
+}
diff --git a/apps/website/src/config/models.ts b/apps/website/src/config/models.ts
new file mode 100644
index 0000000000..c779ac1430
--- /dev/null
+++ b/apps/website/src/config/models.ts
@@ -0,0 +1,81 @@
+import generatedModels from './generated-models.json'
+import { modelMetadata } from './model-metadata'
+
+type ModelDirectory =
+ | 'diffusion_models'
+ | 'checkpoints'
+ | 'loras'
+ | 'controlnet'
+ | 'clip_vision'
+ | 'model_patches'
+ | 'vae'
+ | 'text_encoders'
+ | 'audio_encoders'
+ | 'latent_upscale_models'
+ | 'upscale_models'
+ | 'style_models'
+ | 'partner_nodes'
+
+interface Model {
+ readonly slug: string
+ readonly canonicalSlug?: string
+ readonly name: string
+ readonly displayName: string
+ readonly directory: ModelDirectory
+ readonly huggingFaceUrl: string
+ readonly thumbnailUrl?: string
+ readonly docsUrl?: string
+ readonly blogUrl?: string
+ readonly hubSlug?: string
+ readonly featured: boolean
+ readonly workflowCount: number
+}
+
+export const models: readonly Model[] = (
+ generatedModels as Array<{
+ slug: string
+ canonicalSlug?: string
+ name: string
+ displayName: string
+ directory: string
+ huggingFaceUrl: string
+ docsUrl?: string
+ thumbnailUrl?: string
+ workflowCount: number
+ }>
+).map((m) => ({
+ slug: m.slug,
+ ...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
+ name: m.name,
+ displayName: m.displayName,
+ directory: m.directory as ModelDirectory,
+ huggingFaceUrl: m.huggingFaceUrl,
+ ...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
+ ...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
+ featured: false,
+ workflowCount: m.workflowCount,
+ ...modelMetadata[m.slug]
+}))
+
+const slugSet = new Set(models.map((m) => m.slug))
+if (slugSet.size !== models.length) {
+ for (const model of models) {
+ if (models.filter((m) => m.slug === model.slug).length > 1) {
+ throw new Error(`Duplicate model slug: ${model.slug}`)
+ }
+ }
+}
+for (const model of models) {
+ if (
+ model.canonicalSlug !== undefined &&
+ (!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
+ ) {
+ throw new Error(
+ `Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
+ )
+ }
+}
+
+export function getModelBySlug(slug: string): Model | undefined {
+ return models.find((m) => m.slug === slug)
+}
diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts
index d9b6811e88..eb215b5c98 100644
--- a/apps/website/src/config/routes.ts
+++ b/apps/website/src/config/routes.ts
@@ -14,7 +14,8 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
- contact: '/contact'
+ contact: '/contact',
+ models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
diff --git a/apps/website/src/data/ashby-roles.snapshot.json b/apps/website/src/data/ashby-roles.snapshot.json
index b738e98407..455930cd62 100644
--- a/apps/website/src/data/ashby-roles.snapshot.json
+++ b/apps/website/src/data/ashby-roles.snapshot.json
@@ -1,37 +1,30 @@
{
- "fetchedAt": "2026-05-02T20:15:18.321Z",
+ "fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
- "id": "e915f2c78b17f93b",
+ "id": "18743530eb448c99",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
},
{
- "id": "b9f9a23219be7cd4",
- "title": "Design Engineer",
- "department": "Design",
- "location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
- },
- {
- "id": "547b6ba622c800a5",
+ "id": "8718d17012f26fa2",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
},
{
- "id": "7bb02634a24763bc",
+ "id": "1e181b9ed8fb2e86",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
}
]
},
@@ -40,67 +33,67 @@
"key": "engineering",
"roles": [
{
- "id": "102d58e35a8a9817",
+ "id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
- "id": "d01d69fba7743905",
+ "id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
- "id": "f36f60cfd5bb5910",
+ "id": "a6d8269c66e37c5c",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
},
{
- "id": "9d8ec4c65e20b19e",
+ "id": "841da783e6e41928",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
},
{
- "id": "be94b193d1f4d482",
+ "id": "5d01d58b03870d7a",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
},
{
- "id": "ab48f5db6bd1783c",
+ "id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
- "id": "c5dff4ee628bdcd1",
+ "id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
- "id": "4302a7aaa87e16e3",
+ "id": "0b8f4fecd89c3b11",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
},
{
- "id": "2eb53e8943cc9396",
+ "id": "2f6bac39d723dfef",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
}
]
},
@@ -109,39 +102,39 @@
"key": "marketing",
"roles": [
{
- "id": "4c5d6afb78652df7",
+ "id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
- "id": "0f5256cf302e552b",
+ "id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
- "id": "5746486d87874937",
+ "id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
- "id": "b5803a0d4785d406",
+ "id": "6f771af6858283aa",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
},
{
- "id": "130d7218d7895bdb",
+ "id": "527a47e82970afc1",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
}
]
},
@@ -150,25 +143,18 @@
"key": "operations",
"roles": [
{
- "id": "ec68ae44dd5943c9",
- "title": "Talent Lead",
+ "id": "0c6cc3685194ab7a",
+ "title": "Head of Talent",
"department": "Operations",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
},
{
- "id": "16f556001ce1cef4",
- "title": "BizOps Strategist",
- "department": "Operations",
- "location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
- },
- {
- "id": "8e773a72c1b8e099",
+ "id": "82bd6ed26adab1c3",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
}
]
}
diff --git a/apps/website/src/data/cloud-nodes.snapshot.json b/apps/website/src/data/cloud-nodes.snapshot.json
new file mode 100644
index 0000000000..a5a85565db
--- /dev/null
+++ b/apps/website/src/data/cloud-nodes.snapshot.json
@@ -0,0 +1,394 @@
+{
+ "fetchedAt": "2026-05-04T16:29:55.587Z",
+ "packs": [
+ {
+ "id": "comfyui-impact-pack",
+ "registryId": "comfyui-impact-pack",
+ "displayName": "ComfyUI Impact Pack",
+ "description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
+ "repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
+ "publisher": {
+ "id": "drltdata",
+ "name": "Dr.Lt.Data"
+ },
+ "downloads": 2618646,
+ "githubStars": 3092,
+ "latestVersion": "8.28.3",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-19T17:08:04.993918Z",
+ "nodes": [
+ {
+ "name": "FaceDetailer",
+ "displayName": "FaceDetailer",
+ "category": "ImpactPack/Detailer",
+ "description": "Detect and refine faces with iterative passes."
+ },
+ {
+ "name": "DetailerForEach",
+ "displayName": "DetailerForEach",
+ "category": "ImpactPack/Detailer",
+ "description": "Run iterative detail refinement over detected SEG regions."
+ },
+ {
+ "name": "UltralyticsDetectorProvider",
+ "displayName": "UltralyticsDetectorProvider",
+ "category": "ImpactPack/Detector",
+ "description": "Provide detector models powered by Ultralytics YOLO."
+ },
+ {
+ "name": "SAMLoader",
+ "displayName": "SAMLoader",
+ "category": "ImpactPack/Detector",
+ "description": "Load Segment Anything models for high-fidelity masking."
+ },
+ {
+ "name": "MaskToSEGS",
+ "displayName": "MaskToSEGS",
+ "category": "ImpactPack/Operation",
+ "description": "Convert binary masks into SEGS regions for the detailer pipeline."
+ }
+ ]
+ },
+ {
+ "id": "ComfyUI-Crystools",
+ "registryId": "ComfyUI-Crystools",
+ "displayName": "ComfyUI-Crystools",
+ "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
+ "iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
+ "repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
+ "publisher": {
+ "id": "crystian",
+ "name": "Crystian"
+ },
+ "downloads": 1671447,
+ "githubStars": 1855,
+ "latestVersion": "1.27.4",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2025-10-26T19:11:09.943366Z",
+ "supportedOs": ["OS Independent"],
+ "supportedAccelerators": ["GPU :: NVIDIA CUDA"],
+ "nodes": [
+ {
+ "name": "CCrystools_Show_Resources",
+ "displayName": "CCrystools_Show_Resources",
+ "category": "crystools/show",
+ "description": "Display GPU, RAM and disk usage live in the workflow."
+ },
+ {
+ "name": "CCrystools_Show_Image",
+ "displayName": "CCrystools_Show_Image",
+ "category": "crystools/show",
+ "description": "Inspect images at full resolution with metadata overlays."
+ },
+ {
+ "name": "CCrystools_Json",
+ "displayName": "CCrystools_Json",
+ "category": "crystools/json",
+ "description": "Compose and parse JSON inline for advanced workflows."
+ },
+ {
+ "name": "CCrystools_Pipe_To_Any",
+ "displayName": "CCrystools_Pipe_To_Any",
+ "category": "crystools/pipe",
+ "description": "Convert a pipe bus into individual outputs."
+ },
+ {
+ "name": "CCrystools_Save_Metadata",
+ "displayName": "CCrystools_Save_Metadata",
+ "category": "crystools/save",
+ "description": "Save images with workflow metadata embedded."
+ }
+ ]
+ },
+ {
+ "id": "rgthree-comfy",
+ "registryId": "rgthree-comfy",
+ "displayName": "rgthree-comfy",
+ "description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
+ "iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
+ "repoUrl": "https://github.com/rgthree/rgthree-comfy",
+ "publisher": {
+ "id": "rgthree"
+ },
+ "downloads": 3025389,
+ "githubStars": 3028,
+ "latestVersion": "1.0.2604070017",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-07T04:19:24.689627Z",
+ "nodes": [
+ {
+ "name": "Power Lora Loader (rgthree)",
+ "displayName": "Power Lora Loader (rgthree)",
+ "category": "rgthree",
+ "description": "Stack multiple LoRAs in a single, foldable widget."
+ },
+ {
+ "name": "Fast Groups Bypasser (rgthree)",
+ "displayName": "Fast Groups Bypasser (rgthree)",
+ "category": "rgthree",
+ "description": "Toggle whole groups on or off without rewiring."
+ },
+ {
+ "name": "Seed (rgthree)",
+ "displayName": "Seed (rgthree)",
+ "category": "rgthree",
+ "description": "A predictable seed control with quick reset."
+ },
+ {
+ "name": "Reroute (rgthree)",
+ "displayName": "Reroute (rgthree)",
+ "category": "rgthree",
+ "description": "A clean reroute alternative with persistent labels."
+ },
+ {
+ "name": "Display Any (rgthree)",
+ "displayName": "Display Any (rgthree)",
+ "category": "rgthree",
+ "description": "Inspect any value with a compact debug widget."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-kjnodes",
+ "registryId": "comfyui-kjnodes",
+ "displayName": "ComfyUI-KJNodes",
+ "description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
+ "iconUrl": "https://avatars.githubusercontent.com/u/40791699",
+ "repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
+ "publisher": {
+ "id": "kijai",
+ "name": "Kijai"
+ },
+ "downloads": 3319866,
+ "githubStars": 2544,
+ "latestVersion": "1.3.9",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-24T09:32:28.326616Z",
+ "nodes": [
+ {
+ "name": "ColorMatch",
+ "displayName": "ColorMatch",
+ "category": "KJNodes/image",
+ "description": "Match the colors of one image to another using statistics."
+ },
+ {
+ "name": "ImageResizeKJ",
+ "displayName": "ImageResizeKJ",
+ "category": "KJNodes/image",
+ "description": "Resize images with intuitive size and divisibility controls."
+ },
+ {
+ "name": "StringConstantMultiline",
+ "displayName": "StringConstantMultiline",
+ "category": "KJNodes/string",
+ "description": "A multi-line string constant suitable for prompts."
+ },
+ {
+ "name": "EmptyLatentImagePresets",
+ "displayName": "EmptyLatentImagePresets",
+ "category": "KJNodes/latent",
+ "description": "Quickly create empty latents at common resolutions."
+ },
+ {
+ "name": "GetImageSizeAndCount",
+ "displayName": "GetImageSizeAndCount",
+ "category": "KJNodes/image",
+ "description": "Read width, height and batch size from an image input."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-easy-use",
+ "registryId": "comfyui-easy-use",
+ "displayName": "ComfyUI-Easy-Use",
+ "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
+ "iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
+ "repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
+ "publisher": {
+ "id": "yolain",
+ "name": "yolain"
+ },
+ "downloads": 2767609,
+ "githubStars": 2500,
+ "latestVersion": "1.3.6",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-01-23T06:19:17.505188Z",
+ "nodes": [
+ {
+ "name": "easy fullLoader",
+ "displayName": "easy fullLoader",
+ "category": "EasyUse/Loaders",
+ "description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
+ },
+ {
+ "name": "easy preSampling",
+ "displayName": "easy preSampling",
+ "category": "EasyUse/PreSampling",
+ "description": "A unified pre-sampling node bundling common settings."
+ },
+ {
+ "name": "easy kSampler",
+ "displayName": "easy kSampler",
+ "category": "EasyUse/KSampler",
+ "description": "A simplified KSampler with extra quality-of-life options."
+ },
+ {
+ "name": "easy showAnything",
+ "displayName": "easy showAnything",
+ "category": "EasyUse/Util",
+ "description": "Display any value inline for debugging."
+ },
+ {
+ "name": "easy XYPlot",
+ "displayName": "easy XYPlot",
+ "category": "EasyUse/XYPlot",
+ "description": "Compose XY plots over arbitrary parameters."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-advanced-controlnet",
+ "registryId": "comfyui-advanced-controlnet",
+ "displayName": "ComfyUI-Advanced-ControlNet",
+ "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
+ "repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
+ "publisher": {
+ "id": "kosinkadink",
+ "name": "Kosinkadink"
+ },
+ "downloads": 590539,
+ "githubStars": 967,
+ "latestVersion": "1.5.7",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-03-30T01:40:06.836236Z",
+ "nodes": [
+ {
+ "name": "Apply Advanced ControlNet",
+ "displayName": "Apply Advanced ControlNet",
+ "category": "Adv-ControlNet/conditioning",
+ "description": "Apply ControlNet with timestep keyframes and per-frame masks."
+ },
+ {
+ "name": "ControlNetLoaderAdvanced",
+ "displayName": "ControlNetLoaderAdvanced",
+ "category": "Adv-ControlNet/loaders",
+ "description": "Load ControlNet models with the advanced wrapper."
+ },
+ {
+ "name": "Latent Keyframe Group",
+ "displayName": "Latent Keyframe Group",
+ "category": "Adv-ControlNet/keyframes",
+ "description": "Schedule ControlNet strength over a batch of latents."
+ },
+ {
+ "name": "Timestep Keyframe",
+ "displayName": "Timestep Keyframe",
+ "category": "Adv-ControlNet/keyframes",
+ "description": "Set ControlNet strength at a specific timestep."
+ },
+ {
+ "name": "Scaled Soft Mask",
+ "displayName": "Scaled Soft Mask",
+ "category": "Adv-ControlNet/masks",
+ "description": "Apply a soft attention mask to ControlNet conditioning."
+ }
+ ]
+ },
+ {
+ "id": "was-node-suite-comfyui",
+ "registryId": "was-node-suite-comfyui",
+ "displayName": "WAS Node Suite",
+ "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
+ "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
+ "publisher": {
+ "id": "was",
+ "name": "WAS"
+ },
+ "downloads": 981051,
+ "githubStars": 1777,
+ "latestVersion": "1.0.1",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2024-08-01T05:28:23.655235Z",
+ "nodes": [
+ {
+ "name": "Image Filter Adjustments",
+ "displayName": "Image Filter Adjustments",
+ "category": "WAS Suite/Image/Adjustment",
+ "description": "Adjust brightness, contrast, saturation and more."
+ },
+ {
+ "name": "Image Blending Mode",
+ "displayName": "Image Blending Mode",
+ "category": "WAS Suite/Image/Compositing",
+ "description": "Composite two images with Photoshop-style blend modes."
+ },
+ {
+ "name": "Text String",
+ "displayName": "Text String",
+ "category": "WAS Suite/Text",
+ "description": "A reusable text constant suitable for prompts."
+ },
+ {
+ "name": "Number to Float",
+ "displayName": "Number to Float",
+ "category": "WAS Suite/Number",
+ "description": "Cast integer or string values to a float."
+ },
+ {
+ "name": "Image Save",
+ "displayName": "Image Save",
+ "category": "WAS Suite/IO",
+ "description": "Save an image to disk with rich filename templating."
+ }
+ ]
+ },
+ {
+ "id": "comfyui_ipadapter_plus",
+ "registryId": "comfyui_ipadapter_plus",
+ "displayName": "ComfyUI_IPAdapter_plus",
+ "description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
+ "repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
+ "publisher": {
+ "id": "matteo",
+ "name": "Matteo"
+ },
+ "downloads": 1208394,
+ "githubStars": 5938,
+ "latestVersion": "2.0.0",
+ "license": "GPL-3.0 license",
+ "lastUpdated": "2024-06-05T06:57:13.485481Z",
+ "nodes": [
+ {
+ "name": "IPAdapterUnifiedLoader",
+ "displayName": "IPAdapterUnifiedLoader",
+ "category": "ipadapter",
+ "description": "Load IPAdapter, image encoder and CLIP vision in one node."
+ },
+ {
+ "name": "IPAdapterFaceID",
+ "displayName": "IPAdapterFaceID",
+ "category": "ipadapter/faceid",
+ "description": "Apply Face ID embeddings for high-fidelity portrait reference."
+ },
+ {
+ "name": "IPAdapterStyleComposition",
+ "displayName": "IPAdapterStyleComposition",
+ "category": "ipadapter",
+ "description": "Reference an image for style without copying its content."
+ },
+ {
+ "name": "IPAdapterAdvanced",
+ "displayName": "IPAdapterAdvanced",
+ "category": "ipadapter",
+ "description": "Full-control IPAdapter with masking, weights, and noise injection."
+ },
+ {
+ "name": "IPAdapterEncoder",
+ "displayName": "IPAdapterEncoder",
+ "category": "ipadapter/embeds",
+ "description": "Encode reference images into IPAdapter embeddings for reuse."
+ }
+ ]
+ }
+ ]
+}
diff --git a/apps/website/src/data/cloudNodes.ts b/apps/website/src/data/cloudNodes.ts
new file mode 100644
index 0000000000..fb25603564
--- /dev/null
+++ b/apps/website/src/data/cloudNodes.ts
@@ -0,0 +1,52 @@
+export interface PackNode {
+ name: string
+ displayName: string
+ category: string
+ description?: string
+ deprecated?: boolean
+ experimental?: boolean
+}
+
+export interface Pack {
+ id: string
+ registryId?: string
+ displayName: string
+ description?: string
+ bannerUrl?: string
+ iconUrl?: string
+ repoUrl?: string
+ publisher?: {
+ id: string
+ name?: string
+ }
+ downloads?: number
+ githubStars?: number
+ latestVersion?: string
+ license?: string
+ lastUpdated?: string
+ supportedOs?: string[]
+ supportedAccelerators?: string[]
+ nodes: PackNode[]
+}
+
+export interface NodesSnapshot {
+ fetchedAt: string
+ packs: Pack[]
+}
+
+export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
+ if (value === null || typeof value !== 'object') return false
+ const candidate = value as { fetchedAt?: unknown; packs?: unknown }
+ if (typeof candidate.fetchedAt !== 'string') return false
+ if (!Array.isArray(candidate.packs)) return false
+
+ return candidate.packs.every((pack) => {
+ if (pack === null || typeof pack !== 'object') return false
+ const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
+ return (
+ typeof p.id === 'string' &&
+ typeof p.displayName === 'string' &&
+ Array.isArray(p.nodes)
+ )
+ })
+}
diff --git a/apps/website/src/data/roles.ts b/apps/website/src/data/roles.ts
index d1e4cf075d..dd2896d28f 100644
--- a/apps/website/src/data/roles.ts
+++ b/apps/website/src/data/roles.ts
@@ -3,7 +3,7 @@ export interface Role {
title: string
department: string
location: string
- applyUrl: string
+ jobUrl: string
}
export interface Department {
diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts
index e2c669f18d..1bf6dfa1ff 100644
--- a/apps/website/src/i18n/translations.ts
+++ b/apps/website/src/i18n/translations.ts
@@ -735,6 +735,142 @@ const translations = {
'zh-CN': '免费试用 COMFY CLOUD'
},
+ 'cloudNodes.hero.label': {
+ en: 'CLOUD NODES',
+ 'zh-CN': '云端节点目录'
+ },
+ 'cloudNodes.hero.heading': {
+ en: 'Run your favorite ComfyUI custom nodes on the cloud',
+ 'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
+ },
+ 'cloudNodes.hero.body': {
+ en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
+ 'zh-CN':
+ '在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
+ },
+ 'cloudNodes.section.heading': {
+ en: 'Find a custom-node pack',
+ 'zh-CN': '查找自定义节点包'
+ },
+ 'cloudNodes.search.placeholder': {
+ en: 'Search packs or nodes',
+ 'zh-CN': '搜索节点包或节点名称'
+ },
+ 'cloudNodes.sort.downloads': {
+ en: 'Most installed',
+ 'zh-CN': '按安装量'
+ },
+ 'cloudNodes.sort.mostNodes': {
+ en: 'Most nodes',
+ 'zh-CN': '按节点数量'
+ },
+ 'cloudNodes.sort.az': {
+ en: 'A → Z',
+ 'zh-CN': '按名称 A → Z'
+ },
+ 'cloudNodes.sort.recentlyUpdated': {
+ en: 'Recently updated',
+ 'zh-CN': '最近更新'
+ },
+ 'cloudNodes.search.label': {
+ en: 'Search custom-node packs',
+ 'zh-CN': '搜索自定义节点包'
+ },
+ 'cloudNodes.sort.label': {
+ en: 'Sort packs',
+ 'zh-CN': '排序节点包'
+ },
+ 'cloudNodes.list.ariaLabel': {
+ en: 'Custom-node packs supported on Comfy Cloud',
+ 'zh-CN': 'Comfy Cloud 支持的自定义节点包'
+ },
+ 'cloudNodes.meta.title': {
+ en: 'Custom-node packs on Comfy Cloud — supported by default',
+ 'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
+ },
+ 'cloudNodes.meta.description': {
+ en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
+ 'zh-CN':
+ '浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
+ },
+ 'cloudNodes.detail.metaTitle': {
+ en: '{pack} on Comfy Cloud',
+ 'zh-CN': '{pack}(Comfy Cloud)'
+ },
+ 'cloudNodes.detail.metaDescription': {
+ en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
+ 'zh-CN':
+ '{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
+ },
+ 'cloudNodes.empty.heading': {
+ en: 'No matching packs',
+ 'zh-CN': '未找到匹配的节点包'
+ },
+ 'cloudNodes.empty.body': {
+ en: 'Try a different search term or clear your filters.',
+ 'zh-CN': '试试其他关键词,或清空筛选条件。'
+ },
+ 'cloudNodes.card.nodeCountOne': {
+ en: '{count} node',
+ 'zh-CN': '{count} 个节点'
+ },
+ 'cloudNodes.card.nodeCountOther': {
+ en: '{count} nodes',
+ 'zh-CN': '{count} 个节点'
+ },
+ 'cloudNodes.card.viewRepo': {
+ en: 'View repository',
+ 'zh-CN': '查看仓库'
+ },
+ 'cloudNodes.card.unavailableDescription': {
+ en: 'Description unavailable.',
+ 'zh-CN': '暂无描述信息。'
+ },
+ 'cloudNodes.card.nodesHeading': {
+ en: 'Included nodes',
+ 'zh-CN': '包含节点'
+ },
+ 'cloudNodes.detail.back': {
+ en: 'Back to all packs',
+ 'zh-CN': '返回所有节点包'
+ },
+ 'cloudNodes.detail.publisher': {
+ en: 'Publisher',
+ 'zh-CN': '发布者'
+ },
+ 'cloudNodes.detail.downloads': {
+ en: 'Downloads',
+ 'zh-CN': '下载量'
+ },
+ 'cloudNodes.detail.stars': {
+ en: 'GitHub stars',
+ 'zh-CN': 'GitHub 星标'
+ },
+ 'cloudNodes.detail.latestVersion': {
+ en: 'Latest version',
+ 'zh-CN': '最新版本'
+ },
+ 'cloudNodes.detail.license': {
+ en: 'License',
+ 'zh-CN': '许可证'
+ },
+ 'cloudNodes.detail.lastUpdated': {
+ en: 'Last updated',
+ 'zh-CN': '最后更新'
+ },
+ 'cloudNodes.detail.deprecated': {
+ en: 'Deprecated',
+ 'zh-CN': '已弃用'
+ },
+ 'cloudNodes.detail.experimental': {
+ en: 'Experimental',
+ 'zh-CN': '实验性'
+ },
+ 'cloudNodes.detail.nodesHeading': {
+ en: 'Nodes in this pack',
+ 'zh-CN': '此节点包中的节点'
+ },
+
// Cloud – ReasonSection
'cloud.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
@@ -3570,6 +3706,20 @@ const translations = {
'打开模板浏览器 — 点击 ComfyUI 侧栏中的模板图标。浏览分类 — 模板按任务分类:图像生成、视频、放大等。预览模板 — 将鼠标悬停在模板上查看预览。加载并自定义 — 点击加载模板,然后修改参数。 '
},
+ 'demos.community-workflows.title': {
+ en: 'Explore and Use a Community Workflow from the Hub',
+ 'zh-CN': '探索并使用社区工作流'
+ },
+ 'demos.community-workflows.description': {
+ en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
+ 'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
+ },
+ 'demos.community-workflows.transcript': {
+ en: 'Open the Workflow Hub — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.Browse popular workflows — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.Preview a workflow — Click a workflow card to see example outputs, required models, and a description of what it produces.Open in ComfyUI — Use the "Get Started" action to load the selected community workflow directly onto your canvas.Run and customize — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own. ',
+ 'zh-CN':
+ '打开工作流中心 — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。浏览热门工作流 — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。预览工作流 — 点击工作流卡片,查看示例输出、所需模型和功能描述。在 ComfyUI 中打开 — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。运行并自定义 — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。 '
+ },
+
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
@@ -3664,6 +3814,60 @@ const translations = {
'zh-CN': 'Creative Studios AI 负责人'
},
+ // Models – UI keys
+ 'models.hero.eyebrow': {
+ en: 'AI Model',
+ 'zh-CN': 'AI 模型'
+ },
+ 'models.hero.primaryCta': {
+ en: 'TRY IN COMFY',
+ 'zh-CN': '在 Comfy 中试用'
+ },
+ 'models.hero.secondaryCta': {
+ en: 'DOWNLOAD MODEL',
+ 'zh-CN': '下载模型'
+ },
+ 'models.hero.cloudCta': {
+ en: 'RUN ON CLOUD',
+ 'zh-CN': '云端运行'
+ },
+ 'models.hero.tutorialCta': {
+ en: 'VIEW TUTORIAL',
+ 'zh-CN': '查看教程'
+ },
+ 'models.hero.blogLink': {
+ en: 'Read blog post',
+ 'zh-CN': '阅读博客文章'
+ },
+ 'models.hero.workflowCount': {
+ en: '{count} workflows use this model',
+ 'zh-CN': '{count} 个工作流使用此模型'
+ },
+ 'models.whatIs.heading': {
+ en: 'What is {name}?',
+ 'zh-CN': '什么是 {name}?'
+ },
+ 'models.whatIs.tutorialLink': {
+ en: 'Read the full tutorial →',
+ 'zh-CN': '阅读完整教程 →'
+ },
+ 'models.index.title': {
+ en: 'Supported Models',
+ 'zh-CN': '支持的模型'
+ },
+ 'models.index.subtitle': {
+ 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': '支持的模型'
+ },
+
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',
diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro
index e18cbffe16..d46e85c7b3 100644
--- a/apps/website/src/layouts/BaseLayout.astro
+++ b/apps/website/src/layouts/BaseLayout.astro
@@ -5,6 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
+import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
@@ -94,8 +95,11 @@ const websiteJsonLd = {
-
-
+
+
+
+
+
{gtmEnabled && (
diff --git a/apps/website/src/pages/cloud/supported-nodes.astro b/apps/website/src/pages/cloud/supported-nodes.astro
new file mode 100644
index 0000000000..5d1e14aae5
--- /dev/null
+++ b/apps/website/src/pages/cloud/supported-nodes.astro
@@ -0,0 +1,42 @@
+---
+import BaseLayout from '../../layouts/BaseLayout.astro'
+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'
+
+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,
+ name: pack.displayName,
+ image: pack.bannerUrl || pack.iconUrl
+ }))
+}
+---
+
+
+
+
+
+
diff --git a/apps/website/src/pages/cloud/supported-nodes/AGENTS.md b/apps/website/src/pages/cloud/supported-nodes/AGENTS.md
new file mode 100644
index 0000000000..0ae27dd0b6
--- /dev/null
+++ b/apps/website/src/pages/cloud/supported-nodes/AGENTS.md
@@ -0,0 +1,48 @@
+# Cloud Nodes Pages
+
+Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
+
+## Sources
+
+- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
+- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
+
+The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
+
+- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
+- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
+- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
+- Public docs:
+
+## Build pipeline
+
+| File | Role |
+| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
+| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
+| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
+| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
+| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
+| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `` or `
-
+
{{ errorMessageValue }}
diff --git a/src/components/rightSidePanel/parameters/TabNodes.vue b/src/components/rightSidePanel/parameters/TabNodes.vue
index da5da33d26..8f7da538fc 100644
--- a/src/components/rightSidePanel/parameters/TabNodes.vue
+++ b/src/components/rightSidePanel/parameters/TabNodes.vue
@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -44,24 +44,24 @@ watch(
}
)
-function isSectionCollapsed(nodeId: string): boolean {
+function isSectionCollapsed(nodeId: NodeId): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true
}
-function setSectionCollapsed(nodeId: string, collapsed: boolean) {
+function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
return searchedWidgetsSectionDataList.value.every(({ node }) =>
- isSectionCollapsed(String(node.id))
+ isSectionCollapsed(node.id)
)
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
- setSectionCollapsed(String(node.id), collapse)
+ setSectionCollapsed(node.id, collapse)
}
}
})
@@ -101,7 +101,7 @@ async function searcher(query: string) {
:key="node.id"
:node
:widgets
- :collapse="isSectionCollapsed(String(node.id)) && !isSearching"
+ :collapse="isSectionCollapsed(node.id) && !isSearching"
:tooltip="
isSearching || widgets.length
? ''
@@ -109,7 +109,7 @@ async function searcher(query: string) {
"
show-locate-button
class="border-b border-interface-stroke"
- @update:collapse="setSectionCollapsed(String(node.id), $event)"
+ @update:collapse="setSectionCollapsed(node.id, $event)"
/>
diff --git a/src/components/rightSidePanel/parameters/TabNormalInputs.vue b/src/components/rightSidePanel/parameters/TabNormalInputs.vue
index e104b98f4c..ba89698667 100644
--- a/src/components/rightSidePanel/parameters/TabNormalInputs.vue
+++ b/src/components/rightSidePanel/parameters/TabNormalInputs.vue
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -68,19 +68,19 @@ watch(
}
)
-function isSectionCollapsed(nodeId: string): boolean {
+function isSectionCollapsed(nodeId: NodeId): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
}
-function setSectionCollapsed(nodeId: string, collapsed: boolean) {
+function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
- ({ node }) => isSectionCollapsed(String(node.id))
+ ({ node }) => isSectionCollapsed(node.id)
)
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
return hasAdvanced
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
- setSectionCollapsed(String(node.id), collapse)
+ setSectionCollapsed(node.id, collapse)
}
advancedCollapsed.value = collapse
}
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
:node
:label
:widgets
- :collapse="isSectionCollapsed(String(node.id)) && !isSearching"
+ :collapse="isSectionCollapsed(node.id) && !isSearching"
:show-locate-button="isMultipleNodesSelected"
:tooltip="
isSearching || widgets.length
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
- @update:collapse="setSectionCollapsed(String(node.id), $event)"
+ @update:collapse="setSectionCollapsed(node.id, $event)"
/>
diff --git a/src/components/searchbox/v2/NodeSearchFilterBar.test.ts b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts
index 70f0b282a3..c71a936bb0 100644
--- a/src/components/searchbox/v2/NodeSearchFilterBar.test.ts
+++ b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts
@@ -55,7 +55,7 @@ describe(NodeSearchFilterBar, () => {
const buttonTexts = () =>
screen.getAllByRole('button').map((b) => b.textContent?.trim())
- it.each([
+ it.for([
{ prop: 'hasFavorites', label: 'Bookmarked' },
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
{ prop: 'hasEssentialNodes', label: 'Essentials' },
diff --git a/src/components/ui/color-picker/ColorPicker.vue b/src/components/ui/color-picker/ColorPicker.vue
index da27e0dfd9..c8837991da 100644
--- a/src/components/ui/color-picker/ColorPicker.vue
+++ b/src/components/ui/color-picker/ColorPicker.vue
@@ -68,7 +68,7 @@ const isOpen = ref(false)
type="button"
:class="
cn(
- 'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
+ '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',
isOpen && 'border-node-stroke',
$props.class
)
@@ -91,7 +91,7 @@ const isOpen = ref(false)
{{ displayHex }}
diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts
index 8bb07a76bb..0a4b351333 100644
--- a/src/composables/graph/useErrorClearingHooks.test.ts
+++ b/src/composables/graph/useErrorClearingHooks.test.ts
@@ -21,6 +21,11 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
+import type { MissingModelCandidate } from '@/platform/missingModel/types'
+
+beforeEach(() => {
+ vi.restoreAllMocks()
+})
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
@@ -347,6 +352,90 @@ describe('installErrorClearingHooks lifecycle', () => {
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
+
+ it('scans added-node missing models after widget values are restored', async () => {
+ const graph = new LGraph()
+ vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
+ installErrorClearingHooks(graph)
+
+ const node = new LGraphNode('CheckpointLoaderSimple')
+ node.type = 'CheckpointLoaderSimple'
+ const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
+ values: []
+ })
+
+ graph.add(node)
+ widget.value = 'fake_model.safetensors'
+
+ await Promise.resolve()
+
+ expect(useMissingModelStore().missingModelCandidates).toEqual([
+ expect.objectContaining({ name: 'fake_model.safetensors' })
+ ])
+ })
+
+ it('scans added-node missing models before the deferred media scan', async () => {
+ const graph = new LGraph()
+ vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
+ const modelScan = vi
+ .spyOn(missingModelScan, 'scanNodeModelCandidates')
+ .mockImplementation((_rootGraph, node) => [
+ {
+ nodeId: String(node.id),
+ nodeType: node.type,
+ widgetName: 'ckpt_name',
+ isAssetSupported: false,
+ name: 'fake_model.safetensors',
+ directory: 'checkpoints',
+ isMissing: true
+ } satisfies MissingModelCandidate
+ ])
+ const mediaScan = vi
+ .spyOn(missingMediaScan, 'scanNodeMediaCandidates')
+ .mockReturnValue([])
+ installErrorClearingHooks(graph)
+
+ const node = new LGraphNode('CheckpointLoaderSimple')
+ node.type = 'CheckpointLoaderSimple'
+ graph.add(node)
+
+ await Promise.resolve()
+
+ expect(modelScan).toHaveBeenCalledOnce()
+ expect(useMissingModelStore().missingModelCandidates).toEqual([
+ expect.objectContaining({ name: 'fake_model.safetensors' })
+ ])
+ expect(mediaScan).not.toHaveBeenCalled()
+
+ await Promise.resolve()
+
+ expect(mediaScan).toHaveBeenCalledTimes(1)
+ expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
+ mediaScan.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
+ const graph = new LGraph()
+ vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
+ vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
+ const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
+ installErrorClearingHooks(graph)
+
+ const node = new LGraphNode('LoadVideo')
+ node.type = 'LoadVideo'
+ node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
+ values: []
+ })
+
+ graph.add(node)
+ await Promise.resolve()
+ node.isUploading = true
+ await Promise.resolve()
+
+ expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
+ expect(mediaScan).toHaveBeenCalledOnce()
+ })
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
@@ -543,7 +632,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
- .spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
+ .spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -611,7 +700,6 @@ describe('realtime scan verifies pending cloud candidates', () => {
describe('realtime verification staleness guards', () => {
beforeEach(() => {
- vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
@@ -686,7 +774,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise((r) => (resolveVerify = r))
const verifySpy = vi
- .spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
+ .spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
@@ -771,7 +859,6 @@ describe('realtime verification staleness guards', () => {
describe('scan skips interior of bypassed subgraph containers', () => {
beforeEach(() => {
- vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
diff --git a/src/composables/graph/useErrorClearingHooks.ts b/src/composables/graph/useErrorClearingHooks.ts
index 3a6f00929a..2568643bd1 100644
--- a/src/composables/graph/useErrorClearingHooks.ts
+++ b/src/composables/graph/useErrorClearingHooks.ts
@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
- verifyCloudMediaCandidates
+ verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -155,25 +155,26 @@ function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
-/** Scan a single node and add confirmed missing model/media to stores.
- * For subgraph containers, also scans all active interior nodes. */
-function scanAndAddNodeErrors(node: LGraphNode): void {
+function scanNodeErrorTargets(
+ node: LGraphNode,
+ scanNode: (node: LGraphNode) => void
+): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (innerNode.isSubgraphNode?.()) continue
if (isNodeInactive(innerNode.mode)) continue
- scanSingleNodeErrors(innerNode)
+ scanNode(innerNode)
}
return
}
- scanSingleNodeErrors(node)
+ scanNode(node)
}
-function scanSingleNodeErrors(node: LGraphNode): void {
- if (!app.rootGraph) return
+function getActiveExecutionId(node: LGraphNode): string | null {
+ if (!app.rootGraph) return null
// Skip when any enclosing subgraph is muted/bypassed. Callers only
// verify each node's own mode; entering a bypassed subgraph (via
// useGraphNodeManager replaying onNodeAdded for existing interior
@@ -181,7 +182,25 @@ function scanSingleNodeErrors(node: LGraphNode): void {
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
- if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return
+ if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
+ return execId
+}
+
+/** Scan a single node and add confirmed missing model/media to stores.
+ * For subgraph containers, also scans all active interior nodes. */
+function scanAndAddNodeErrors(node: LGraphNode): void {
+ scanNodeErrorTargets(node, scanSingleNodeErrors)
+}
+
+function scanSingleNodeErrors(node: LGraphNode): void {
+ scanSingleNodeModelsAndTypes(node)
+ scanSingleNodeMedia(node)
+}
+
+function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
+ if (!app.rootGraph) return
+ const execId = getActiveExecutionId(node)
+ if (!execId) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
@@ -204,39 +223,40 @@ function scanSingleNodeErrors(node: LGraphNode): void {
void verifyAndAddPendingModels(pendingModels)
}
+ const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
+ if (!(originalType in LiteGraph.registered_node_types)) {
+ const nodeReplacementStore = useNodeReplacementStore()
+ const replacement = nodeReplacementStore.getReplacementFor(originalType)
+ const store = useMissingNodesErrorStore()
+ const existing = store.missingNodesError?.nodeTypes ?? []
+ store.surfaceMissingNodes([
+ ...existing,
+ {
+ type: originalType,
+ nodeId: execId,
+ cnrId: getCnrIdFromNode(node),
+ isReplaceable: replacement !== null,
+ replacement: replacement ?? undefined
+ }
+ ])
+ }
+}
+
+function scanSingleNodeMedia(node: LGraphNode): void {
+ if (!app.rootGraph) return
+ if (!getActiveExecutionId(node)) return
+
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
- // Cloud media scans always return isMissing: undefined pending
- // verification against the input-assets list.
+ // Cloud media scans return pending for asset verification. OSS scans only
+ // return pending for generated output media.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
-
- // Check for missing node type
- const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
- if (!(originalType in LiteGraph.registered_node_types)) {
- const execId = getExecutionIdByNode(app.rootGraph, node)
- if (execId) {
- const nodeReplacementStore = useNodeReplacementStore()
- const replacement = nodeReplacementStore.getReplacementFor(originalType)
- const store = useMissingNodesErrorStore()
- const existing = store.missingNodesError?.nodeTypes ?? []
- store.surfaceMissingNodes([
- ...existing,
- {
- type: originalType,
- nodeId: execId,
- cnrId: getCnrIdFromNode(node),
- isReplaceable: replacement !== null,
- replacement: replacement ?? undefined
- }
- ])
- }
- }
}
/**
@@ -282,7 +302,7 @@ async function verifyAndAddPendingMedia(
): Promise {
const rootGraphAtScan = app.rootGraph
try {
- await verifyCloudMediaCandidates(pending)
+ await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
@@ -293,10 +313,23 @@ async function verifyAndAddPendingMedia(
}
}
-function scanAddedNode(node: LGraphNode): void {
+function scanAddedNode(
+ node: LGraphNode,
+ scanNode: (node: LGraphNode) => void
+): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
- scanAndAddNodeErrors(node)
+ scanNodeErrorTargets(node, scanNode)
+}
+
+function scheduleAddedNodeScan(node: LGraphNode): void {
+ queueMicrotask(() => {
+ scanAddedNode(node, scanSingleNodeModelsAndTypes)
+ // Paste/drop upload handlers run immediately after graph.add and must set
+ // node.isUploading synchronously before their first await. This second
+ // microtask lets that upload state settle before media widgets are scanned.
+ queueMicrotask(() => scanAddedNode(node, scanSingleNodeMedia))
+ })
}
function handleNodeModeChange(
@@ -368,10 +401,12 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
- // Deferred to microtask because onNodeAdded fires before
- // node.configure() restores widget values.
+ // Model and node scans use the original one-microtask deferral so pasted
+ // missing-model errors appear before selection-scoped tabs recalculate.
+ // Media gets one extra microtask so drag/drop upload handlers can mark
+ // transient upload state before media detection reads the widget value.
if (!ChangeTracker.isLoadingGraph) {
- queueMicrotask(() => scanAddedNode(node))
+ scheduleAddedNodeScan(node)
}
originalOnNodeAdded?.call(this, node)
diff --git a/src/composables/node/useNodeImageUpload.test.ts b/src/composables/node/useNodeImageUpload.test.ts
index 662d39c0b6..59a788c064 100644
--- a/src/composables/node/useNodeImageUpload.test.ts
+++ b/src/composables/node/useNodeImageUpload.test.ts
@@ -54,8 +54,8 @@ function createMockNode(): LGraphNode {
})
}
-function createFile(name = 'test.png'): File {
- return new File(['data'], name, { type: 'image/png' })
+function createFile(name = 'test.png', type = 'image/png'): File {
+ return new File(['data'], name, { type })
}
function successResponse(name: string, subfolder?: string) {
@@ -95,15 +95,21 @@ describe('useNodeImageUpload', () => {
})
})
- it('sets isUploading true during upload and false after', async () => {
- mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
+ it.for([
+ { mediaType: 'image', filename: 'test.png', mimeType: 'image/png' },
+ { mediaType: 'video', filename: 'clip.mp4', mimeType: 'video/mp4' }
+ ])(
+ 'sets isUploading true during $mediaType upload and false after',
+ async ({ filename, mimeType }) => {
+ mockFetchApi.mockResolvedValueOnce(successResponse(filename))
- const promise = capturedDragOnDrop([createFile()])
- expect(node.isUploading).toBe(true)
+ const promise = capturedDragOnDrop([createFile(filename, mimeType)])
+ expect(node.isUploading).toBe(true)
- await promise
- expect(node.isUploading).toBe(false)
- })
+ await promise
+ expect(node.isUploading).toBe(false)
+ }
+ )
it('clears node.imgs on upload start', async () => {
mockFetchApi.mockResolvedValueOnce(successResponse('test.png'))
diff --git a/src/composables/painter/usePainter.test.ts b/src/composables/painter/usePainter.test.ts
index 80a64fef2d..ef70fc7410 100644
--- a/src/composables/painter/usePainter.test.ts
+++ b/src/composables/painter/usePainter.test.ts
@@ -349,25 +349,75 @@ describe('usePainter', () => {
})
describe('serializeValue', () => {
- it('returns empty string when canvas has no strokes', async () => {
+ it('returns existing modelValue when not dirty (preserves workflow-restored mask reference across WidgetPainter remount)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
- mountPainter()
+ mountPainter('test-node', 'painter/existing.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
- expect(result).toBe('')
+ expect(result).toBe('painter/existing.png [temp]')
})
- it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
+ it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
- const { modelValue } = mountPainter()
- modelValue.value = 'painter/existing.png [temp]'
+ const fetchApiMock = vi.mocked(api.fetchApi)
+ fetchApiMock.mockResolvedValueOnce({
+ status: 200,
+ json: async () => ({ name: 'uploaded.png' })
+ } as Response)
+
+ const fakeCanvas = {
+ width: 4,
+ height: 4,
+ toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
+ } as unknown as HTMLCanvasElement
+
+ const { canvasEl } = mountPainter('test-node', '')
+ canvasEl.value = fakeCanvas
+ await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
- expect(result).toBe('')
+ expect(fetchApiMock).toHaveBeenCalledWith(
+ '/upload/image',
+ expect.objectContaining({ method: 'POST' })
+ )
+ expect(result).toBe('painter/uploaded.png [temp]')
+ })
+
+ it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
+ const maskWidget = makeWidget('mask', '')
+ mockWidgets.push(maskWidget)
+
+ mountPainter('test-node', 'painter/cached.png [temp]')
+
+ const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
+ expect(result).toBe('painter/cached.png [temp]')
+ })
+
+ it('clears the cached upload reference when the user clears the canvas', () => {
+ const maskWidget = makeWidget('mask', '')
+ mockWidgets.push(maskWidget)
+
+ const fakeCanvas = {
+ width: 4,
+ height: 4,
+ getContext: vi.fn(() => ({
+ clearRect: vi.fn()
+ }))
+ } as unknown as HTMLCanvasElement
+
+ const { painter, canvasEl, modelValue } = mountPainter(
+ 'test-node',
+ 'painter/old-upload.png [temp]'
+ )
+ canvasEl.value = fakeCanvas
+
+ painter.handleClear()
+
+ expect(modelValue.value).toBe('')
})
})
diff --git a/src/composables/painter/usePainter.ts b/src/composables/painter/usePainter.ts
index 3c60ddfbf7..4fa623c331 100644
--- a/src/composables/painter/usePainter.ts
+++ b/src/composables/painter/usePainter.ts
@@ -61,7 +61,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
- let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
@@ -413,7 +412,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
- hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -513,7 +511,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
- hasStrokes = false
+ modelValue.value = ''
}
function updateCursorPos(e: PointerEvent) {
@@ -619,17 +617,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}
- function isCanvasEmpty(): boolean {
- return !hasStrokes
- }
-
async function serializeValue(): Promise {
const el = canvasEl.value
- if (!el) return ''
+ if (!el) return modelValue.value
- if (isCanvasEmpty()) return ''
-
- if (!isDirty.value) return modelValue.value
+ if (!isDirty.value && modelValue.value) return modelValue.value
const blob = await new Promise((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -717,7 +709,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
- hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''
diff --git a/src/composables/queue/useJobMenu.test.ts b/src/composables/queue/useJobMenu.test.ts
index 3b64ce4b6c..e06d97f2a4 100644
--- a/src/composables/queue/useJobMenu.test.ts
+++ b/src/composables/queue/useJobMenu.test.ts
@@ -263,10 +263,10 @@ describe('useJobMenu', () => {
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
- it.each([
+ it.for([
['running', interruptMock, deleteItemMock],
['initialization', interruptMock, deleteItemMock]
- ])('cancels %s job via interrupt', async (state) => {
+ ])('cancels %s job via interrupt', async ([state]) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
@@ -417,7 +417,7 @@ describe('useJobMenu', () => {
}
] as const
- it.each(previewCases)(
+ it.for(previewCases)(
'adds loader node for %s preview output',
async ({ flags, expectedNode, widget }) => {
const widgetCallback = vi.fn()
diff --git a/src/composables/queue/useQueueProgress.test.ts b/src/composables/queue/useQueueProgress.test.ts
index a0d7231828..bc81d2ac3a 100644
--- a/src/composables/queue/useQueueProgress.test.ts
+++ b/src/composables/queue/useQueueProgress.test.ts
@@ -58,7 +58,7 @@ describe('useQueueProgress', () => {
setExecutingNodeProgress(null)
})
- it.each([
+ it.for([
{
description: 'defaults to 0% when execution store values are missing',
execution: undefined,
diff --git a/src/composables/useErrorHandling.test.ts b/src/composables/useErrorHandling.test.ts
index 5c7c8212bd..122c9528eb 100644
--- a/src/composables/useErrorHandling.test.ts
+++ b/src/composables/useErrorHandling.test.ts
@@ -324,11 +324,11 @@ describe('useErrorHandling', () => {
})
describe('network error detection', () => {
- it.each([
+ it.for([
['Failed to fetch', 'Chrome/Edge'],
['NetworkError when attempting to fetch resource.', 'Firefox'],
['Load failed', 'Safari']
- ])('should show disconnected toast for "%s" (%s)', async (message) => {
+ ])('should show disconnected toast for "%s" (%s)', async ([message]) => {
const action = vi.fn(async () => {
throw new TypeError(message)
})
diff --git a/src/composables/useReconnectQueueRefresh.test.ts b/src/composables/useReconnectQueueRefresh.test.ts
new file mode 100644
index 0000000000..dc21ff7b47
--- /dev/null
+++ b/src/composables/useReconnectQueueRefresh.test.ts
@@ -0,0 +1,88 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { useExecutionStore } from '@/stores/executionStore'
+
+function makeJob(id: string, status: JobListItem['status']): JobListItem {
+ return {
+ id,
+ status,
+ create_time: 0,
+ update_time: 0,
+ last_state_update: 0,
+ priority: 0
+ }
+}
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ getQueue: vi.fn(),
+ getHistory: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ apiURL: vi.fn((p: string) => `/api${p}`)
+ }
+}))
+
+describe('useReconnectQueueRefresh', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ vi.restoreAllMocks()
+ vi.mocked(api.getQueue).mockResolvedValue({ Running: [], Pending: [] })
+ vi.mocked(api.getHistory).mockResolvedValue([])
+ })
+
+ it('forwards running+pending job ids to clearActiveJobIfStale', async () => {
+ vi.mocked(api.getQueue).mockResolvedValue({
+ Running: [makeJob('run-1', 'in_progress')],
+ Pending: [makeJob('pend-1', 'pending'), makeJob('pend-2', 'pending')]
+ })
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh()
+
+ expect(clearSpy).toHaveBeenCalledTimes(1)
+ expect(clearSpy).toHaveBeenCalledWith(
+ new Set(['run-1', 'pend-1', 'pend-2'])
+ )
+ })
+
+ it('passes an empty set when the queue is genuinely empty', async () => {
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh()
+
+ expect(clearSpy).toHaveBeenCalledWith(new Set())
+ })
+
+ it('reuses the prior queue snapshot when the fetch fails, so a still-running job is not falsely cleared', async () => {
+ vi.mocked(api.getQueue)
+ .mockResolvedValueOnce({
+ Running: [makeJob('run-1', 'in_progress')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('network down'))
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh() // primes the store with run-1
+ await refresh() // network failure here — store must not go empty
+
+ expect(clearSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
+ })
+})
diff --git a/src/composables/useReconnectQueueRefresh.ts b/src/composables/useReconnectQueueRefresh.ts
new file mode 100644
index 0000000000..267ade9850
--- /dev/null
+++ b/src/composables/useReconnectQueueRefresh.ts
@@ -0,0 +1,25 @@
+import { useExecutionStore } from '@/stores/executionStore'
+import { useQueueStore } from '@/stores/queueStore'
+
+/**
+ * After a WebSocket reconnect, refresh the queue from the server and clear
+ * any active job that finished during the disconnect window. Returns the
+ * handler so the caller can wire it to the `reconnected` api event.
+ *
+ * `update()` preserves the previous queue snapshot when the fetch fails, so
+ * if the network is still flaky we reconcile against the last known good
+ * state rather than an empty (and falsely "stale") set.
+ */
+export function useReconnectQueueRefresh() {
+ const queueStore = useQueueStore()
+ const executionStore = useExecutionStore()
+
+ return async function refreshOnReconnect() {
+ await queueStore.update()
+ const activeJobIds = new Set([
+ ...queueStore.runningTasks.map((t) => t.jobId),
+ ...queueStore.pendingTasks.map((t) => t.jobId)
+ ])
+ executionStore.clearActiveJobIfStale(activeJobIds)
+ }
+}
diff --git a/src/constants/essentialsDisplayNames.test.ts b/src/constants/essentialsDisplayNames.test.ts
index 57967510f3..18b6dfd375 100644
--- a/src/constants/essentialsDisplayNames.test.ts
+++ b/src/constants/essentialsDisplayNames.test.ts
@@ -8,7 +8,7 @@ import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames
describe('resolveEssentialsDisplayName', () => {
describe('exact name matches', () => {
- it.each([
+ it.for([
['LoadImage', 'essentials.loadImage'],
['SaveImage', 'essentials.saveImage'],
['PrimitiveStringMultiline', 'essentials.text'],
@@ -22,26 +22,26 @@ describe('resolveEssentialsDisplayName', () => {
['Video Slice', 'essentials.extractFrame'],
['KlingLipSyncAudioToVideoNode', 'essentials.lipsync'],
['KlingLipSyncTextToVideoNode', 'essentials.lipsync']
- ])('%s -> %s', (name, expected) => {
+ ])('%s -> %s', ([name, expected]) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('3D API node alternatives', () => {
- it.each([
+ it.for([
['TencentTextToModelNode', 'essentials.textTo3DModel'],
['MeshyTextToModelNode', 'essentials.textTo3DModel'],
['TripoTextToModelNode', 'essentials.textTo3DModel'],
['TencentImageToModelNode', 'essentials.imageTo3DModel'],
['MeshyImageToModelNode', 'essentials.imageTo3DModel'],
['TripoImageToModelNode', 'essentials.imageTo3DModel']
- ])('%s -> %s', (name, expected) => {
+ ])('%s -> %s', ([name, expected]) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('blueprint prefix matches', () => {
- it.each([
+ it.for([
[
'SubgraphBlueprint.text_to_image_flux_schnell.json',
'essentials.textToImage'
@@ -82,7 +82,7 @@ describe('resolveEssentialsDisplayName', () => {
'SubgraphBlueprint.image_outpainting_qwen_image_instantx.json',
'essentials.outpaintImage'
]
- ])('%s -> %s', (name, expected) => {
+ ])('%s -> %s', ([name, expected]) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
diff --git a/src/extensions/core/editAttention.test.ts b/src/extensions/core/editAttention.test.ts
index d779c29dd3..1f3ec40b86 100644
--- a/src/extensions/core/editAttention.test.ts
+++ b/src/extensions/core/editAttention.test.ts
@@ -39,8 +39,16 @@ describe('incrementWeight', () => {
})
})
+type Enclosure = { start: number; end: number } | null
+type EnclosureCase = [
+ name: string,
+ text: string,
+ cursor: number,
+ expected: Enclosure
+]
+
describe('findNearestEnclosure', () => {
- it.each([
+ it.for([
[
'returns start and end of a simple parenthesized expression',
'(cat)',
@@ -74,13 +82,15 @@ describe('findNearestEnclosure', () => {
2,
null
]
- ])('%s', (_, text, cursor, expected) => {
+ ])('%s', ([, text, cursor, expected]) => {
expect(findNearestEnclosure(text, cursor)).toEqual(expected)
})
})
+type WeightCase = [name: string, input: string, expected: string]
+
describe('addWeightToParentheses', () => {
- it.each([
+ it.for([
['adds weight 1.0 to a bare parenthesized token', '(cat)', '(cat:1.0)'],
[
'leaves a token that already has a weight unchanged',
@@ -118,7 +128,7 @@ describe('addWeightToParentheses', () => {
'(sdxl1:0.8)',
'(sdxl1:0.8)'
]
- ])('%s', (_, input, expected) => {
+ ])('%s', ([, input, expected]) => {
expect(addWeightToParentheses(input)).toBe(expected)
})
})
diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts
index 0a0fe143a2..4626587523 100644
--- a/src/extensions/core/load3d.ts
+++ b/src/extensions/core/load3d.ts
@@ -9,7 +9,10 @@ import type {
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
-import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
+import {
+ LOAD3D_NONE_MODEL,
+ SUPPORTED_EXTENSIONS_ACCEPT
+} from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -290,13 +293,9 @@ useExtensionService().registerExtension({
)
node.addWidget('button', 'clear', 'clear', () => {
- useLoad3d(node).waitForLoad3d((load3d) => {
- load3d.clearModel()
- })
-
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
- modelWidget.value = ''
+ modelWidget.value = LOAD3D_NONE_MODEL
}
})
diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts
index 39db356fa5..3f9638cca9 100644
--- a/src/extensions/core/load3d/Load3DConfiguration.test.ts
+++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts
@@ -594,3 +594,91 @@ describe('Load3DConfiguration.configure forwards persisted + settings to load3d'
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
})
})
+
+describe('Load3DConfiguration "none" model handling', () => {
+ let load3d: Load3d
+ let loadModelSpy: ReturnType
+ let clearModelSpy: ReturnType
+
+ function makeLoad3dMock(): Load3d {
+ loadModelSpy = vi.fn().mockResolvedValue(undefined)
+ clearModelSpy = vi.fn()
+ return {
+ loadModel: loadModelSpy,
+ clearModel: clearModelSpy,
+ setUpDirection: vi.fn(),
+ setMaterialMode: vi.fn(),
+ setTargetSize: vi.fn(),
+ setCameraState: vi.fn(),
+ toggleGrid: vi.fn(),
+ setBackgroundColor: vi.fn(),
+ setBackgroundImage: vi.fn().mockResolvedValue(undefined),
+ setBackgroundRenderMode: vi.fn(),
+ toggleCamera: vi.fn(),
+ setFOV: vi.fn(),
+ setLightIntensity: vi.fn(),
+ setHDRIIntensity: vi.fn(),
+ setHDRIAsBackground: vi.fn(),
+ setHDRIEnabled: vi.fn(),
+ emitModelReady: vi.fn()
+ } as unknown as Load3d
+ }
+
+ async function flush() {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ beforeEach(() => {
+ load3d = makeLoad3dMock()
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('does not load or clear a model when the initial widget value is "none"', async () => {
+ const config = new Load3DConfiguration(load3d)
+ config.configure({
+ modelWidget: { value: 'none' } as unknown as IBaseWidget,
+ loadFolder: 'input'
+ })
+ await flush()
+
+ expect(loadModelSpy).not.toHaveBeenCalled()
+ expect(clearModelSpy).not.toHaveBeenCalled()
+ })
+
+ it('clears the model (and skips loadModel) when the widget value changes to "none"', async () => {
+ const config = new Load3DConfiguration(load3d)
+ const widget = { value: 'model.glb' } as unknown as IBaseWidget
+ config.configure({ modelWidget: widget, loadFolder: 'input' })
+ await flush()
+
+ loadModelSpy.mockClear()
+ clearModelSpy.mockClear()
+
+ widget.value = 'none'
+ await flush()
+
+ expect(clearModelSpy).toHaveBeenCalledTimes(1)
+ expect(loadModelSpy).not.toHaveBeenCalled()
+ })
+
+ it('loads a model when the widget value transitions from "none" to a real path', async () => {
+ const config = new Load3DConfiguration(load3d)
+ const widget = { value: 'none' } as unknown as IBaseWidget
+ config.configure({ modelWidget: widget, loadFolder: 'input' })
+ await flush()
+
+ expect(loadModelSpy).not.toHaveBeenCalled()
+
+ widget.value = 'model.glb'
+ await flush()
+
+ expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
+ silentOnNotFound: false
+ })
+ })
+})
diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts
index fa9fba7eb2..57e3da6453 100644
--- a/src/extensions/core/load3d/Load3DConfiguration.ts
+++ b/src/extensions/core/load3d/Load3DConfiguration.ts
@@ -1,3 +1,4 @@
+import { LOAD3D_NONE_MODEL } from '@/extensions/core/load3d/constants'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
@@ -109,7 +110,7 @@ class Load3DConfiguration {
cameraState,
silentOnNotFound
)
- if (modelWidget.value) {
+ if (modelWidget.value && modelWidget.value !== LOAD3D_NONE_MODEL) {
void onModelWidgetUpdate(modelWidget.value)
}
@@ -280,7 +281,10 @@ class Load3DConfiguration {
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
- if (!value) return
+ if (!value || value === LOAD3D_NONE_MODEL) {
+ this.load3d.clearModel()
+ return
+ }
const { filename, folder } = parseAnnotatedFilename(
value as string,
diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts
index 07f9854c92..dd0d2bb1a3 100644
--- a/src/extensions/core/load3d/Load3d.test.ts
+++ b/src/extensions/core/load3d/Load3d.test.ts
@@ -136,7 +136,7 @@ describe('Load3d', () => {
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
- it.each(['translate', 'rotate', 'scale'] as const)(
+ it.for(['translate', 'rotate', 'scale'] as const)(
'setGizmoMode delegates "%s" and forces a render',
(mode: GizmoMode) => {
ctx.load3d.setGizmoMode(mode)
diff --git a/src/extensions/core/load3d/LoaderManager.test.ts b/src/extensions/core/load3d/LoaderManager.test.ts
index ca4b636f9d..79a0f504d1 100644
--- a/src/extensions/core/load3d/LoaderManager.test.ts
+++ b/src/extensions/core/load3d/LoaderManager.test.ts
@@ -241,7 +241,7 @@ describe('LoaderManager', () => {
})
describe('pickAdapter', () => {
- it.each(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
+ it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
'routes %s to the mesh adapter',
(ext) => {
const { pick } = makeLoaderManager()
@@ -249,7 +249,7 @@ describe('LoaderManager', () => {
}
)
- it.each(['spz', 'splat', 'ksplat'])(
+ it.for(['spz', 'splat', 'ksplat'])(
'routes %s to the splat adapter',
(ext) => {
const { pick } = makeLoaderManager()
diff --git a/src/extensions/core/load3d/cameraFromMatrices.test.ts b/src/extensions/core/load3d/cameraFromMatrices.test.ts
index 0b71520b5a..0d6a50047d 100644
--- a/src/extensions/core/load3d/cameraFromMatrices.test.ts
+++ b/src/extensions/core/load3d/cameraFromMatrices.test.ts
@@ -132,13 +132,13 @@ describe('computeCameraFromMatrices', () => {
).toThrow(/intrinsics/)
})
- it.each([
+ it.for<[label: string, fy: number]>([
['zero', 0],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY]
])(
'throws when fy is %s rather than producing a NaN/Infinite FOV',
- (_label, fy) => {
+ ([, fy]) => {
expect(() =>
computeCameraFromMatrices(
extrinsics(IDENTITY_R, [0, 0, 0]),
diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts
index fb9cc0d985..08898e1860 100644
--- a/src/extensions/core/load3d/constants.ts
+++ b/src/extensions/core/load3d/constants.ts
@@ -22,3 +22,5 @@ export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')
+
+export const LOAD3D_NONE_MODEL = 'none'
diff --git a/src/extensions/core/load3d/exportMenuHelper.test.ts b/src/extensions/core/load3d/exportMenuHelper.test.ts
index 8d40e7c4bf..2b0722d2ba 100644
--- a/src/extensions/core/load3d/exportMenuHelper.test.ts
+++ b/src/extensions/core/load3d/exportMenuHelper.test.ts
@@ -98,13 +98,13 @@ describe('createExportMenuItems', () => {
)
})
- it.each([
+ it.for<[label: string, value: string]>([
['GLB', 'glb'],
['OBJ', 'obj'],
['STL', 'stl']
])(
'invokes load3d.exportModel(%s) and shows a success toast when the %s submenu item is clicked',
- async (label, value) => {
+ async ([label, value]) => {
const exportModel = vi.fn().mockResolvedValue(undefined)
const items = createExportMenuItems(makeLoad3d(exportModel))
;(items[1]!.callback as (...args: unknown[]) => void)(
diff --git a/src/extensions/core/load3d/load3dViewport.test.ts b/src/extensions/core/load3d/load3dViewport.test.ts
index 55f2492216..6dc55e622e 100644
--- a/src/extensions/core/load3d/load3dViewport.test.ts
+++ b/src/extensions/core/load3d/load3dViewport.test.ts
@@ -96,13 +96,13 @@ describe('isLoad3dActive', () => {
expect(isLoad3dActive({ ...idle, initialRenderDone: false })).toBe(true)
})
- it.each([
+ it.for([
['mouseOnNode'],
['mouseOnScene'],
['mouseOnViewer'],
['recording'],
['animationPlaying']
- ] as const)('is active when %s is true', (flag) => {
+ ] as const)('is active when %s is true', ([flag]) => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})
diff --git a/src/extensions/core/load3dLazy.test.ts b/src/extensions/core/load3dLazy.test.ts
index 289b38b9e9..b5c721fdec 100644
--- a/src/extensions/core/load3dLazy.test.ts
+++ b/src/extensions/core/load3dLazy.test.ts
@@ -83,7 +83,7 @@ describe('load3dLazy', () => {
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
})
- it.each(['Load3D', 'Preview3D', 'SaveGLB'])(
+ it.for(['Load3D', 'Preview3D', 'SaveGLB'])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {
const { hook } = await loadLazyExtensionFresh()
diff --git a/src/extensions/core/uploadAudio.test.ts b/src/extensions/core/uploadAudio.test.ts
new file mode 100644
index 0000000000..d123ae1ea3
--- /dev/null
+++ b/src/extensions/core/uploadAudio.test.ts
@@ -0,0 +1,241 @@
+import { fromAny } from '@total-typescript/shoehorn'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { ComfyExtension } from '@/types/comfy'
+
+const { mockAddAlert, mockApiURL, mockFetchApi, mockRegisterExtension } =
+ vi.hoisted(() => ({
+ mockAddAlert: vi.fn(),
+ mockApiURL: vi.fn((url: string) => `api:${url}`),
+ mockFetchApi: vi.fn(),
+ mockRegisterExtension: vi.fn()
+ }))
+
+let capturedDragDrop: ((files: File[]) => Promise) | undefined
+let capturedFileSelect:
+ | ((files: File[]) => Promise)
+ | undefined
+let capturedPaste: ((files: File[]) => Promise) | undefined
+
+type AudioUploadWidget = (node: LGraphNode, inputName: string) => unknown
+
+vi.mock('extendable-media-recorder', () => ({
+ MediaRecorder: class MockMediaRecorder {}
+}))
+
+vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
+ useNodeDragAndDrop: (
+ _node: LGraphNode,
+ options: { onDrop: typeof capturedDragDrop }
+ ) => {
+ capturedDragDrop = options.onDrop
+ }
+}))
+
+vi.mock('@/composables/node/useNodeFileInput', () => ({
+ useNodeFileInput: (
+ _node: LGraphNode,
+ options: { onSelect: typeof capturedFileSelect }
+ ) => {
+ capturedFileSelect = options.onSelect
+ return { openFileSelection: vi.fn() }
+ }
+}))
+
+vi.mock('@/composables/node/useNodePaste', () => ({
+ useNodePaste: (
+ _node: LGraphNode,
+ options: { onPaste: typeof capturedPaste }
+ ) => {
+ capturedPaste = options.onPaste
+ }
+}))
+
+vi.mock('@/i18n', () => ({
+ t: (key: string) => key
+}))
+
+vi.mock('@/platform/updates/common/toastStore', () => ({
+ useToastStore: () => ({ addAlert: mockAddAlert })
+}))
+
+vi.mock('@/renderer/extensions/vueNodes/widgets/utils/audioUtils', () => ({
+ getResourceURL: (subfolder = '', filename = '', type = 'input') =>
+ `/view?filename=${filename}&subfolder=${subfolder}&type=${type}`,
+ splitFilePath: (path: string) => ['', path, 'input']
+}))
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ apiURL: mockApiURL,
+ fetchApi: mockFetchApi
+ }
+}))
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ registerExtension: mockRegisterExtension,
+ rootGraph: { id: 'root' }
+ }
+}))
+
+vi.mock('@/stores/widgetValueStore', () => ({
+ useWidgetValueStore: () => ({
+ getWidget: vi.fn()
+ })
+}))
+
+vi.mock('@/utils/graphTraversalUtil', () => ({
+ getNodeByLocatorId: vi.fn()
+}))
+
+vi.mock('@/services/audioService', () => ({
+ useAudioService: () => ({})
+}))
+
+function createFile(name = 'clip.mp3'): File {
+ return new File(['audio'], name, { type: 'audio/mpeg' })
+}
+
+function successResponse(name: string, subfolder?: string) {
+ return {
+ status: 200,
+ json: () => Promise.resolve({ name, subfolder })
+ }
+}
+
+function failResponse(status = 500) {
+ return {
+ status,
+ statusText: 'Server Error'
+ }
+}
+
+function createAudioNode() {
+ const audioWidget = {
+ name: 'audio',
+ value: 'previous.mp3',
+ options: { values: ['previous.mp3'] },
+ callback: vi.fn()
+ }
+ const audioUIWidget = {
+ name: 'audioUI',
+ element: document.createElement('audio'),
+ value: '',
+ callback: vi.fn()
+ }
+ const uploadWidget = { label: '', serialize: true, canvasOnly: false }
+ const node = fromAny({
+ widgets: [audioWidget, audioUIWidget],
+ isUploading: false,
+ graph: { setDirtyCanvas: vi.fn() },
+ addWidget: vi.fn(() => uploadWidget)
+ })
+
+ return { audioUIWidget, audioWidget, node, uploadWidget }
+}
+
+async function loadAudioUploadWidget() {
+ vi.resetModules()
+ mockRegisterExtension.mockClear()
+ await import('./uploadAudio')
+ const extension = mockRegisterExtension.mock.calls
+ .map(([extension]) => extension as ComfyExtension)
+ .find((extension) => extension.name === 'Comfy.UploadAudio')
+ if (!extension)
+ throw new Error('Comfy.UploadAudio extension was not registered')
+ const widgets = await extension.getCustomWidgets!(fromAny({}))
+ return (widgets as Record).AUDIOUPLOAD
+}
+
+describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedDragDrop = undefined
+ capturedFileSelect = undefined
+ capturedPaste = undefined
+ })
+
+ it('sets isUploading while upload is in progress and clears it after success', async () => {
+ const AUDIOUPLOAD = await loadAudioUploadWidget()
+ const { audioWidget, node } = createAudioNode()
+ AUDIOUPLOAD(node, 'upload')
+
+ let resolveUpload: (response: ReturnType) => void
+ mockFetchApi.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveUpload = resolve
+ })
+ )
+
+ const upload = capturedDragDrop!([createFile()])
+
+ expect(node.isUploading).toBe(true)
+ expect(audioWidget.value).toBe('clip.mp3')
+
+ resolveUpload!(successResponse('uploaded.mp3', 'pasted'))
+ await upload
+
+ expect(node.isUploading).toBe(false)
+ expect(audioWidget.value).toBe('pasted/uploaded.mp3')
+ expect(audioWidget.options.values).toContain('pasted/uploaded.mp3')
+ expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
+ })
+
+ it('rejects concurrent audio uploads without starting another request', async () => {
+ const AUDIOUPLOAD = await loadAudioUploadWidget()
+ const { node } = createAudioNode()
+ AUDIOUPLOAD(node, 'upload')
+ node.isUploading = true
+
+ const result = await capturedDragDrop!([createFile()])
+
+ expect(result).toEqual([])
+ expect(mockAddAlert).toHaveBeenCalledWith('g.uploadAlreadyInProgress')
+ expect(mockFetchApi).not.toHaveBeenCalled()
+ })
+
+ it('rolls back the widget value and clears isUploading when upload fails', async () => {
+ const AUDIOUPLOAD = await loadAudioUploadWidget()
+ const { audioWidget, node } = createAudioNode()
+ AUDIOUPLOAD(node, 'upload')
+ mockFetchApi.mockResolvedValueOnce(failResponse())
+
+ await capturedPaste!([createFile()])
+
+ expect(node.isUploading).toBe(false)
+ expect(audioWidget.value).toBe('previous.mp3')
+ expect(mockAddAlert).toHaveBeenCalledWith('500 - Server Error')
+ expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
+ })
+
+ it('rolls back the widget value and clears isUploading when upload throws synchronously', async () => {
+ const AUDIOUPLOAD = await loadAudioUploadWidget()
+ const { audioWidget, node } = createAudioNode()
+ AUDIOUPLOAD(node, 'upload')
+ const error = new Error('Upload failed before request promise')
+ mockFetchApi.mockImplementationOnce(() => {
+ throw error
+ })
+
+ await capturedDragDrop!([createFile()])
+
+ expect(node.isUploading).toBe(false)
+ expect(audioWidget.value).toBe('previous.mp3')
+ expect(mockAddAlert).toHaveBeenCalledWith(error)
+ expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true)
+ })
+
+ it('returns early when no files are provided', async () => {
+ const AUDIOUPLOAD = await loadAudioUploadWidget()
+ const { node } = createAudioNode()
+ AUDIOUPLOAD(node, 'upload')
+
+ const result = await capturedFileSelect!([])
+
+ expect(result).toEqual([])
+ expect(node.isUploading).toBe(false)
+ expect(mockFetchApi).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts
index 8b6f65ab26..34229d8c60 100644
--- a/src/extensions/core/uploadAudio.ts
+++ b/src/extensions/core/uploadAudio.ts
@@ -234,9 +234,17 @@ app.registerExtension({
}
const handleUpload = async (files: File[]) => {
- if (files?.length) {
- const previousValue = audioWidget.value
- audioWidget.value = files[0].name
+ if (!files?.length) return files
+
+ if (node.isUploading) {
+ useToastStore().addAlert(t('g.uploadAlreadyInProgress'))
+ return []
+ }
+
+ node.isUploading = true
+ const previousValue = audioWidget.value
+ audioWidget.value = files[0].name
+ try {
const success = await uploadFile(
audioWidget,
audioUIWidget,
@@ -246,6 +254,9 @@ app.registerExtension({
if (!success) {
audioWidget.value = previousValue
}
+ } finally {
+ node.isUploading = false
+ node.graph?.setDirtyCanvas(true)
}
return files
}
diff --git a/src/i18n.test.ts b/src/i18n.test.ts
index 1cb2b673b0..282a1ed936 100644
--- a/src/i18n.test.ts
+++ b/src/i18n.test.ts
@@ -1,5 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
+
+import type * as I18nModule from './i18n'
+
+let i18n: typeof I18nModule.i18n
+let loadLocale: typeof I18nModule.loadLocale
+let mergeCustomNodesI18n: typeof I18nModule.mergeCustomNodesI18n
+let resolveSupportedLocale: typeof I18nModule.resolveSupportedLocale
+let setActiveLocale: typeof I18nModule.setActiveLocale
+
+async function importI18nModule() {
+ const i18nModule = await import('./i18n')
+ i18n = i18nModule.i18n
+ loadLocale = i18nModule.loadLocale
+ mergeCustomNodesI18n = i18nModule.mergeCustomNodesI18n
+ resolveSupportedLocale = i18nModule.resolveSupportedLocale
+ setActiveLocale = i18nModule.setActiveLocale
+}
// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
@@ -24,6 +40,7 @@ vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
describe('i18n', () => {
beforeEach(async () => {
vi.resetModules()
+ await importI18nModule()
})
describe('mergeCustomNodesI18n', () => {
@@ -46,8 +63,6 @@ describe('i18n', () => {
})
it('should store data for not-yet-loaded locales', async () => {
- const { i18n, mergeCustomNodesI18n } = await import('./i18n')
-
// Chinese is not pre-loaded, data should be stored but not merged yet
mergeCustomNodesI18n({
zh: {
@@ -148,7 +163,7 @@ describe('i18n', () => {
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
// Use fresh module instance to ensure clean state
vi.resetModules()
- const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
+ await importI18nModule()
mergeCustomNodesI18n({
zh: { plugin1: { name: '插件1' } }
@@ -175,26 +190,88 @@ describe('i18n', () => {
it('should not reload already loaded locale', async () => {
await loadLocale('zh')
await loadLocale('zh')
-
- // Should complete without error (second call returns early)
})
- it('should warn for unsupported locale', async () => {
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
-
- await loadLocale('unsupported-locale')
-
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Locale "unsupported-locale" is not supported'
+ it('should load shipped BCP-47 variants', async () => {
+ await loadLocale('zh-TW')
+ expect(i18n.global.getLocaleMessage('zh-TW')).toEqual(
+ expect.objectContaining({
+ commands: expect.any(Object),
+ nodeDefs: expect.any(Object),
+ settings: expect.any(Object)
+ })
)
- consoleSpy.mockRestore()
})
it('should handle concurrent load requests for same locale', async () => {
- // Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
-
await Promise.all(promises)
})
})
+
+ describe('setActiveLocale', () => {
+ it('clamps unsupported input to en', async () => {
+ expect(await setActiveLocale('de')).toBe('en')
+ expect(i18n.global.locale.value).toBe('en')
+ })
+
+ it('resolves shipped variants and sets the active locale', async () => {
+ expect(await setActiveLocale('pt-BR')).toBe('pt-BR')
+ expect(i18n.global.locale.value).toBe('pt-BR')
+ // pt is not shipped — pt-BR must not be promoted as a base match
+ expect(await setActiveLocale('pt')).toBe('en')
+ })
+
+ it('honors prioritized navigator.languages', async () => {
+ // First preference unsupported, second shipped — should land on French.
+ expect(await setActiveLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
+ })
+ })
+
+ describe('resolveSupportedLocale', () => {
+ it('returns the canonical tag when the input is shipped', () => {
+ expect(resolveSupportedLocale('en')).toBe('en')
+ expect(resolveSupportedLocale('ja')).toBe('ja')
+ expect(resolveSupportedLocale('zh-TW')).toBe('zh-TW')
+ expect(resolveSupportedLocale('pt-BR')).toBe('pt-BR')
+ })
+
+ it('matches case-insensitively per BCP-47 and returns canonical casing', () => {
+ // Older browsers / OS configs may emit lowercase region tags.
+ expect(resolveSupportedLocale('pt-br')).toBe('pt-BR')
+ expect(resolveSupportedLocale('PT-BR')).toBe('pt-BR')
+ expect(resolveSupportedLocale('zh-tw')).toBe('zh-TW')
+ expect(resolveSupportedLocale('ZH-TW')).toBe('zh-TW')
+ expect(resolveSupportedLocale('EN')).toBe('en')
+ })
+
+ it('falls back to the base tag when the full tag is unshipped', () => {
+ // de-DE → de (unshipped) → en
+ expect(resolveSupportedLocale('de-DE')).toBe('en')
+ // fr-CA → fr (shipped) → fr
+ expect(resolveSupportedLocale('fr-CA')).toBe('fr')
+ // ko-KR → ko (shipped) → ko
+ expect(resolveSupportedLocale('ko-KR')).toBe('ko')
+ // zh-CN → zh (shipped) → zh (Simplified is the base)
+ expect(resolveSupportedLocale('zh-CN')).toBe('zh')
+ })
+
+ it('falls back to en for unsupported and missing inputs', () => {
+ expect(resolveSupportedLocale('de')).toBe('en')
+ expect(resolveSupportedLocale('it')).toBe('en')
+ expect(resolveSupportedLocale('nl')).toBe('en')
+ expect(resolveSupportedLocale('xx-YY')).toBe('en')
+ expect(resolveSupportedLocale('')).toBe('en')
+ expect(resolveSupportedLocale(undefined)).toBe('en')
+ expect(resolveSupportedLocale(null)).toBe('en')
+ })
+
+ it('walks a prioritized array per RFC 4647 lookup order', () => {
+ // First shipped match wins (de unshipped → fr shipped → fr).
+ expect(resolveSupportedLocale(['de-DE', 'fr-CA', 'en'])).toBe('fr')
+ // Empty / all-unshipped arrays fall back to en.
+ expect(resolveSupportedLocale([])).toBe('en')
+ expect(resolveSupportedLocale(['de', 'it'])).toBe('en')
+ })
+ })
})
diff --git a/src/i18n.ts b/src/i18n.ts
index 13f25b0ba7..c3fc651820 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -1,7 +1,11 @@
import { createI18n } from 'vue-i18n'
-// ESLint cannot statically resolve dynamic imports with relative paths in template strings,
-// but these are valid ES module imports that Vite processes correctly at build time.
+import {
+ getDefaultLocale,
+ localeDefinitions,
+ resolveSupportedLocale
+} from '@/locales/localeConfig'
+import type { SupportedLocale } from '@/locales/localeConfig'
// Import only English locale eagerly as the default/fallback
import enCommands from './locales/en/commands.json' with { type: 'json' }
@@ -9,6 +13,8 @@ import en from './locales/en/main.json' with { type: 'json' }
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from './locales/en/settings.json' with { type: 'json' }
+export { resolveSupportedLocale }
+
function buildLocale<
M extends Record,
N extends Record,
@@ -23,75 +29,6 @@ function buildLocale<
} as M & { nodeDefs: N; commands: C; settings: S }
}
-// Locale loader map - dynamically import locales only when needed
-const localeLoaders: Record<
- string,
- () => Promise<{ default: Record }>
-> = {
- ar: () => import('./locales/ar/main.json'),
- es: () => import('./locales/es/main.json'),
- fa: () => import('./locales/fa/main.json'),
- fr: () => import('./locales/fr/main.json'),
- ja: () => import('./locales/ja/main.json'),
- ko: () => import('./locales/ko/main.json'),
- ru: () => import('./locales/ru/main.json'),
- tr: () => import('./locales/tr/main.json'),
- zh: () => import('./locales/zh/main.json'),
- 'zh-TW': () => import('./locales/zh-TW/main.json'),
- 'pt-BR': () => import('./locales/pt-BR/main.json')
-}
-
-const nodeDefsLoaders: Record<
- string,
- () => Promise<{ default: Record }>
-> = {
- ar: () => import('./locales/ar/nodeDefs.json'),
- es: () => import('./locales/es/nodeDefs.json'),
- fa: () => import('./locales/fa/nodeDefs.json'),
- fr: () => import('./locales/fr/nodeDefs.json'),
- ja: () => import('./locales/ja/nodeDefs.json'),
- ko: () => import('./locales/ko/nodeDefs.json'),
- ru: () => import('./locales/ru/nodeDefs.json'),
- tr: () => import('./locales/tr/nodeDefs.json'),
- zh: () => import('./locales/zh/nodeDefs.json'),
- 'zh-TW': () => import('./locales/zh-TW/nodeDefs.json'),
- 'pt-BR': () => import('./locales/pt-BR/nodeDefs.json')
-}
-
-const commandsLoaders: Record<
- string,
- () => Promise<{ default: Record }>
-> = {
- ar: () => import('./locales/ar/commands.json'),
- es: () => import('./locales/es/commands.json'),
- fa: () => import('./locales/fa/commands.json'),
- fr: () => import('./locales/fr/commands.json'),
- ja: () => import('./locales/ja/commands.json'),
- ko: () => import('./locales/ko/commands.json'),
- ru: () => import('./locales/ru/commands.json'),
- tr: () => import('./locales/tr/commands.json'),
- zh: () => import('./locales/zh/commands.json'),
- 'zh-TW': () => import('./locales/zh-TW/commands.json'),
- 'pt-BR': () => import('./locales/pt-BR/commands.json')
-}
-
-const settingsLoaders: Record<
- string,
- () => Promise<{ default: Record }>
-> = {
- ar: () => import('./locales/ar/settings.json'),
- es: () => import('./locales/es/settings.json'),
- fa: () => import('./locales/fa/settings.json'),
- fr: () => import('./locales/fr/settings.json'),
- ja: () => import('./locales/ja/settings.json'),
- ko: () => import('./locales/ko/settings.json'),
- ru: () => import('./locales/ru/settings.json'),
- tr: () => import('./locales/tr/settings.json'),
- zh: () => import('./locales/zh/settings.json'),
- 'zh-TW': () => import('./locales/zh-TW/settings.json'),
- 'pt-BR': () => import('./locales/pt-BR/settings.json')
-}
-
// Track which locales have been loaded
const loadedLocales = new Set(['en'])
@@ -102,37 +39,33 @@ const loadingLocales = new Map>()
const customNodesI18nData: Record = {}
/**
- * Dynamically load a locale and its associated files (nodeDefs, commands, settings)
+ * Dynamically load a shipped locale's bundles (nodeDefs, commands, settings).
+ * Callers must pre-resolve untrusted input via `resolveSupportedLocale` or
+ * `setActiveLocale`, which is the boundary helper for arbitrary input.
*/
-export async function loadLocale(locale: string): Promise {
+export async function loadLocale(locale: SupportedLocale): Promise {
if (loadedLocales.has(locale)) {
return
}
- // If already loading, return the existing promise to prevent duplicate loads
const existingLoad = loadingLocales.get(locale)
if (existingLoad) {
- return existingLoad
- }
-
- const loader = localeLoaders[locale]
- const nodeDefsLoader = nodeDefsLoaders[locale]
- const commandsLoader = commandsLoaders[locale]
- const settingsLoader = settingsLoaders[locale]
-
- if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
- console.warn(`Locale "${locale}" is not supported`)
+ await existingLoad
+ return
+ }
+
+ const loaders = localeDefinitions[locale].loaders
+ if (!loaders) {
return
}
- // Create and track the loading promise
const loadPromise = (async () => {
try {
const [main, nodes, commands, settings] = await Promise.all([
- loader(),
- nodeDefsLoader(),
- commandsLoader(),
- settingsLoader()
+ loaders.main(),
+ loaders.nodeDefs(),
+ loaders.commands(),
+ loaders.settings()
])
const messages = buildLocale(
@@ -152,13 +85,33 @@ export async function loadLocale(locale: string): Promise {
console.error(`Failed to load locale "${locale}":`, error)
throw error
} finally {
- // Clean up the loading promise once complete
loadingLocales.delete(locale)
}
})()
loadingLocales.set(locale, loadPromise)
- return loadPromise
+ await loadPromise
+}
+
+/**
+ * Boundary helper for arbitrary locale input (settings, browser preferences):
+ * resolves to a shipped tag, loads it, and updates the active locale.
+ *
+ * Returns the resolved tag so callers can detect a clamp (e.g. a stale stored
+ * `Comfy.Locale` from an older build) and self-heal persisted state.
+ */
+export async function setActiveLocale(
+ input: string | readonly string[] | null | undefined
+): Promise {
+ const resolved = resolveSupportedLocale(input)
+ if (typeof input === 'string' && input && input !== resolved) {
+ // Single warn — gated on a real clamp event, never per missing key — so
+ // stale stored locales surface in logs without re-introducing #1867's spam.
+ console.warn(`Locale "${input}" not shipped; using "${resolved}"`)
+ }
+ await loadLocale(resolved)
+ i18n.global.locale.value = resolved
+ return resolved
}
/**
@@ -179,18 +132,18 @@ export function mergeCustomNodesI18n(i18nData: Record): void {
}
}
-// Only include English in the initial bundle
-const messages = {
- en: buildLocale(en, enNodes, enCommands, enSettings)
-}
+// Only include English in the initial bundle; other locales lazy-load.
+const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
+type LocaleMessages = typeof enMessages
-// Type for locale messages - inferred from the English locale structure
-type LocaleMessages = typeof messages.en
+const messages: Partial> = {
+ en: enMessages
+}
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
- locale: navigator.language.split('-')[0] || 'en',
+ locale: getDefaultLocale(),
fallbackLocale: 'en',
escapeParameter: true,
messages,
diff --git a/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts b/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
new file mode 100644
index 0000000000..f774267491
--- /dev/null
+++ b/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
@@ -0,0 +1,173 @@
+import { fromPartial } from '@total-typescript/shoehorn'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
+import {
+ LGraph,
+ LGraphCanvas,
+ LGraphNode,
+ LiteGraph
+} from '@/lib/litegraph/src/litegraph'
+
+class TestNode extends LGraphNode {
+ static override type = 'TestNode'
+
+ constructor(title?: string) {
+ super(title ?? 'TestNode')
+ }
+}
+
+function makeNodeClass(title: string) {
+ class N extends TestNode {
+ static override title = title
+
+ constructor() {
+ super(title)
+ }
+ }
+ return N
+}
+
+function createCanvas(graph: LGraph): LGraphCanvas {
+ const el = document.createElement('canvas')
+ el.width = 800
+ el.height = 600
+ const ctx = fromPartial({
+ measureText: vi.fn().mockReturnValue({ width: 50 }),
+ getTransform: vi
+ .fn()
+ .mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
+ })
+
+ el.getContext = vi.fn().mockReturnValue(ctx)
+ el.getBoundingClientRect = vi.fn().mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 600
+ })
+
+ return new LGraphCanvas(el, graph, { skip_render: true })
+}
+
+type MenuEntry = IContextMenuValue
+
+describe('LGraphCanvas.onMenuAdd category sorting', () => {
+ let graph: LGraph
+ let canvas: LGraphCanvas
+ const registeredTypes: string[] = []
+ let originalContextMenu: typeof LiteGraph.ContextMenu
+ const capturedEntries: MenuEntry[][] = []
+
+ beforeEach(() => {
+ graph = new LGraph()
+ canvas = createCanvas(graph)
+ LGraphCanvas.active_canvas = canvas
+
+ capturedEntries.length = 0
+ originalContextMenu = LiteGraph.ContextMenu
+ const MockContextMenu = vi.fn(function (
+ this: unknown,
+ values: MenuEntry[]
+ ) {
+ capturedEntries.push(values)
+ }) as unknown as typeof LiteGraph.ContextMenu
+ LiteGraph.ContextMenu = MockContextMenu
+ })
+
+ afterEach(() => {
+ LiteGraph.ContextMenu = originalContextMenu
+ for (const type of registeredTypes) {
+ delete LiteGraph.registered_node_types[type]
+ }
+ registeredTypes.length = 0
+ })
+
+ function register(type: string, title: string) {
+ LiteGraph.registerNodeType(type, makeNodeClass(title))
+ registeredTypes.push(type)
+ }
+
+ function openTopLevelMenu() {
+ const event = new MouseEvent('contextmenu', { clientX: 10, clientY: 10 })
+ LGraphCanvas.onMenuAdd(undefined, undefined, event)
+ return event
+ }
+
+ function drillInto(label: string, sourceEvent: MouseEvent) {
+ const top = capturedEntries[capturedEntries.length - 1]
+ const entry = top.find((e) => e.content === label)
+ expect(entry, `submenu entry "${label}" should exist`).toBeDefined()
+ expect(entry!.callback).toBeDefined()
+ expect(typeof entry!.value).toBe('string')
+ const callback = entry!.callback!
+ const menuThis = document.createElement('div') as ThisParameterType<
+ typeof callback
+ >
+ void callback.call(menuThis, entry, undefined, sourceEvent, undefined)
+ }
+
+ it('sorts top-level category submenus alphabetically (case-insensitive)', () => {
+ register('zebra/zNode', 'Zebra Node')
+ register('Apple/aNode', 'Apple Node')
+ register('middle/mNode', 'Middle Node')
+
+ openTopLevelMenu()
+
+ const submenuLabels = capturedEntries[0]
+ .filter((e) => e.has_submenu)
+ .map((e) => e.content)
+ const ours = submenuLabels.filter((label) =>
+ ['Apple', 'middle', 'zebra'].includes(label ?? '')
+ )
+ expect(ours).toEqual(['Apple', 'middle', 'zebra'])
+ })
+
+ it('uses natural numeric ordering for numbered category names', () => {
+ register('Cat10/n10', 'Item10')
+ register('Cat2/n2', 'Item2')
+ register('Cat1/n1', 'Item1')
+
+ openTopLevelMenu()
+
+ const ours = capturedEntries[0]
+ .filter(
+ (e) =>
+ e.has_submenu && ['Cat1', 'Cat2', 'Cat10'].includes(e.content ?? '')
+ )
+ .map((e) => e.content)
+ expect(ours).toEqual(['Cat1', 'Cat2', 'Cat10'])
+ })
+
+ it('sorts leaf nodes inside a category alphabetically', () => {
+ register('leafsort/Zeta', 'Zeta')
+ register('leafsort/Alpha', 'Alpha')
+ register('leafsort/Mike', 'Mike')
+
+ const event = openTopLevelMenu()
+ drillInto('leafsort', event)
+
+ const leafLabels = capturedEntries[1]
+ .filter((e) => !e.has_submenu)
+ .map((e) => e.content)
+ expect(leafLabels).toEqual(['Alpha', 'Mike', 'Zeta'])
+ })
+
+ it('places category submenus before leaf entries within a category level', () => {
+ register('mixed/leafA', 'A Leaf')
+ register('mixed/leafZ', 'Z Leaf')
+ register('mixed/inner/deep', 'Deep')
+
+ const event = openTopLevelMenu()
+ drillInto('mixed', event)
+
+ const inside = capturedEntries[1]
+ const ours = inside.filter((e) =>
+ ['inner', 'A Leaf', 'Z Leaf'].includes(e.content ?? '')
+ )
+ expect(ours[0].content).toBe('inner')
+ expect(ours[0].has_submenu).toBe(true)
+ expect(ours[1].content).toBe('A Leaf')
+ expect(ours[2].content).toBe('Z Leaf')
+ })
+})
diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts
index 27ed614d74..2de0d096a9 100644
--- a/src/lib/litegraph/src/LGraphCanvas.ts
+++ b/src/lib/litegraph/src/LGraphCanvas.ts
@@ -1179,7 +1179,7 @@ export class LGraphCanvas implements CustomEventDispatcher
const categories = LiteGraph.getNodeTypesCategories(
canvas.filter || graph.filter
).filter((category) => category.startsWith(base_category))
- const entries: AddNodeMenu[] = []
+ const categoryEntries: AddNodeMenu[] = []
for (const category of categories) {
if (!category) continue
@@ -1197,11 +1197,11 @@ export class LGraphCanvas implements CustomEventDispatcher
// in case it has a namespace like "shader::math/rand" it hides the namespace
if (name.includes('::')) name = name.split('::', 2)[1]
- const index = entries.findIndex(
+ const index = categoryEntries.findIndex(
(entry) => entry.value === category_path
)
if (index === -1) {
- entries.push({
+ categoryEntries.push({
value: category_path,
content: name,
has_submenu: true,
@@ -1212,11 +1212,19 @@ export class LGraphCanvas implements CustomEventDispatcher
}
}
+ const compareByContent = (a: AddNodeMenu, b: AddNodeMenu) =>
+ (a.content ?? '').localeCompare(b.content ?? '', undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ })
+ categoryEntries.sort(compareByContent)
+
const nodes = LiteGraph.getNodeTypesInCategory(
base_category.slice(0, -1),
canvas.filter || graph.filter
)
+ const nodeEntries: AddNodeMenu[] = []
for (const node of nodes) {
if (node.skip_list) continue
@@ -1246,9 +1254,13 @@ export class LGraphCanvas implements CustomEventDispatcher
}
}
- entries.push(entry)
+ nodeEntries.push(entry)
}
+ nodeEntries.sort(compareByContent)
+
+ const entries: AddNodeMenu[] = [...categoryEntries, ...nodeEntries]
+
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
}
}
diff --git a/src/lib/litegraph/src/infrastructure/Rectangle.test.ts b/src/lib/litegraph/src/infrastructure/Rectangle.test.ts
index cb4245e265..786bdb142f 100644
--- a/src/lib/litegraph/src/infrastructure/Rectangle.test.ts
+++ b/src/lib/litegraph/src/infrastructure/Rectangle.test.ts
@@ -301,7 +301,7 @@ describe('Rectangle', () => {
describe('containment and overlap', () => {
const rect = new Rectangle(10, 10, 20, 20) // x: 10, y: 10, right: 30, bottom: 30
- test.each([
+ test.for([
[10, 10, true], // top-left corner
[29, 29, true], // bottom-right corner
[15, 15, true], // inside
@@ -311,14 +311,14 @@ describe('Rectangle', () => {
[15, 30, false], // outside bottom
[10, 29, true], // on bottom edge
[29, 10, true] // on right edge
- ])(
+ ] as const)(
'when checking if (%s, %s) is inside, should return %s',
- (x, y, expected) => {
+ ([x, y, expected]) => {
expect(rect.containsXy(x, y)).toBe(expected)
}
)
- test.each([
+ test.for([
[[0, 0] as Point, true],
[[9, 9] as Point, true],
[[5, 5] as Point, true],
@@ -326,12 +326,15 @@ describe('Rectangle', () => {
[[11, 5] as Point, false],
[[5, -1] as Point, false],
[[5, 11] as Point, false]
- ])('should return %s for point %j', (point: Point, expected: boolean) => {
- rect.updateTo([0, 0, 10, 10])
- expect(rect.containsPoint(point)).toBe(expected)
- })
+ ] as const)(
+ 'should return %s for point %j',
+ ([point, expected]: readonly [Point, boolean]) => {
+ rect.updateTo([0, 0, 10, 10])
+ expect(rect.containsPoint(point)).toBe(expected)
+ }
+ )
- test.each([
+ test.for([
// Completely inside
[new Rectangle(10, 10, 10, 10), true],
// Touching edges
@@ -348,13 +351,13 @@ describe('Rectangle', () => {
[new Rectangle(0, 0, 5, 5), new Rectangle(0, 0, 10, 10), true],
// Same size
[new Rectangle(0, 0, 99, 99), true]
- ])(
+ ] as const)(
'should return %s when checking if %s is inside outer rect',
- (
- inner: Rectangle,
- expectedOrOuter: boolean | Rectangle,
- expectedIfThreeArgs?: boolean
- ) => {
+ ([inner, expectedOrOuter, expectedIfThreeArgs]: readonly [
+ Rectangle,
+ boolean | Rectangle,
+ boolean?
+ ]) => {
let testOuter = rect
rect.updateTo([0, 0, 100, 100])
@@ -367,7 +370,7 @@ describe('Rectangle', () => {
}
)
- test.each([
+ test.for([
// Completely overlapping
[new Rectangle(15, 15, 10, 10), true], // r2 inside r1
// Partially overlapping
@@ -387,7 +390,7 @@ describe('Rectangle', () => {
[new Rectangle(0, 0, 5, 5), false], // r2 outside top-left
// rect1 inside rect2
[new Rectangle(0, 0, 100, 100), true]
- ])('should return %s for overlap with %s', (rect2, expected) => {
+ ] as const)('should return %s for overlap with %s', ([rect2, expected]) => {
const rect = new Rectangle(10, 10, 20, 20) // 10,10 to 30,30
expect(rect.overlaps(rect2)).toBe(expected)
diff --git a/src/lib/litegraph/src/utils/mathParser.test.ts b/src/lib/litegraph/src/utils/mathParser.test.ts
index 377c71d85c..09a7b985e7 100644
--- a/src/lib/litegraph/src/utils/mathParser.test.ts
+++ b/src/lib/litegraph/src/utils/mathParser.test.ts
@@ -3,52 +3,52 @@ import { describe, expect, test } from 'vitest'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
describe('evaluateMathExpression', () => {
- test.each([
+ test.for<[string, number]>([
['2+3', 5],
['10-4', 6],
['3*7', 21],
['15/3', 5]
- ])('basic arithmetic: %s = %d', (input, expected) => {
+ ])('basic arithmetic: %s = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['2+3*4', 14],
['(2+3)*4', 20],
['10-2*3', 4],
['10/2+3', 8]
- ])('operator precedence: %s = %d', (input, expected) => {
+ ])('operator precedence: %s = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['3.14*2', 6.28],
['.5+.5', 1],
['1.5+2.5', 4],
['0.1+0.2', 0.1 + 0.2],
['123.', 123],
['123.+3', 126]
- ])('decimals: %s', (input, expected) => {
+ ])('decimals: %s', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
[' 2 + 3 ', 5],
[' 10 - 4 ', 6],
[' ( 2 + 3 ) * 4 ', 20]
- ])('whitespace handling: "%s" = %d', (input, expected) => {
+ ])('whitespace handling: "%s" = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['((2+3))', 5],
['(1+(2*(3+4)))', 15],
['((1+2)*(3+4))', 21]
- ])('nested parentheses: %s = %d', (input, expected) => {
+ ])('nested parentheses: %s = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['-5', -5],
['-(3+2)', -5],
['--5', 5],
@@ -59,11 +59,11 @@ describe('evaluateMathExpression', () => {
['2--3', 5],
['-2*-3', 6],
['-(2+3)*-(4+5)', 45]
- ])('unary operators: %s = %d', (input, expected) => {
+ ])('unary operators: %s = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['2 /2+3 * 4.75- -6', 21.25],
['2 / (2 + 3) * 4.33 - -6', 7.732],
['12* 123/-(-5 + 2)', 492],
@@ -78,11 +78,11 @@ describe('evaluateMathExpression', () => {
'(123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) - (123.45*(678.90 / (-2.5+ 11.5)-(((80 -(19))) *33.25)) / 20) + (13 - 2)/ -(-11) ',
1
]
- ])('complex expression: %s', (input, expected) => {
+ ])('complex expression: %s', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBeCloseTo(expected as number)
})
- test.each(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3', '.', '123..'])(
+ test.for(['', 'abc', '2+', '(2+3', '2+3)', '()', '*3', '2 3', '.', '123..'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
@@ -97,11 +97,11 @@ describe('evaluateMathExpression', () => {
expect(evaluateMathExpression('0/0')).toBeNaN()
})
- test.each([
+ test.for<[string, number]>([
['10%3', 1],
['10%3+1', 2],
['7%2', 1]
- ])('modulo: %s = %d', (input, expected) => {
+ ])('modulo: %s = %d', ([input, expected]) => {
expect(evaluateMathExpression(input)).toBe(expected)
})
diff --git a/src/lib/litegraph/src/utils/widget.test.ts b/src/lib/litegraph/src/utils/widget.test.ts
index 6eb9707dd2..48543c7897 100644
--- a/src/lib/litegraph/src/utils/widget.test.ts
+++ b/src/lib/litegraph/src/utils/widget.test.ts
@@ -73,21 +73,21 @@ describe('resolveNodeRootGraphId', () => {
})
describe('evaluateInput', () => {
- test.each([
+ test.for<[string, number]>([
['42', 42],
['3.14', 3.14],
['-7', -7],
['0', 0]
- ])('plain number: "%s" = %d', (input, expected) => {
+ ])('plain number: "%s" = %d', ([input, expected]) => {
expect(evaluateInput(input)).toBe(expected)
})
- test.each([
+ test.for<[string, number]>([
['2+3', 5],
['(4+2)*3', 18],
['3.14*2', 6.28],
['10/2+3', 8]
- ])('expression: "%s" = %d', (input, expected) => {
+ ])('expression: "%s" = %d', ([input, expected]) => {
expect(evaluateInput(input)).toBe(expected)
})
@@ -95,7 +95,7 @@ describe('evaluateInput', () => {
expect(evaluateInput('')).toBe(0)
})
- test.each(['abc', 'hello world'])(
+ test.for(['abc', 'hello world'])(
'invalid input returns undefined: "%s"',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
@@ -118,7 +118,7 @@ describe('evaluateInput', () => {
expect(evaluateInput('0xff')).toBe(255)
})
- test.each(['Infinity', '-Infinity'])(
+ test.for(['Infinity', '-Infinity'])(
'"%s" returns undefined (non-finite rejected)',
(input) => {
expect(evaluateInput(input)).toBeUndefined()
diff --git a/src/locales/CONTRIBUTING.md b/src/locales/CONTRIBUTING.md
index 530f7dc9de..56fd4c1c59 100644
--- a/src/locales/CONTRIBUTING.md
+++ b/src/locales/CONTRIBUTING.md
@@ -35,47 +35,13 @@ module.exports = defineConfig({
})
```
-#### 1.2 Update `src/platform/settings/constants/coreSettings.ts`
+#### 1.2 Update `src/locales/localeConfig.ts`
-Add your language to the dropdown options:
+Add your language to the shared runtime locale definition. This feeds the
+settings dropdown, supported-locale resolution, and lazy locale loading:
```typescript
-{
- id: 'Comfy.Locale',
- name: 'Language',
- type: 'combo',
- options: [
- { value: 'en', text: 'English' },
- { value: 'zh', text: '中文' },
- { value: 'zh-TW', text: '繁體中文 (台灣)' }, // Add your language here
- { value: 'ru', text: 'Русский' },
- { value: 'ja', text: '日本語' },
- { value: 'ko', text: '한국어' },
- { value: 'fr', text: 'Français' },
- { value: 'es', text: 'Español' }
- ],
- defaultValue: () => navigator.language.split('-')[0] || 'en'
-},
-```
-
-#### 1.3 Update `src/i18n.ts`
-
-Add imports for your new language files:
-
-```typescript
-// Add these imports (replace zh-TW with your language code)
-import zhTWCommands from './locales/zh-TW/commands.json'
-import zhTW from './locales/zh-TW/main.json'
-import zhTWNodes from './locales/zh-TW/nodeDefs.json'
-import zhTWSettings from './locales/zh-TW/settings.json'
-
-// Add to the messages object
-const messages = {
- en: buildLocale(en, enNodes, enCommands, enSettings),
- zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
- 'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings) // Add this line
- // ... other languages
-}
+'zh-TW': { text: '繁體中文', loaders: loadersFor('zh-TW') }
```
### Step 2: Generate Translation Files
@@ -168,7 +134,7 @@ Each language has 4 translation files:
### Issue: Language not appearing in dropdown
-**Solution**: Check that the language code in `coreSettings.ts` matches your other files exactly
+**Solution**: Check that the language code in `src/locales/localeConfig.ts` matches your other files exactly
### Issue: Rate limits during local translation
diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json
index bd8ededb22..948f2818ba 100644
--- a/src/locales/ar/main.json
+++ b/src/locales/ar/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "مُشَفِّر الصوت",
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
"AUDIO_RECORD": "تسجيل صوتي",
+ "BACKGROUND_REMOVAL": "إزالة الخلفية",
"BOOLEAN": "منطقي",
"BOUNDING_BOX": "مربع التحديد",
"CAMERA_CONTROL": "تحكم الكاميرا",
@@ -2284,15 +2285,13 @@
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_للاختبار",
"advanced": "متقدم",
"animation": "الرسوم المتحركة",
- "api": "API",
"api node": "عقدة API",
"attention_experiments": "تجارب الانتباه",
"audio": "صوت",
+ "background removal": "إزالة الخلفية",
"batch": "دفعة",
- "camera": "كاميرا",
"chroma_radiance": "تألق اللون",
"clip": "clip",
"color": "لون",
@@ -2301,7 +2300,6 @@
"cond pair": "زوج شرطي",
"cond single": "شرط فردي",
"conditioning": "التكييف",
- "context": "سياق",
"controlnet": "كونترول نت",
"create": "إنشاء",
"custom_sampling": "تجميع مخصص",
@@ -2310,6 +2308,7 @@
"deprecated": "مهمل",
"detection": "الكشف",
"edit_models": "تحرير النماذج",
+ "experimental": "تجريبي",
"flux": "تدفق",
"gligen": "gligen",
"guidance": "التوجيه",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "قناع",
- "math": "رياضيات",
"model": "نموذج",
"model_merging": "دمج النماذج",
"model_patches": "تصحيحات النموذج",
@@ -2342,7 +2340,6 @@
"save": "حفظ",
"schedulers": "الجدولة",
"scheduling": "الجدولة",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سيجمات",
@@ -2350,7 +2347,6 @@
"style_model": "نموذج النمط",
"supir": "supir",
"text": "نص",
- "textgen": "textgen",
"training": "تدريب",
"transform": "تحويل",
"unet": "unet",
diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json
index 1f9980faf4..5b0eb2dfe6 100644
--- a/src/locales/ar/nodeDefs.json
+++ b/src/locales/ar/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "حجم الدفعة"
+ },
+ "height": {
+ "name": "الارتفاع"
+ },
+ "length": {
+ "name": "الطول"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "الصورة_البدء"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "العرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "إضافة ضجيج",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "توليد موحد من النص إلى الصورة وتحرير دقيق لجملة واحدة بدقة تصل إلى 4K.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "التحكم بعد التوليد"
+ },
+ "model": {
+ "name": "النموذج"
+ },
+ "model_fail_on_partial": {
+ "name": "فشل عند التوليد الجزئي"
+ },
+ "model_height": {
+ "name": "الارتفاع"
+ },
+ "model_max_images": {
+ "name": "أقصى عدد للصور"
+ },
+ "model_size_preset": {
+ "name": "إعداد الحجم"
+ },
+ "model_width": {
+ "name": "العرض"
+ },
+ "prompt": {
+ "name": "الموجه",
+ "tooltip": "موجه نصي لإنشاء أو تعديل صورة."
+ },
+ "seed": {
+ "name": "البذرة",
+ "tooltip": "البذرة المستخدمة للتوليد."
+ },
+ "watermark": {
+ "name": "علامة مائية",
+ "tooltip": "هل تريد إضافة علامة \"تم الإنشاء بواسطة الذكاء الاصطناعي\" على الصورة."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "توليد فيديو باستخدام نماذج ByteDance عبر API استنادًا إلى النص الموجه",
"display_name": "ByteDance نص إلى فيديو",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "قيمة منطقية",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "فضاء كامِن بكسل فارغ لـ HiDream-O1-Image. تم تدريب النموذج على دقة تقارب ٤ ميغابكسل؛ الدقات الأقل تخرج عن التوزيع وتنخفض الجودة بشكل ملحوظ. الدقات المدعومة: ٢٠٤٨×٢٠٤٨، ٢٣٠٤×١٧٢٨، ١٧٢٨×٢٣٠٤، ٢٥٦٠×١٤٤٠، ١٤٤٠×٢٥٦٠، ٢٤٩٦×١٦٦٤، ١٦٦٤×٢٤٩٦، ٣١٠٤×١٣١٢، ١٣١٢×٣١٠٤، ٢٣٠٤×١٧٩٢، ١٧٩٢×٢٣٠٤.",
+ "display_name": "صورة HiDream-O1 كامنة فارغة",
+ "inputs": {
+ "batch_size": {
+ "name": "حجم الدفعة"
+ },
+ "height": {
+ "name": "الارتفاع"
+ },
+ "width": {
+ "name": "العرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "توليد الصور عبر Flux.2 [pro] أو Flux.2 [max] من موجه وصور مرجعية اختيارية.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "التحكم بعد التوليد"
+ },
+ "model": {
+ "name": "النموذج"
+ },
+ "model_height": {
+ "name": "الارتفاع"
+ },
+ "model_width": {
+ "name": "العرض"
+ },
+ "prompt": {
+ "name": "الموجه",
+ "tooltip": "موجه لتوليد أو تعديل الصورة"
+ },
+ "seed": {
+ "name": "البذرة",
+ "tooltip": "البذرة العشوائية المستخدمة لإنشاء الضوضاء."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "ينشئ الصور بشكل متزامن بناءً على النص والوَضوح.",
"display_name": "Flux.2 [max] صورة",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "تعديل صورة موجودة بناءً على موجه نصي",
+ "display_name": "تعديل صورة Grok",
+ "inputs": {
+ "control_after_generate": {
+ "name": "التحكم بعد التوليد"
+ },
+ "model": {
+ "name": "النموذج"
+ },
+ "model_aspect_ratio": {
+ "name": "نسبة العرض إلى الارتفاع"
+ },
+ "model_number_of_images": {
+ "name": "عدد الصور"
+ },
+ "model_resolution": {
+ "name": "الدقة"
+ },
+ "prompt": {
+ "name": "الموجه",
+ "tooltip": "الموجه النصي المستخدم لتوليد الصورة"
+ },
+ "seed": {
+ "name": "البذرة",
+ "tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "توليد صور باستخدام Grok بناءً على مطالبة نصية",
"display_name": "صورة Grok",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "يتم حساب متوسط مخرجات النموذج عبر عدة مواضع لشبكة الباتشات المحوّلة خلال الجزء الأخير من عملية التوليد. يلغي الحواف.",
+ "display_name": "تنعيم حواف الباتش HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "المزج",
+ "tooltip": "average: متوسط بوزن متساوٍ. window: وزن نافذة هان يفضل كل تمريرة بعيداً عن حدود الباتش الخاصة بها. median: الوسيط لكل بكسل، يرفض التمريرات الشاذة."
+ },
+ "end_percent": {
+ "name": "نسبة النهاية",
+ "tooltip": "تقدم التوليد عند إيقاف المزج."
+ },
+ "model": {
+ "name": "النموذج"
+ },
+ "passes": {
+ "name": "عدد التمريرات",
+ "tooltip": "عدد التمريرات لكل خطوة مفعلة. ٢/٤ = ثابت. ramp_*: يزداد عدد التمريرات مع اقتراب التوليد من النهاية (مزيد من التنعيم حيث الحواف أكثر وضوحاً)."
+ },
+ "pattern": {
+ "name": "النمط",
+ "tooltip": "تخطيط التحويل. single_shift: تمريرة واحدة على شبكة الباتش الطبيعية + أخرى محوّلة. symmetric: جميع التمريرات خارج الشبكة، التحويلات موزعة حول الأصل."
+ },
+ "start_percent": {
+ "name": "نسبة البداية",
+ "tooltip": "تقدم التوليد (٠=بداية، ١=نهاية) عند تفعيل المزج."
+ },
+ "strength": {
+ "name": "القوة",
+ "tooltip": "تداخل بين التنبؤ الطبيعي (٠) والنتيجة الممزوجة (١)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "إرفاق ١-١٠ صور مرجعية للتهيئة، صورة واحدة لتعليمات التحرير أو عدة صور لتخصيص قائم على الموضوع.",
+ "display_name": "صور مرجعية HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "الصور",
+ "tooltip": "صور مرجعية. صورة واحدة = تحرير بالتعليمات؛ ٢-١٠ صور = مرجع متعدد."
+ },
+ "negative": {
+ "name": "سلبي"
+ },
+ "positive": {
+ "name": "إيجابي"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "إيجابي",
+ "tooltip": null
+ },
+ "1": {
+ "name": "سلبي",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "تكبير الصور منخفضة الدقة إلى دقة فائقة، إزالة الشوائب والضوضاء. الحد الأقصى للإخراج: ٣٢ ميغابيكسل.",
"display_name": "تحسين الصورة العام من HitPaw",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "تحميل نموذج إزالة الخلفية",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "اسم_إزالة_الخلفية",
+ "tooltip": "النموذج المستخدم لإزالة الخلفيات من الصور"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "نموذج_الخلفية",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "تحميل صورة",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "مقياس ضوضاء النموذج",
+ "inputs": {
+ "model": {
+ "name": "النموذج"
+ },
+ "noise_scale": {
+ "name": "مقياس الضوضاء",
+ "tooltip": "مقياس الضوضاء المطلق أثناء التدريب. مثال HiDream-O1 base: ٨٫٠، dev: ٧٫٥."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "توليد الصور عبر نقطة نهاية GPT Image من OpenAI.",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "التحكم بعد التوليد"
+ },
+ "model": {
+ "name": "النموذج"
+ },
+ "model_background": {
+ "name": "الخلفية"
+ },
+ "model_custom_height": {
+ "name": "ارتفاع مخصص"
+ },
+ "model_custom_width": {
+ "name": "عرض مخصص"
+ },
+ "model_quality": {
+ "name": "الجودة"
+ },
+ "model_size": {
+ "name": "الحجم"
+ },
+ "n": {
+ "name": "عدد الصور",
+ "tooltip": "كم عدد الصور التي سيتم توليدها"
+ },
+ "prompt": {
+ "name": "الموجه",
+ "tooltip": "موجه نصي لـ GPT Image"
+ },
+ "seed": {
+ "name": "البذرة",
+ "tooltip": "لم يتم تطبيقها بعد في الخلفية"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "يقوم بتحميل وإعداد ملفات الإدخال (نص، pdf، إلخ) لتضمينها كمدخلات لعقدة OpenAI Chat. سيتم قراءة الملفات بواسطة نموذج OpenAI عند إنشاء الرد. 🛈 تلميح: يمكن ربطها مع عقد OpenAI Input File الأخرى.",
"display_name": "OpenAI ChatGPT Input Files",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "إزالة الخلفية",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "نموذج_إزالة_الخلفية",
+ "tooltip": "نموذج إزالة الخلفية المستخدم لتوليد القناع"
+ },
+ "image": {
+ "name": "الصورة",
+ "tooltip": "صورة الإدخال لإزالة الخلفية منها"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "القناع",
+ "tooltip": "قناع المقدمة المُنتج"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "إعادة تهيئة CFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "مُعين LCM مع ضوضاء قابلة للتعديل في كل خطوة. s_noise هو معامل لمقياس ضوضاء التدريب للنموذج",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "تقييد الضوضاء في كل خطوة إلى ±N*الانحراف المعياري. ٠ لتعطيل ذلك."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "معامل الضوضاء في كل خطوة عند الخطوة الأولى (١.٠ = يطابق التدريب)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "معامل الضوضاء في كل خطوة عند الخطوة الأخيرة. اجعله مساوياً لـ s_noise لجدول زمني ثابت."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "المُعين LCM للتكبير",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "PBR"
},
"quad": {
- "name": "رباعي"
+ "name": "رباعي",
+ "tooltip": "هذا المعامل قديم ولم يعد له أي تأثير."
},
"texture": {
"name": "الملمس"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "الصوت"
+ },
+ "audio_inject_scale": {
+ "name": "مقياس حقن الصوت",
+ "tooltip": "المقياس لميزات الصوت عند حقنها في نموذج الفيديو."
+ },
+ "video_frames": {
+ "name": "إطارات الفيديو"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مخرجات مشفر الصوت",
+ "tooltip": null
+ },
+ "1": {
+ "name": "سلسلة معدل الإطارات (fps)",
+ "tooltip": "معدل الإطارات المحسوب بناءً على طول الصوت وعدد إطارات الفيديو. يُستخدم في الموجه."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "الصوت",
+ "tooltip": "الصوت المستخدم لحساب إجمالي إطارات الإخراج واستخراج صوت المقطع."
+ },
+ "images": {
+ "name": "الصور"
+ },
+ "segment_index": {
+ "name": "فهرس المقطع",
+ "tooltip": "أي مقطع هذا (٠ للأول، ١ للثاني، إلخ.)"
+ },
+ "segment_length": {
+ "name": "طول المقطع",
+ "tooltip": "طول هذا المقطع (عادةً ١٤٩ إطاراً)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "تسلسل الإطارات الرئيسية المبطنة",
+ "tooltip": "تسلسل الإطارات الرئيسية بعد التبطين"
+ },
+ "1": {
+ "name": "قناع الإطارات الرئيسية",
+ "tooltip": "قناع يحدد الإطارات الصالحة"
+ },
+ "2": {
+ "name": "مقطع الصوت",
+ "tooltip": "مقطع الصوت لهذا الجزء من الفيديو"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "الصوت",
+ "tooltip": "الصوت الذي سيتم تقطيعه لكل مقطع صادر."
+ },
+ "images": {
+ "name": "الصور"
+ },
+ "num_segments": {
+ "name": "عدد المقاطع",
+ "tooltip": "عدد المقاطع المبطنة التي سيتم إصدارها كقوائم."
+ },
+ "segment_length": {
+ "name": "طول المقطع",
+ "tooltip": "طول كل مقطع (عادةً ١٤٩ إطاراً)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "تسلسلات الإطارات الرئيسية المبطنة",
+ "tooltip": "تسلسلات الإطارات الرئيسية بعد التبطين"
+ },
+ "1": {
+ "name": "أقنعة الإطارات الرئيسية",
+ "tooltip": "أقنعة تحدد الإطارات الصالحة"
+ },
+ "2": {
+ "name": "مقطع الصوت",
+ "tooltip": "مقطع الصوت لكل جزء من الفيديو"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "مخرجات ترميز الصوت"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "تضمينات CLIP للرؤية للإطار الأول."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "تضمينات CLIP للرؤية لصورة المرجع."
+ },
+ "height": {
+ "name": "الارتفاع"
+ },
+ "length": {
+ "name": "الطول",
+ "tooltip": "عدد الإطارات في الفيديو المُنتج. يجب أن يبقى ١٤٩ لـ WanDancer."
+ },
+ "mask": {
+ "name": "قناع",
+ "tooltip": "قناع معالجة الصورة للصورة/الصور الابتدائية. الأبيض يبقى، الأسود يُولّد. يُستخدم للتوليد المحلي."
+ },
+ "negative": {
+ "name": "سلبي"
+ },
+ "positive": {
+ "name": "إيجابي"
+ },
+ "start_image": {
+ "name": "الصورة الابتدائية",
+ "tooltip": "الصورة أو الصور الأولية التي سيتم ترميزها، يمكن أن تكون أي عدد من الإطارات."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "العرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "إيجابي",
+ "tooltip": null
+ },
+ "1": {
+ "name": "سلبي",
+ "tooltip": null
+ },
+ "2": {
+ "name": "كامِن",
+ "tooltip": "كامِن فارغ."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "وان إطار أول وآخر إلى فيديو",
"inputs": {
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 75b947fb10..85d6009b5f 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -1650,7 +1650,7 @@
"Directories": "Directories"
},
"nodeCategories": {
- "_for_testing": "_for_testing",
+ "experimental": "experimental",
"custom_sampling": "custom_sampling",
"noise": "noise",
"dataset": "dataset",
@@ -1658,8 +1658,9 @@
"image": "image",
"sampling": "sampling",
"schedulers": "schedulers",
- "audio": "audio",
"conditioning": "conditioning",
+ "video_models": "video_models",
+ "audio": "audio",
"loaders": "loaders",
"guiders": "guiders",
"batch": "batch",
@@ -1682,17 +1683,14 @@
"postprocessing": "postprocessing",
"hooks": "hooks",
"combine": "combine",
- "math": "math",
"logic": "logic",
"cond single": "cond single",
- "context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"deprecated": "deprecated",
"detection": "detection",
- "": "",
"debug": "debug",
"model": "model",
"ElevenLabs": "ElevenLabs",
@@ -1703,14 +1701,14 @@
"unet": "unet",
"sigmas": "sigmas",
"BFL": "BFL",
+ "": "",
"Gemini": "Gemini",
- "video_models": "video_models",
"gligen": "gligen",
"shader": "shader",
"Grok": "Grok",
"Wan": "Wan",
"HitPaw": "HitPaw",
- "sd": "sd",
+ "3d_models": "3d_models",
"Ideogram": "Ideogram",
"transform": "transform",
"color": "color",
@@ -1737,27 +1735,24 @@
"Quiver": "Quiver",
"Recraft": "Recraft",
"edit_models": "edit_models",
+ "background removal": "background removal",
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"animation": "animation",
- "api": "api",
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Sonilo": "Sonilo",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
- "3d_models": "3d_models",
"style_model": "style_model",
"supir": "supir",
"Tencent": "Tencent",
- "textgen": "textgen",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
- "camera": "camera",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
@@ -1767,6 +1762,7 @@
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
+ "BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CAMERA_CONTROL",
diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json
index 47882a007d..f093995736 100644
--- a/src/locales/en/nodeDefs.json
+++ b/src/locales/en/nodeDefs.json
@@ -141,6 +141,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "width": {
+ "name": "width"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length"
+ },
+ "batch_size": {
+ "name": "batch_size"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AudioAdjustVolume": {
"display_name": "Audio Adjust Volume",
"inputs": {
@@ -196,7 +230,7 @@
}
},
"AudioEncoderLoader": {
- "display_name": "AudioEncoderLoader",
+ "display_name": "Load Audio Encoder",
"inputs": {
"audio_encoder_name": {
"name": "audio_encoder_name"
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Text prompt for creating or editing an image."
+ },
+ "model": {
+ "name": "model"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Seed to use for generation."
+ },
+ "watermark": {
+ "name": "watermark",
+ "tooltip": "Whether to add an \"AI generated\" watermark to the image."
+ },
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model_fail_on_partial": {
+ "name": "fail_on_partial"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_max_images": {
+ "name": "max_images"
+ },
+ "model_size_preset": {
+ "name": "size_preset"
+ },
+ "model_width": {
+ "name": "width"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"display_name": "ByteDance Text to Video",
"description": "Generate video using ByteDance models via api based on prompt",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "display_name": "Empty HiDream-O1 Latent Image",
+ "description": "Empty pixel-space latent for HiDream-O1-Image. The model was trained at ~4 megapixels; lower resolutions go off-distribution and quality regresses noticeably. Trained resolutions: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "inputs": {
+ "width": {
+ "name": "width"
+ },
+ "height": {
+ "name": "height"
+ },
+ "batch_size": {
+ "name": "batch_size"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "display_name": "Flux.2 Image",
+ "description": "Generate images via Flux.2 [pro] or Flux.2 [max] from a prompt and optional reference images.",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt for the image generation or edit"
+ },
+ "model": {
+ "name": "model"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "The random seed used for creating the noise."
+ },
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_width": {
+ "name": "width"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"display_name": "Flux.2 [max] Image",
"description": "Generates images synchronously based on prompt and resolution.",
@@ -4290,7 +4423,7 @@
}
},
"GLIGENLoader": {
- "display_name": "GLIGENLoader",
+ "display_name": "Load GLIGEN Model",
"inputs": {
"gligen_name": {
"name": "gligen_name"
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "display_name": "Grok Image Edit",
+ "description": "Modify an existing image based on a text prompt",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "The text prompt used to generate the image"
+ },
+ "model": {
+ "name": "model"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
+ },
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model_aspect_ratio": {
+ "name": "aspect_ratio"
+ },
+ "model_number_of_images": {
+ "name": "number_of_images"
+ },
+ "model_resolution": {
+ "name": "resolution"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"display_name": "Grok Image",
"description": "Generate images using Grok based on a text prompt",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "display_name": "HiDream-O1 Patch Seam Smoothing",
+ "description": "Average the model output across multiple shifted patch-grid positions during the late portion of sampling. Cancels seams.",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "start_percent": {
+ "name": "start_percent",
+ "tooltip": "Sampling progress (0=start, 1=end) at which the blend turns ON."
+ },
+ "end_percent": {
+ "name": "end_percent",
+ "tooltip": "Sampling progress at which the blend turns OFF."
+ },
+ "pattern": {
+ "name": "pattern",
+ "tooltip": "Shift layout. single_shift: one pass at the natural patch grid + others offset. symmetric: all passes off-grid, shifts split around origin."
+ },
+ "passes": {
+ "name": "passes",
+ "tooltip": "Number of passes per gated step. 2/4 = fixed. ramp_*: pass count increases as sampling approaches end (more smoothing where seams are most visible)."
+ },
+ "blend": {
+ "name": "blend",
+ "tooltip": "average: equal-weight mean. window: Hann-windowed weighting favoring each pass away from its patch boundaries. median: per-pixel median, rejects wraparound-outlier passes."
+ },
+ "strength": {
+ "name": "strength",
+ "tooltip": "Interpolation between the natural-grid pred (0) and the averaged result (1)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "display_name": "HiDream-O1 Reference Images",
+ "description": "Attach 1-10 reference images to conditioning, one for edit instructionor multiple for subject-driven personalization.",
+ "inputs": {
+ "positive": {
+ "name": "positive"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "images": {
+ "name": "images",
+ "tooltip": "Reference images. 1 image = instruction edit; 2-10 images = multi reference."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"display_name": "HitPaw General Image Enhance",
"description": "Upscale low-resolution images to super-resolution, eliminate artifacts and noise. Maximum output: 32 megapixels.",
@@ -4887,7 +5118,7 @@
}
},
"HunyuanRefinerLatent": {
- "display_name": "HunyuanRefinerLatent",
+ "display_name": "Hunyuan Latent Refiner",
"inputs": {
"positive": {
"name": "positive"
@@ -4992,7 +5223,7 @@
}
},
"HunyuanVideo15SuperResolution": {
- "display_name": "HunyuanVideo15SuperResolution",
+ "display_name": "Hunyuan Video 1.5 Super Resolution",
"inputs": {
"positive": {
"name": "positive"
@@ -5032,7 +5263,7 @@
}
},
"HypernetworkLoader": {
- "display_name": "HypernetworkLoader",
+ "display_name": "Load Hypernetwork",
"inputs": {
"model": {
"name": "model"
@@ -5592,7 +5823,7 @@
}
},
"ImageQuantize": {
- "display_name": "ImageQuantize",
+ "display_name": "Quantize Image",
"inputs": {
"image": {
"name": "image"
@@ -5724,7 +5955,7 @@
}
},
"ImageSharpen": {
- "display_name": "ImageSharpen",
+ "display_name": "Sharpen Image",
"inputs": {
"image": {
"name": "image"
@@ -7598,6 +7829,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Load Background Removal Model",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "The model used to remove backgrounds from images"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Load Image",
"inputs": {
@@ -8259,7 +8505,7 @@
}
},
"LTXVPreprocess": {
- "display_name": "LTXVPreprocess",
+ "display_name": "LTXV Preprocess",
"inputs": {
"image": {
"name": "image"
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "ModelNoiseScale",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "noise_scale": {
+ "name": "noise_scale",
+ "tooltip": "Absolute training noise scale. For example HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "display_name": "OpenAI GPT Image 2",
+ "description": "Generates images via OpenAI's GPT Image endpoint.",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Text prompt for GPT Image"
+ },
+ "model": {
+ "name": "model"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "How many images to generate"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "not implemented yet in backend"
+ },
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model_background": {
+ "name": "background"
+ },
+ "model_custom_height": {
+ "name": "custom_height"
+ },
+ "model_custom_width": {
+ "name": "custom_width"
+ },
+ "model_quality": {
+ "name": "quality"
+ },
+ "model_size": {
+ "name": "size"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"display_name": "OpenAI ChatGPT Input Files",
"description": "Loads and prepares input files (text, pdf, etc.) to include as inputs for the OpenAI Chat Node. The files will be read by the OpenAI model when generating a response. 🛈 TIP: Can be chained together with other OpenAI Input File nodes.",
@@ -12189,7 +12491,7 @@
}
},
"PerpNeg": {
- "display_name": "Perp-Neg (DEPRECATED by PerpNegGuider)",
+ "display_name": "Perp-Neg (DEPRECATED by Perp-Neg Guider)",
"inputs": {
"model": {
"name": "model"
@@ -12208,7 +12510,7 @@
}
},
"PerpNegGuider": {
- "display_name": "PerpNegGuider",
+ "display_name": "Perp-Neg Guider",
"inputs": {
"model": {
"name": "model"
@@ -13432,6 +13734,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Remove Background",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "tooltip": "Input image to remove the background from"
+ },
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "Background removal model used to generate the mask"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Generated foreground mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14487,6 +14808,29 @@
}
}
},
+ "SamplerLCM": {
+ "display_name": "SamplerLCM",
+ "description": "LCM sampler with tunable per-step noise. s_noise is a multiplier on the model's training noise scale",
+ "inputs": {
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "Per-step noise multiplier at the first step (1.0 = match training)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Per-step noise multiplier at the last step. Set equal to s_noise for a constant schedule."
+ },
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Clamp per-step noise to +/- N*std. 0 disables."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -14759,7 +15103,7 @@
}
},
"SaveImageWebsocket": {
- "display_name": "SaveImageWebsocket",
+ "display_name": "Save Image (Websocket)",
"inputs": {
"images": {
"name": "images"
@@ -16681,7 +17025,7 @@
}
},
"TextGenerate": {
- "display_name": "TextGenerate",
+ "display_name": "Generate Text",
"inputs": {
"clip": {
"name": "clip"
@@ -16743,7 +17087,7 @@
}
},
"TextGenerateLTX2Prompt": {
- "display_name": "TextGenerateLTX2Prompt",
+ "display_name": "Generate LTX2 Prompt",
"inputs": {
"clip": {
"name": "clip"
@@ -17362,7 +17706,8 @@
"name": "face_limit"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "This parameter is deprecated and does nothing."
},
"geometry_quality": {
"name": "geometry_quality"
@@ -17569,7 +17914,7 @@
}
},
"unCLIPCheckpointLoader": {
- "display_name": "unCLIPCheckpointLoader",
+ "display_name": "Load unCLIP Checkpoint",
"inputs": {
"ckpt_name": {
"name": "ckpt_name"
@@ -18759,7 +19104,7 @@
}
},
"VoxelToMesh": {
- "display_name": "VoxelToMesh",
+ "display_name": "Voxel to Mesh",
"inputs": {
"voxel": {
"name": "voxel"
@@ -18778,7 +19123,7 @@
}
},
"VoxelToMeshBasic": {
- "display_name": "VoxelToMeshBasic",
+ "display_name": "Voxel to Mesh (Basic)",
"inputs": {
"voxel": {
"name": "voxel"
@@ -19360,6 +19705,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "The scale for the audio features when injected into the video model."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "The calculated fps based on the audio length and the number of video frames. Used in the prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "images": {
+ "name": "images"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Length of this segment (usually 149 frames)"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Which segment this is (0 for first, 1 for second, etc.)"
+ },
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio to calculate total output frames from and extract segment audio."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Padded keyframe sequence"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Mask indicating valid frames"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Audio segment for this video segment"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "images": {
+ "name": "images"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Length of each segment (usually 149 frames)"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "How many padded segments to emit as lists."
+ },
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio to slice for each emitted segment."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Padded keyframe sequences"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masks indicating valid frames"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Audio segment for each video segment"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "positive": {
+ "name": "positive"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "width"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length",
+ "tooltip": "The number of frames in the generated video. Should stay 149 for WanDancer."
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "The CLIP vision embeds for the first frame."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "The CLIP vision embeds for the reference image."
+ },
+ "start_image": {
+ "name": "start_image",
+ "tooltip": "The initial image(s) to be encoded, can be any number of frames."
+ },
+ "mask": {
+ "name": "mask",
+ "tooltip": "Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."
+ },
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Empty latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index f1da390e45..5362f01042 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
"AUDIO_RECORD": "GRABACIÓN_AUDIO",
+ "BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "CUADRO DELIMITADOR",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
- "api": "api",
"api node": "nodo api",
"attention_experiments": "experimentos_de_atención",
"audio": "audio",
+ "background removal": "eliminación de fondo",
"batch": "lote",
- "camera": "cámara",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "color",
@@ -2301,7 +2300,6 @@
"cond pair": "par_cond",
"cond single": "cond único",
"conditioning": "acondicionamiento",
- "context": "contexto",
"controlnet": "controlnet",
"create": "crear",
"custom_sampling": "muestreo_personalizado",
@@ -2310,6 +2308,7 @@
"deprecated": "obsoleto",
"detection": "detección",
"edit_models": "editar_modelos",
+ "experimental": "experimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "orientación",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "mask",
- "math": "matemáticas",
"model": "modelo",
"model_merging": "fusión_de_modelos",
"model_patches": "parches_de_modelo",
@@ -2342,7 +2340,6 @@
"save": "guardar",
"schedulers": "programadores",
"scheduling": "programación",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
- "textgen": "textgen",
"training": "entrenamiento",
"transform": "transformar",
"unet": "unet",
diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json
index 4600adfc14..e2aee655f4 100644
--- a/src/locales/es/nodeDefs.json
+++ b/src/locales/es/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "tamaño_de_lote"
+ },
+ "height": {
+ "name": "alto"
+ },
+ "length": {
+ "name": "longitud"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "start_image": {
+ "name": "imagen_inicial"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ancho"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AñadirRuido",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "Generación unificada de texto a imagen y edición precisa de una sola frase hasta una resolución de 4K.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control después de generar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_fail_on_partial": {
+ "name": "fallar_en_parcial"
+ },
+ "model_height": {
+ "name": "altura"
+ },
+ "model_max_images": {
+ "name": "imágenes_máximas"
+ },
+ "model_size_preset": {
+ "name": "preajuste_de_tamaño"
+ },
+ "model_width": {
+ "name": "ancho"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Indicador de texto para crear o editar una imagen."
+ },
+ "seed": {
+ "name": "semilla",
+ "tooltip": "Semilla a utilizar para la generación."
+ },
+ "watermark": {
+ "name": "marca de agua",
+ "tooltip": "Indica si se añade una marca de agua de \"Generado por IA\" a la imagen."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "Generar video usando modelos de ByteDance mediante API basado en prompt",
"display_name": "ByteDance Texto a Video",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "Latente en espacio de píxeles vacío para HiDream-O1-Image. El modelo fue entrenado a ~4 megapíxeles; resoluciones más bajas salen de la distribución y la calidad disminuye notablemente. Resoluciones entrenadas: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Imagen Latente Vacía HiDream-O1",
+ "inputs": {
+ "batch_size": {
+ "name": "tamaño_lote"
+ },
+ "height": {
+ "name": "alto"
+ },
+ "width": {
+ "name": "ancho"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "ImagenLatenteHunyuanVacía",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Genera imágenes mediante Flux.2 [pro] o Flux.2 [max] a partir de un prompt y, opcionalmente, imágenes de referencia.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control después de generar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_height": {
+ "name": "altura"
+ },
+ "model_width": {
+ "name": "ancho"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt para la generación o edición de la imagen"
+ },
+ "seed": {
+ "name": "semilla",
+ "tooltip": "La semilla aleatoria utilizada para crear el ruido."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "Genera imágenes de forma sincrónica según el prompt y la resolución.",
"display_name": "Flux.2 [max] Imagen",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "Modifica una imagen existente en base a un prompt de texto",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control después de generar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_aspect_ratio": {
+ "name": "relación_de_aspecto"
+ },
+ "model_number_of_images": {
+ "name": "número_de_imágenes"
+ },
+ "model_resolution": {
+ "name": "resolución"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "El prompt de texto utilizado para generar la imagen"
+ },
+ "seed": {
+ "name": "semilla",
+ "tooltip": "Semilla para determinar si el nodo debe volver a ejecutarse; los resultados reales son no deterministas independientemente de la semilla."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "Genera imágenes usando Grok a partir de una indicación de texto",
"display_name": "Imagen Grok",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "Promedia la salida del modelo a través de múltiples posiciones desplazadas de la cuadrícula de parches durante la parte final del muestreo. Elimina costuras.",
+ "display_name": "Suavizado de Costuras de Parches HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "mezcla",
+ "tooltip": "average: media de igual peso. window: ponderación con ventana de Hann favoreciendo cada pasada lejos de los bordes de su parche. median: mediana por píxel, rechaza pasadas atípicas por envoltura."
+ },
+ "end_percent": {
+ "name": "porcentaje_fin",
+ "tooltip": "Progreso del muestreo en el que se desactiva la mezcla."
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "passes": {
+ "name": "pasadas",
+ "tooltip": "Número de pasadas por paso controlado. 2/4 = fijo. ramp_*: el número de pasadas aumenta a medida que el muestreo se acerca al final (más suavizado donde las costuras son más visibles)."
+ },
+ "pattern": {
+ "name": "patrón",
+ "tooltip": "Disposición del desplazamiento. single_shift: una pasada en la cuadrícula natural de parches + otras desplazadas. symmetric: todas las pasadas fuera de la cuadrícula, desplazamientos repartidos alrededor del origen."
+ },
+ "start_percent": {
+ "name": "porcentaje_inicio",
+ "tooltip": "Progreso del muestreo (0=inicio, 1=fin) en el que se activa la mezcla."
+ },
+ "strength": {
+ "name": "fuerza",
+ "tooltip": "Interpolación entre la predicción de la cuadrícula natural (0) y el resultado promediado (1)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "Adjunta de 1 a 10 imágenes de referencia para el condicionamiento, una para instrucción de edición o varias para personalización basada en el sujeto.",
+ "display_name": "Imágenes de Referencia HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "imágenes",
+ "tooltip": "Imágenes de referencia. 1 imagen = edición por instrucción; 2-10 imágenes = referencia múltiple."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "Aumenta la resolución de imágenes de baja calidad a superresolución, elimina artefactos y ruido. Salida máxima: 32 megapíxeles.",
"display_name": "HitPaw General Image Enhance",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Cargar modelo de eliminación de fondo",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nombre_del_modelo_de_eliminación_de_fondo",
+ "tooltip": "El modelo utilizado para eliminar fondos de imágenes"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modelo_de_fondo",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Cargar Imagen",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "Escala de Ruido del Modelo",
+ "inputs": {
+ "model": {
+ "name": "modelo"
+ },
+ "noise_scale": {
+ "name": "escala_ruido",
+ "tooltip": "Escala absoluta de ruido de entrenamiento. Por ejemplo HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "Cargador de Parches de Modelo",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "Genera imágenes a través del endpoint GPT Image de OpenAI.",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control después de generar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_background": {
+ "name": "fondo"
+ },
+ "model_custom_height": {
+ "name": "altura_personalizada"
+ },
+ "model_custom_width": {
+ "name": "ancho_personalizado"
+ },
+ "model_quality": {
+ "name": "calidad"
+ },
+ "model_size": {
+ "name": "tamaño"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "Cuántas imágenes generar"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt de texto para GPT Image"
+ },
+ "seed": {
+ "name": "semilla",
+ "tooltip": "aún no implementado en el backend"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "Carga y prepara archivos de entrada (texto, pdf, etc.) para incluirlos como entradas para el Nodo de Chat de OpenAI. Los archivos serán leídos por el modelo de OpenAI al generar una respuesta. 🛈 CONSEJO: Se puede encadenar con otros nodos de Archivos de Entrada de OpenAI.",
"display_name": "Archivos de Entrada de OpenAI ChatGPT",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Eliminar fondo",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modelo_de_eliminación_de_fondo",
+ "tooltip": "Modelo de eliminación de fondo utilizado para generar la máscara"
+ },
+ "image": {
+ "name": "imagen",
+ "tooltip": "Imagen de entrada para eliminar el fondo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "máscara",
+ "tooltip": "Máscara de primer plano generada"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "Muestreador LCM con ruido ajustable por paso. s_noise es un multiplicador en la escala de ruido de entrenamiento del modelo",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Limita el ruido por paso a +/- N*std. 0 desactiva."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "Multiplicador de ruido por paso en el primer paso (1.0 = igual que el entrenamiento)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Multiplicador de ruido por paso en el último paso. Igualar a s_noise para un programa constante."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "cuadrilátero"
+ "name": "cuadrilátero",
+ "tooltip": "Este parámetro está obsoleto y no hace nada."
},
"texture": {
"name": "textura"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "La escala para las características de audio cuando se inyectan en el modelo de video."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Los fps calculados en base a la duración del audio y el número de fotogramas de video. Se usa en el prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio para calcular el total de fotogramas de salida y extraer el audio del segmento."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Qué segmento es este (0 para el primero, 1 para el segundo, etc.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longitud de este segmento (usualmente 149 fotogramas)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Secuencia de keyframes rellenada"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Máscara que indica los fotogramas válidos"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segmento de audio para este segmento de video"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio para dividir para cada segmento emitido."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Cuántos segmentos rellenados emitir como listas."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longitud de cada segmento (usualmente 149 fotogramas)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Secuencias de keyframes rellenadas"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Máscaras que indican los fotogramas válidos"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segmento de audio para cada segmento de video"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Las incrustaciones de visión de CLIP para el primer fotograma."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Las incrustaciones de visión de CLIP para la imagen de referencia."
+ },
+ "height": {
+ "name": "alto"
+ },
+ "length": {
+ "name": "longitud",
+ "tooltip": "El número de fotogramas en el video generado. Debe mantenerse en 149 para WanDancer."
+ },
+ "mask": {
+ "name": "máscara",
+ "tooltip": "Máscara de acondicionamiento de imagen para la(s) imagen(es) inicial(es). El blanco se mantiene, el negro se genera. Se utiliza para las generaciones locales."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ },
+ "start_image": {
+ "name": "imagen_inicial",
+ "tooltip": "La(s) imagen(es) inicial(es) a codificar, puede ser cualquier cantidad de fotogramas."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ancho"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latente",
+ "tooltip": "Latente vacío."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json
index 6f7a5ff67b..a78c38b643 100644
--- a/src/locales/fa/main.json
+++ b/src/locales/fa/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "رمزگذار صوت",
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
"AUDIO_RECORD": "ضبط صوت",
+ "BACKGROUND_REMOVAL": "حذف پسزمینه",
"BOOLEAN": "بولی",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "کنترل دوربین",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "پیشرفته",
"animation": "انیمیشن",
- "api": "API",
"api node": "گره API",
"attention_experiments": "آزمایشهای توجه",
"audio": "صدا",
+ "background removal": "حذف پسزمینه",
"batch": "دستهای",
- "camera": "دوربین",
"chroma_radiance": "درخشندگی رنگی",
"clip": "clip",
"color": "رنگ",
@@ -2301,7 +2300,6 @@
"cond pair": "جفت شرط",
"cond single": "شرط تکی",
"conditioning": "شرطگذاری",
- "context": "زمینه",
"controlnet": "controlnet",
"create": "ایجاد",
"custom_sampling": "نمونهگیری سفارشی",
@@ -2310,6 +2308,7 @@
"deprecated": "منسوخ",
"detection": "شناسایی",
"edit_models": "ویرایش مدلها",
+ "experimental": "آزمایشی",
"flux": "flux",
"gligen": "gligen",
"guidance": "راهنمایی",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "ماسک",
- "math": "ریاضی",
"model": "مدل",
"model_merging": "ادغام مدل",
"model_patches": "وصلههای مدل",
@@ -2342,7 +2340,6 @@
"save": "ذخیره",
"schedulers": "زمانبندیها",
"scheduling": "زمانبندی",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سیگماها",
@@ -2350,7 +2347,6 @@
"style_model": "مدل سبک",
"supir": "supir",
"text": "متن",
- "textgen": "textgen",
"training": "آموزش",
"transform": "تبدیل",
"unet": "unet",
diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json
index 07df825175..8629f77603 100644
--- a/src/locales/fa/nodeDefs.json
+++ b/src/locales/fa/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "اندازه بچ"
+ },
+ "height": {
+ "name": "ارتفاع"
+ },
+ "length": {
+ "name": "طول"
+ },
+ "model": {
+ "name": "مدل"
+ },
+ "start_image": {
+ "name": "تصویر اولیه"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "عرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AddNoise",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "تولید تصویر از متن و ویرایش دقیق جملهای با وضوح تا ۴K به صورت یکپارچه.",
+ "display_name": "ByteDance Seedream ۴.۵ و ۵.۰",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_fail_on_partial": {
+ "name": "fail_on_partial"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_max_images": {
+ "name": "max_images"
+ },
+ "model_size_preset": {
+ "name": "size_preset"
+ },
+ "model_width": {
+ "name": "width"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "پرامپت متنی برای ایجاد یا ویرایش تصویر."
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Seed مورد استفاده برای تولید."
+ },
+ "watermark": {
+ "name": "watermark",
+ "tooltip": "آیا واترمارک «تولید شده توسط هوش مصنوعی» به تصویر اضافه شود یا خیر."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "تولید ویدیو با استفاده از مدلهای ByteDance از طریق API بر اساس پرامپت",
"display_name": "تبدیل متن به ویدیو ByteDance",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "بولین",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "فضای پیکسلی نهفته خالی برای HiDream-O1-Image. مدل در حدود ۴ مگاپیکسل آموزش دیده است؛ رزولوشنهای پایینتر از توزیع خارج میشوند و کیفیت به طور محسوسی کاهش مییابد. رزولوشنهای آموزشدیده: ۲۰۴۸×۲۰۴۸، ۲۳۰۴×۱۷۲۸، ۱۷۲۸×۲۳۰۴، ۲۵۶۰×۱۴۴۰، ۱۴۴۰×۲۵۶۰، ۲۴۹۶×۱۶۶۴، ۱۶۶۴×۲۴۹۶، ۳۱۰۴×۱۳۱۲، ۱۳۱۲×۳۱۰۴، ۲۳۰۴×۱۷۹۲، ۱۷۹۲×۲۳۰۴.",
+ "display_name": "تصویر نهفته خالی HiDream-O1",
+ "inputs": {
+ "batch_size": {
+ "name": "batch_size"
+ },
+ "height": {
+ "name": "ارتفاع"
+ },
+ "width": {
+ "name": "عرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "تصویر نهفته Hunyuan خالی",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "تولید تصویر با استفاده از Flux.2 [pro] یا Flux.2 [max] از طریق پرامپت و تصاویر مرجع اختیاری.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_width": {
+ "name": "width"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "پرامپت برای تولید یا ویرایش تصویر"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Seed تصادفی برای ایجاد نویز."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "تولید تصویر به صورت همزمان بر اساس پرامپت و وضوح تصویر.",
"display_name": "Flux.2 [max] Image",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "ویرایش یک تصویر موجود بر اساس پرامپت متنی",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_aspect_ratio": {
+ "name": "aspect_ratio"
+ },
+ "model_number_of_images": {
+ "name": "number_of_images"
+ },
+ "model_resolution": {
+ "name": "resolution"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "پرامپت متنی برای تولید تصویر"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرفنظر از seed غیرقطعی هستند."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "تولید تصویر با استفاده از Grok بر اساس یک متن راهنما",
"display_name": "تصویر Grok",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "میانگینگیری خروجی مدل در چندین موقعیت شبکه پچ جابجا شده در بخش پایانی نمونهگیری. درزها را حذف میکند.",
+ "display_name": "هموارسازی درز پچ HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "ترکیب",
+ "tooltip": "average: میانگین با وزن برابر. window: وزندهی پنجره Hann که هر عبور را از مرز پچ خود دور میکند. median: میانه هر پیکسل، عبورهای پرت را حذف میکند."
+ },
+ "end_percent": {
+ "name": "درصد پایان",
+ "tooltip": "پیشرفت نمونهگیری که در آن ترکیب غیرفعال میشود."
+ },
+ "model": {
+ "name": "مدل"
+ },
+ "passes": {
+ "name": "تعداد عبور",
+ "tooltip": "تعداد عبور در هر مرحله. ۲/۴ = ثابت. ramp_*: تعداد عبور با نزدیک شدن نمونهگیری به پایان افزایش مییابد (هموارسازی بیشتر در جایی که درزها بیشتر دیده میشوند)."
+ },
+ "pattern": {
+ "name": "الگو",
+ "tooltip": "طرح جابجایی. single_shift: یک بار در شبکه پچ طبیعی + سایرین با افست. symmetric: همه عبورها خارج از شبکه، جابجاییها حول مبدأ تقسیم میشوند."
+ },
+ "start_percent": {
+ "name": "درصد شروع",
+ "tooltip": "پیشرفت نمونهگیری (۰=شروع، ۱=پایان) که در آن ترکیب فعال میشود."
+ },
+ "strength": {
+ "name": "شدت",
+ "tooltip": "درونیابی بین پیشبینی شبکه طبیعی (۰) و نتیجه میانگینگیری شده (۱)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "ضمیمه کردن ۱ تا ۱۰ تصویر مرجع به شرطدهی؛ یک تصویر برای ویرایش دستور یا چند تصویر برای شخصیسازی مبتنی بر سوژه.",
+ "display_name": "تصاویر مرجع HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "تصاویر",
+ "tooltip": "تصاویر مرجع. ۱ تصویر = ویرایش دستور؛ ۲ تا ۱۰ تصویر = مرجع چندگانه."
+ },
+ "negative": {
+ "name": "منفی"
+ },
+ "positive": {
+ "name": "مثبت"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مثبت",
+ "tooltip": null
+ },
+ "1": {
+ "name": "منفی",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "ارتقاء تصاویر با وضوح پایین به وضوح بسیار بالا، حذف نویز و آرتیفکتها. حداکثر خروجی: ۳۲ مگاپیکسل.",
"display_name": "افزایش کیفیت تصویر عمومی HitPaw",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "بارگذاری مدل حذف پسزمینه",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "نام مدل حذف پسزمینه",
+ "tooltip": "مدلی که برای حذف پسزمینه از تصاویر استفاده میشود"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مدل حذف پسزمینه",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "بارگذاری تصویر",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "ModelNoiseScale",
+ "inputs": {
+ "model": {
+ "name": "مدل"
+ },
+ "noise_scale": {
+ "name": "مقیاس نویز",
+ "tooltip": "مقیاس نویز آموزش مطلق. برای مثال HiDream-O1 base: ۸.۰، dev: ۷.۵."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "تولید تصویر از طریق سرویس GPT Image شرکت OpenAI.",
+ "display_name": "OpenAI GPT Image ۲",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_background": {
+ "name": "background"
+ },
+ "model_custom_height": {
+ "name": "custom_height"
+ },
+ "model_custom_width": {
+ "name": "custom_width"
+ },
+ "model_quality": {
+ "name": "quality"
+ },
+ "model_size": {
+ "name": "size"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "تعداد تصاویری که باید تولید شود"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "پرامپت متنی برای GPT Image"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "در backend هنوز پیادهسازی نشده است"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "بارگذاری و آمادهسازی فایلهای ورودی (متن، PDF و غیره) برای استفاده به عنوان ورودی در Node چت OpenAI. این فایلها هنگام تولید پاسخ توسط مدل OpenAI خوانده میشوند. 🛈 نکته: میتوان این node را با سایر nodeهای فایل ورودی OpenAI زنجیره کرد.",
"display_name": "فایلهای ورودی OpenAI ChatGPT",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "حذف پسزمینه",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "مدل حذف پسزمینه",
+ "tooltip": "مدل حذف پسزمینه که برای تولید ماسک استفاده میشود"
+ },
+ "image": {
+ "name": "تصویر",
+ "tooltip": "تصویر ورودی برای حذف پسزمینه"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "ماسک",
+ "tooltip": "ماسک پیشزمینه تولید شده"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "نمونهگیر LCM با نویز قابل تنظیم در هر گام. s_noise ضریب مقیاس نویز آموزش مدل است.",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "محدود کردن نویز هر گام به ±N*std. مقدار ۰ غیرفعال میکند."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "ضریب نویز در هر گام در اولین مرحله (۱.۰ = مطابق آموزش)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "ضریب نویز در هر گام در آخرین مرحله. برای برنامه زمانی ثابت، مقدار را برابر با s_noise قرار دهید."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "PBR"
},
"quad": {
- "name": "چهارضلعی"
+ "name": "چهارضلعی",
+ "tooltip": "این پارامتر منسوخ شده است و هیچ تأثیری ندارد."
},
"texture": {
"name": "تکسچر"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "مقیاس ویژگیهای صوتی هنگام تزریق به مدل ویدیو."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "نرخ فریم بر ثانیه (fps) محاسبهشده بر اساس طول صوت و تعداد فریمهای ویدیو. در prompt استفاده میشود."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "صوت برای محاسبه تعداد کل فریمهای خروجی و استخراج صوت بخش."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "این بخش کدام است (۰ برای اول، ۱ برای دوم و ...)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "طول این بخش (معمولاً ۱۴۹ فریم)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "دنباله keyframe با padding"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "ماسک نشاندهنده فریمهای معتبر"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "بخش صوتی برای این بخش ویدیو"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "صوت برای برش هر بخش خروجی."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "تعداد بخشهای padding که به صورت لیست خروجی داده میشود."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "طول هر بخش (معمولاً ۱۴۹ فریم)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "دنبالههای keyframe با padding"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "ماسکها برای نشان دادن فریمهای معتبر"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "بخش صوتی برای هر بخش ویدیو"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "خروجی رمزگذار صوتی"
+ },
+ "clip_vision_output": {
+ "name": "خروجی بینایی clip",
+ "tooltip": "بردارهای بینایی CLIP برای اولین فریم."
+ },
+ "clip_vision_output_ref": {
+ "name": "خروجی مرجع بینایی clip",
+ "tooltip": "بردارهای بینایی CLIP برای تصویر مرجع."
+ },
+ "height": {
+ "name": "ارتفاع"
+ },
+ "length": {
+ "name": "طول",
+ "tooltip": "تعداد فریمهای ویدئوی تولیدشده. برای WanDancer باید ۱۴۹ باقی بماند."
+ },
+ "mask": {
+ "name": "ماسک",
+ "tooltip": "ماسک شرطیسازی تصویر برای تصویر(ها)ی شروع. سفید حفظ میشود، سیاه تولید میشود. برای تولیدات محلی استفاده میشود."
+ },
+ "negative": {
+ "name": "منفی"
+ },
+ "positive": {
+ "name": "مثبت"
+ },
+ "start_image": {
+ "name": "تصویر شروع",
+ "tooltip": "تصویر(ها)ی اولیه برای رمزگذاری؛ میتواند هر تعداد فریم باشد."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "عرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مثبت",
+ "tooltip": null
+ },
+ "1": {
+ "name": "منفی",
+ "tooltip": null
+ },
+ "2": {
+ "name": "لاتنت",
+ "tooltip": "لاتنت خالی."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index 0991c97681..c0a6ca2ee9 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "ENCODEUR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SORTIE_ENCODEUR_AUDIO",
"AUDIO_RECORD": "ENREGISTREMENT_AUDIO",
+ "BACKGROUND_REMOVAL": "SUPPRESSION_ARRIÈRE-PLAN",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOÎTE ENGLOBANTE",
"CAMERA_CONTROL": "Contrôle de la caméra",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_pour_test",
"advanced": "avancé",
"animation": "animation",
- "api": "api",
"api node": "nœud api",
"attention_experiments": "expériences_d'attention",
"audio": "audio",
+ "background removal": "suppression de l’arrière-plan",
"batch": "lot",
- "camera": "caméra",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "couleur",
@@ -2301,7 +2300,6 @@
"cond pair": "cond pair",
"cond single": "cond unique",
"conditioning": "conditionnement",
- "context": "contexte",
"controlnet": "controlnet",
"create": "créer",
"custom_sampling": "échantillonnage_personnalisé",
@@ -2310,6 +2308,7 @@
"deprecated": "déprécié",
"detection": "détection",
"edit_models": "edit_models",
+ "experimental": "expérimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "guidance",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "masque",
- "math": "math",
"model": "modèle",
"model_merging": "fusion_de_modèles",
"model_patches": "patches_de_modèle",
@@ -2342,7 +2340,6 @@
"save": "enregistrer",
"schedulers": "planificateurs",
"scheduling": "planification",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modèle_de_style",
"supir": "supir",
"text": "texte",
- "textgen": "textgen",
"training": "entraînement",
"transform": "transformer",
"unet": "unet",
diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json
index 3b29ea6c07..92cb7c4b3b 100644
--- a/src/locales/fr/nodeDefs.json
+++ b/src/locales/fr/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "taille_du_lot"
+ },
+ "height": {
+ "name": "hauteur"
+ },
+ "length": {
+ "name": "longueur"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "start_image": {
+ "name": "image_de_départ"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largeur"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AjouterBruit",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "Génération d'image à partir de texte unifiée et édition précise d'une seule phrase jusqu'à une résolution 4K.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "contrôle après génération"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "model_fail_on_partial": {
+ "name": "échec_sur_partiel"
+ },
+ "model_height": {
+ "name": "hauteur"
+ },
+ "model_max_images": {
+ "name": "images_max"
+ },
+ "model_size_preset": {
+ "name": "préréglage_taille"
+ },
+ "model_width": {
+ "name": "largeur"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Invite textuelle pour créer ou éditer une image."
+ },
+ "seed": {
+ "name": "graine",
+ "tooltip": "Graine à utiliser pour la génération."
+ },
+ "watermark": {
+ "name": "filigrane",
+ "tooltip": "Ajouter ou non un filigrane « Généré par IA » à l’image."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "Générer une vidéo en utilisant les modèles ByteDance via l'API basée sur l'invite",
"display_name": "ByteDance Texte vers Vidéo",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "Latent d’espace pixel vide pour HiDream-O1-Image. Le modèle a été entraîné à environ 4 mégapixels ; des résolutions plus basses sortent de la distribution et la qualité régresse nettement. Résolutions d’entraînement : 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Image latente HiDream-O1 vide",
+ "inputs": {
+ "batch_size": {
+ "name": "taille_lot"
+ },
+ "height": {
+ "name": "hauteur"
+ },
+ "width": {
+ "name": "largeur"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Générez des images via Flux.2 [pro] ou Flux.2 [max] à partir d’un prompt et d’images de référence optionnelles.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "contrôle après génération"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "model_height": {
+ "name": "hauteur"
+ },
+ "model_width": {
+ "name": "largeur"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt pour la génération ou l’édition d’image"
+ },
+ "seed": {
+ "name": "graine",
+ "tooltip": "La graine aléatoire utilisée pour créer le bruit."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "Génère des images de manière synchrone à partir d'une invite et d'une résolution.",
"display_name": "Flux.2 [max] Image",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "Modifiez une image existante à partir d’un prompt textuel",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "contrôle après génération"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "model_aspect_ratio": {
+ "name": "ratio_d’aspect"
+ },
+ "model_number_of_images": {
+ "name": "nombre_d’images"
+ },
+ "model_resolution": {
+ "name": "résolution"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Le prompt textuel utilisé pour générer l’image"
+ },
+ "seed": {
+ "name": "graine",
+ "tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes quel que soit la graine."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "Générez des images avec Grok à partir d'une invite textuelle",
"display_name": "Grok Image",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "Moyenne la sortie du modèle sur plusieurs positions de grille de patch décalées pendant la phase finale de l’échantillonnage. Annule les coutures.",
+ "display_name": "Lissage des coutures de patch HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "mélange",
+ "tooltip": "average : moyenne à poids égal. window : pondération fenêtrée de Hann favorisant chaque passage loin de ses bords de patch. median : médiane par pixel, rejette les passages aberrants."
+ },
+ "end_percent": {
+ "name": "pourcentage_fin",
+ "tooltip": "Progression de l’échantillonnage à laquelle le mélange se désactive."
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "passes": {
+ "name": "passes",
+ "tooltip": "Nombre de passages par étape contrôlée. 2/4 = fixe. ramp_* : le nombre de passages augmente à mesure que l’échantillonnage approche de la fin (plus de lissage là où les coutures sont les plus visibles)."
+ },
+ "pattern": {
+ "name": "motif",
+ "tooltip": "Disposition du décalage. single_shift : un passage sur la grille de patch naturelle + autres décalés. symmetric : tous les passages hors grille, décalages répartis autour de l’origine."
+ },
+ "start_percent": {
+ "name": "pourcentage_début",
+ "tooltip": "Progression de l’échantillonnage (0=début, 1=fin) à laquelle le mélange s’active."
+ },
+ "strength": {
+ "name": "force",
+ "tooltip": "Interpolation entre la prédiction sur la grille naturelle (0) et le résultat moyenné (1)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "Ajoutez 1 à 10 images de référence au conditionnement, une pour une instruction d’édition ou plusieurs pour une personnalisation orientée sujet.",
+ "display_name": "Images de référence HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "images",
+ "tooltip": "Images de référence. 1 image = édition par instruction ; 2-10 images = multi-référence."
+ },
+ "negative": {
+ "name": "négatif"
+ },
+ "positive": {
+ "name": "positif"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "négatif",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "Agrandissez les images basse résolution en super-résolution, éliminez les artefacts et le bruit. Sortie maximale : 32 mégapixels.",
"display_name": "HitPaw Amélioration Générale d’Image",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Charger le modèle de suppression d’arrière-plan",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nom_du_modèle_de_suppression_arrière-plan",
+ "tooltip": "Le modèle utilisé pour supprimer les arrière-plans des images"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modèle_bg",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Charger Image",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "Échelle de bruit du modèle",
+ "inputs": {
+ "model": {
+ "name": "modèle"
+ },
+ "noise_scale": {
+ "name": "échelle_bruit",
+ "tooltip": "Échelle absolue du bruit d’entraînement. Par exemple HiDream-O1 base : 8.0, dev : 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "Génère des images via l’API GPT Image d’OpenAI.",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "contrôle après génération"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "model_background": {
+ "name": "arrière-plan"
+ },
+ "model_custom_height": {
+ "name": "hauteur_personnalisée"
+ },
+ "model_custom_width": {
+ "name": "largeur_personnalisée"
+ },
+ "model_quality": {
+ "name": "qualité"
+ },
+ "model_size": {
+ "name": "taille"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "Combien d’images générer"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt textuel pour GPT Image"
+ },
+ "seed": {
+ "name": "graine",
+ "tooltip": "non implémenté côté serveur pour le moment"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "Charge et prépare les fichiers d'entrée (texte, pdf, etc.) à inclure comme entrées pour le nœud de chat OpenAI. Les fichiers seront lus par le modèle OpenAI lors de la génération d'une réponse. 🛈 ASTUCE : Peut être chaîné avec d'autres nœuds de fichiers d'entrée OpenAI.",
"display_name": "Fichiers d'entrée OpenAI ChatGPT",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Supprimer l’arrière-plan",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modèle_de_suppression_arrière-plan",
+ "tooltip": "Modèle de suppression d’arrière-plan utilisé pour générer le masque"
+ },
+ "image": {
+ "name": "image",
+ "tooltip": "Image d’entrée dont l’arrière-plan doit être supprimé"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "masque",
+ "tooltip": "Masque de premier plan généré"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "Échantillonneur LCM avec bruit ajustable à chaque étape. s_noise est un multiplicateur de l'échelle de bruit d'entraînement du modèle.",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Limite le bruit par étape à +/- N*écart-type. 0 pour désactiver."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "Multiplicateur de bruit par étape à la première étape (1.0 = correspond à l'entraînement)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Multiplicateur de bruit par étape à la dernière étape. Identique à s_noise pour un planning constant."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "Ce paramètre est obsolète et n'a aucun effet."
},
"texture": {
"name": "texture"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "L'échelle des caractéristiques audio lors de leur injection dans le modèle vidéo."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Le nombre d'images par seconde calculé en fonction de la durée de l'audio et du nombre d'images vidéo. Utilisé dans l'invite."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio pour calculer le nombre total d'images de sortie et extraire l'audio du segment."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Quel segment est-ce (0 pour le premier, 1 pour le second, etc.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longueur de ce segment (généralement 149 images)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Séquence de keyframes complétée"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masque indiquant les images valides"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segment audio pour ce segment vidéo"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio à découper pour chaque segment émis."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Combien de segments complétés à émettre sous forme de listes."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longueur de chaque segment (généralement 149 images)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Séquences de keyframes complétées"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masques indiquant les images valides"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segment audio pour chaque segment vidéo"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Les embeddings CLIP vision pour la première image."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Les embeddings CLIP vision pour l’image de référence."
+ },
+ "height": {
+ "name": "hauteur"
+ },
+ "length": {
+ "name": "longueur",
+ "tooltip": "Le nombre d’images dans la vidéo générée. Doit rester à 149 pour WanDancer."
+ },
+ "mask": {
+ "name": "masque",
+ "tooltip": "Masque de conditionnement d’image pour l’image ou les images de départ. Le blanc est conservé, le noir est généré. Utilisé pour les générations locales."
+ },
+ "negative": {
+ "name": "négatif"
+ },
+ "positive": {
+ "name": "positif"
+ },
+ "start_image": {
+ "name": "image_de_départ",
+ "tooltip": "L’image ou les images initiales à encoder, peut contenir n’importe quel nombre d’images."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largeur"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "négatif",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Latent vide."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 9807504eb1..a9eb05adea 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "オーディオエンコーダ",
"AUDIO_ENCODER_OUTPUT": "オーディオエンコーダ出力",
"AUDIO_RECORD": "オーディオ録音",
+ "BACKGROUND_REMOVAL": "背景除去",
"BOOLEAN": "ブール",
"BOUNDING_BOX": "バウンディングボックス",
"CAMERA_CONTROL": "カメラコントロール",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_テスト用",
"advanced": "高度な機能",
"animation": "アニメーション",
- "api": "API",
"api node": "apiノード",
"attention_experiments": "アテンション実験",
"audio": "オーディオ",
+ "background removal": "背景除去",
"batch": "バッチ",
- "camera": "カメラ",
"chroma_radiance": "chroma_radiance",
"clip": "クリップ",
"color": "カラー",
@@ -2301,7 +2300,6 @@
"cond pair": "条件ペア",
"cond single": "条件単体",
"conditioning": "条件付け",
- "context": "コンテキスト",
"controlnet": "コントロールネット",
"create": "作成",
"custom_sampling": "カスタムサンプリング",
@@ -2310,6 +2308,7 @@
"deprecated": "非推奨",
"detection": "検出",
"edit_models": "モデル編集",
+ "experimental": "実験的",
"flux": "flux",
"gligen": "グライジェン",
"guidance": "ガイダンス",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "マスク",
- "math": "数学",
"model": "モデル",
"model_merging": "モデルマージ",
"model_patches": "モデルパッチ",
@@ -2342,7 +2340,6 @@
"save": "保存",
"schedulers": "スケジューラー",
"scheduling": "スケジューリング",
- "sd": "sd",
"sd3": "SD3",
"shader": "shader",
"sigmas": "シグマ",
@@ -2350,7 +2347,6 @@
"style_model": "スタイルモデル",
"supir": "supir",
"text": "テキスト",
- "textgen": "textgen",
"training": "トレーニング",
"transform": "変換",
"unet": "U-Net",
diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json
index 75ed4a69df..ff2b777f8a 100644
--- a/src/locales/ja/nodeDefs.json
+++ b/src/locales/ja/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "バッチサイズ"
+ },
+ "height": {
+ "name": "高さ"
+ },
+ "length": {
+ "name": "長さ"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "幅"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "ノイズを追加",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "統合されたテキストから画像生成と、最大4K解像度での正確な単一文編集。",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後のコントロール"
+ },
+ "model": {
+ "name": "モデル"
+ },
+ "model_fail_on_partial": {
+ "name": "部分的な失敗時に停止"
+ },
+ "model_height": {
+ "name": "高さ"
+ },
+ "model_max_images": {
+ "name": "最大画像数"
+ },
+ "model_size_preset": {
+ "name": "サイズプリセット"
+ },
+ "model_width": {
+ "name": "幅"
+ },
+ "prompt": {
+ "name": "プロンプト",
+ "tooltip": "画像の作成または編集のためのテキストプロンプト。"
+ },
+ "seed": {
+ "name": "シード",
+ "tooltip": "生成に使用するシード。"
+ },
+ "watermark": {
+ "name": "ウォーターマーク",
+ "tooltip": "画像に「AI生成」のウォーターマークを追加するかどうか。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "プロンプトに基づきAPI経由でByteDanceモデルを使用して動画を生成",
"display_name": "ByteDance テキストから動画へ",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "HiDream-O1-Image 用の空のピクセル空間潜在。モデルは約4メガピクセルでトレーニングされています。解像度が低いと分布外となり、品質が著しく低下します。トレーニング解像度:2048x2048、2304x1728、1728x2304、2560x1440、1440x2560、2496x1664、1664x2496、3104x1312、1312x3104、2304x1792、1792x2304。",
+ "display_name": "Empty HiDream-O1 潜在画像",
+ "inputs": {
+ "batch_size": {
+ "name": "バッチサイズ"
+ },
+ "height": {
+ "name": "高さ"
+ },
+ "width": {
+ "name": "幅"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Flux.2 [pro] または Flux.2 [max] を使い、プロンプトとオプションの参照画像から画像を生成します。",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後のコントロール"
+ },
+ "model": {
+ "name": "モデル"
+ },
+ "model_height": {
+ "name": "高さ"
+ },
+ "model_width": {
+ "name": "幅"
+ },
+ "prompt": {
+ "name": "プロンプト",
+ "tooltip": "画像生成または編集のためのプロンプト"
+ },
+ "seed": {
+ "name": "シード",
+ "tooltip": "ノイズ生成に使用されるランダムシード。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "プロンプトと解像度に基づいて同期的に画像を生成します。",
"display_name": "Flux.2 [max] 画像",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "既存の画像をテキストプロンプトに基づいて編集します",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後のコントロール"
+ },
+ "model": {
+ "name": "モデル"
+ },
+ "model_aspect_ratio": {
+ "name": "アスペクト比"
+ },
+ "model_number_of_images": {
+ "name": "画像数"
+ },
+ "model_resolution": {
+ "name": "解像度"
+ },
+ "prompt": {
+ "name": "プロンプト",
+ "tooltip": "画像生成に使用されるテキストプロンプト"
+ },
+ "seed": {
+ "name": "シード",
+ "tooltip": "ノードを再実行するかどうかを決定するシード;実際の結果はシードに関係なく非決定的です。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "テキストプロンプトに基づいてGrokで画像を生成します",
"display_name": "Grok画像生成",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "サンプリングの後半で、複数のシフトしたパッチグリッド位置にわたってモデル出力を平均化します。継ぎ目を除去します。",
+ "display_name": "HiDream-O1 パッチ継ぎ目スムージング",
+ "inputs": {
+ "blend": {
+ "name": "ブレンド",
+ "tooltip": "average:等重み平均。window:各パスがパッチ境界から離れるほど重み付けするHannウィンドウ。median:ピクセルごとの中央値、ラップアラウンド外れ値パスを除外。"
+ },
+ "end_percent": {
+ "name": "終了パーセント",
+ "tooltip": "ブレンドがOFFになるサンプリング進行度。"
+ },
+ "model": {
+ "name": "モデル"
+ },
+ "passes": {
+ "name": "パス数",
+ "tooltip": "ゲートステップごとのパス数。2/4=固定。ramp_*:サンプリング終了に近づくほどパス数が増加(継ぎ目が目立つ部分でよりスムージング)。"
+ },
+ "pattern": {
+ "name": "パターン",
+ "tooltip": "シフトレイアウト。single_shift:自然なパッチグリッドで1回+他はオフセット。symmetric:全てのパスがグリッド外、シフトは原点を中心に分割。"
+ },
+ "start_percent": {
+ "name": "開始パーセント",
+ "tooltip": "ブレンドがONになるサンプリング進行度(0=開始、1=終了)。"
+ },
+ "strength": {
+ "name": "強度",
+ "tooltip": "自然グリッド予測(0)と平均化結果(1)の間で補間。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "1~10枚の参照画像をコンディショニングに添付します。1枚は編集指示用、複数枚は被写体パーソナライズ用。",
+ "display_name": "HiDream-O1 参照画像",
+ "inputs": {
+ "images": {
+ "name": "画像",
+ "tooltip": "参照画像。1枚=編集指示、2~10枚=マルチ参照。"
+ },
+ "negative": {
+ "name": "ネガティブ"
+ },
+ "positive": {
+ "name": "ポジティブ"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "ポジティブ",
+ "tooltip": null
+ },
+ "1": {
+ "name": "ネガティブ",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "低解像度画像を超解像度にアップスケールし、アーティファクトやノイズを除去します。最大出力:32メガピクセル。",
"display_name": "HitPaw 一般画像強化",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "背景除去モデルの読み込み",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "画像から背景を除去するために使用するモデル"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "画像を読み込む",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "ModelNoiseScale",
+ "inputs": {
+ "model": {
+ "name": "モデル"
+ },
+ "noise_scale": {
+ "name": "ノイズスケール",
+ "tooltip": "絶対トレーニングノイズスケール。例:HiDream-O1 base:8.0、dev:7.5。"
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "モデルパッチローダー",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "OpenAIのGPT Imageエンドポイントを使って画像を生成します。",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後のコントロール"
+ },
+ "model": {
+ "name": "モデル"
+ },
+ "model_background": {
+ "name": "背景"
+ },
+ "model_custom_height": {
+ "name": "カスタム高さ"
+ },
+ "model_custom_width": {
+ "name": "カスタム幅"
+ },
+ "model_quality": {
+ "name": "品質"
+ },
+ "model_size": {
+ "name": "サイズ"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "生成する画像の数"
+ },
+ "prompt": {
+ "name": "プロンプト",
+ "tooltip": "GPT Image用のテキストプロンプト"
+ },
+ "seed": {
+ "name": "シード",
+ "tooltip": "バックエンドではまだ未実装"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "OpenAIチャットノードの入力として含める入力ファイル(テキスト、PDFなど)を読み込み、準備します。ファイルはOpenAIモデルによって応答生成時に読み込まれます。🛈 ヒント: 他のOpenAI入力ファイルノードと連結できます。",
"display_name": "OpenAI ChatGPT 入力ファイル",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "背景を除去",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "マスクを生成するために使用する背景除去モデル"
+ },
+ "image": {
+ "name": "画像",
+ "tooltip": "背景を除去する入力画像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "マスク",
+ "tooltip": "生成された前景マスク"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "調整可能なステップごとのノイズを持つLCMサンプラー。s_noiseはモデルの学習ノイズスケールに対する乗数です。",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "ステップごとのノイズを±N×標準偏差に制限します。0で無効化。"
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "最初のステップでのステップごとのノイズ乗数(1.0 = 学習時と同じ)。"
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "最後のステップでのステップごとのノイズ乗数。一定スケジュールにするにはs_noiseと同じ値に設定してください。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "サンプラーLCM拡大",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "このパラメータは非推奨であり、何も行いません。"
},
"texture": {
"name": "texture"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "オーディオ特徴量をビデオモデルに注入する際のスケール。"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "オーディオの長さとビデオフレーム数から計算されたfps。プロンプトで使用されます。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "出力フレーム総数の計算やセグメントオーディオの抽出に使用するオーディオ。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "このセグメントがどれか(最初は0、次は1など)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "このセグメントの長さ(通常は149フレーム)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "パディングされたキーフレームシーケンス"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "有効なフレームを示すマスク"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "このビデオセグメント用のオーディオセグメント"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "各出力セグメント用にスライスするオーディオ。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "リストとして出力するパディング済みセグメントの数。"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "各セグメントの長さ(通常は149フレーム)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "パディングされたキーフレームシーケンス"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "有効なフレームを示すマスク"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "各ビデオセグメント用のオーディオセグメント"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "最初のフレームのCLIP vision埋め込み。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "参照画像のCLIP vision埋め込み。"
+ },
+ "height": {
+ "name": "高さ"
+ },
+ "length": {
+ "name": "長さ",
+ "tooltip": "生成される動画のフレーム数。WanDancerの場合は149のままにしてください。"
+ },
+ "mask": {
+ "name": "マスク",
+ "tooltip": "開始画像の画像処理用マスク。白は保持、黒は生成されます。ローカル生成に使用されます。"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "開始画像",
+ "tooltip": "エンコードする初期画像。任意のフレーム数を指定できます。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "幅"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空のlatent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index c628030434..c21959d27e 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
+ "BACKGROUND_REMOVAL": "배경 제거",
"BOOLEAN": "논리값",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "카메라 제어",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_테스트용",
"advanced": "고급",
"animation": "애니메이션",
- "api": "API",
"api node": "api 노드",
"attention_experiments": "어텐션 실험",
"audio": "오디오",
+ "background removal": "배경 제거",
"batch": "배치",
- "camera": "카메라",
"chroma_radiance": "chroma_radiance",
"clip": "클립",
"color": "색상",
@@ -2301,7 +2300,6 @@
"cond pair": "조건 쌍",
"cond single": "단일 조건",
"conditioning": "조건화",
- "context": "컨텍스트",
"controlnet": "컨트롤넷",
"create": "생성",
"custom_sampling": "사용자 정의 샘플링",
@@ -2310,6 +2308,7 @@
"deprecated": "지원 중단",
"detection": "감지",
"edit_models": "edit_models",
+ "experimental": "실험적",
"flux": "flux",
"gligen": "글리젠",
"guidance": "가이드",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "마스크",
- "math": "수학",
"model": "모델",
"model_merging": "모델 병합",
"model_patches": "모델 패치",
@@ -2342,7 +2340,6 @@
"save": "저장",
"schedulers": "스케줄러",
"scheduling": "스케줄링",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "시그마",
@@ -2350,7 +2347,6 @@
"style_model": "스타일 모델",
"supir": "supir",
"text": "텍스트",
- "textgen": "textgen",
"training": "학습",
"transform": "변환",
"unet": "UNet",
diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json
index 7b97c442a0..a74b0a21c7 100644
--- a/src/locales/ko/nodeDefs.json
+++ b/src/locales/ko/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "batch_size"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "width"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "노이즈 추가",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "최대 4K 해상도에서 통합 텍스트-이미지 생성 및 정밀한 단일 문장 편집.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "생성 후 제어"
+ },
+ "model": {
+ "name": "모델"
+ },
+ "model_fail_on_partial": {
+ "name": "부분 실패 시 중단"
+ },
+ "model_height": {
+ "name": "높이"
+ },
+ "model_max_images": {
+ "name": "최대 이미지 수"
+ },
+ "model_size_preset": {
+ "name": "사이즈 프리셋"
+ },
+ "model_width": {
+ "name": "너비"
+ },
+ "prompt": {
+ "name": "프롬프트",
+ "tooltip": "이미지를 생성하거나 편집하기 위한 텍스트 프롬프트입니다."
+ },
+ "seed": {
+ "name": "시드",
+ "tooltip": "생성에 사용할 시드입니다."
+ },
+ "watermark": {
+ "name": "워터마크",
+ "tooltip": "이미지에 \"AI 생성됨\" 워터마크를 추가할지 여부입니다."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "프롬프트를 기반으로 API를 통해 ByteDance 모델을 사용하여 비디오 생성",
"display_name": "ByteDance 텍스트-비디오",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "HiDream-O1-Image용 빈 픽셀 공간 latent입니다. 모델은 약 4메가픽셀에서 학습되었습니다. 더 낮은 해상도에서는 분포를 벗어나 품질이 눈에 띄게 저하됩니다. 학습된 해상도: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Empty HiDream-O1 Latent Image",
+ "inputs": {
+ "batch_size": {
+ "name": "batch_size"
+ },
+ "height": {
+ "name": "height"
+ },
+ "width": {
+ "name": "width"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "빈 훈위안 이미지 잠재",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "프롬프트와 선택적 참조 이미지를 통해 Flux.2 [pro] 또는 Flux.2 [max]로 이미지 생성.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "생성 후 제어"
+ },
+ "model": {
+ "name": "모델"
+ },
+ "model_height": {
+ "name": "높이"
+ },
+ "model_width": {
+ "name": "너비"
+ },
+ "prompt": {
+ "name": "프롬프트",
+ "tooltip": "이미지 생성 또는 편집을 위한 프롬프트"
+ },
+ "seed": {
+ "name": "시드",
+ "tooltip": "노이즈 생성을 위한 랜덤 시드입니다."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "프롬프트와 해상도에 따라 이미지를 동기적으로 생성합니다.",
"display_name": "Flux.2 [max] 이미지",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "텍스트 프롬프트를 기반으로 기존 이미지를 수정합니다.",
+ "display_name": "Grok 이미지 편집",
+ "inputs": {
+ "control_after_generate": {
+ "name": "생성 후 제어"
+ },
+ "model": {
+ "name": "모델"
+ },
+ "model_aspect_ratio": {
+ "name": "종횡비"
+ },
+ "model_number_of_images": {
+ "name": "이미지 개수"
+ },
+ "model_resolution": {
+ "name": "해상도"
+ },
+ "prompt": {
+ "name": "프롬프트",
+ "tooltip": "이미지 생성을 위해 사용되는 텍스트 프롬프트입니다."
+ },
+ "seed": {
+ "name": "시드",
+ "tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "텍스트 프롬프트를 기반으로 Grok을 사용해 이미지를 생성합니다",
"display_name": "Grok 이미지",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "샘플링 후반부에 여러 번 이동된 패치 그리드 위치에서 모델 출력을 평균화합니다. 이음새를 제거합니다.",
+ "display_name": "HiDream-O1 Patch Seam Smoothing",
+ "inputs": {
+ "blend": {
+ "name": "blend",
+ "tooltip": "average: 동일 가중치 평균. window: 각 패스가 패치 경계에서 멀어질수록 가중치를 주는 Hann 윈도우 가중치. median: 픽셀별 중앙값, wraparound-outlier 패스는 제외."
+ },
+ "end_percent": {
+ "name": "end_percent",
+ "tooltip": "블렌드가 꺼지는 샘플링 진행도."
+ },
+ "model": {
+ "name": "model"
+ },
+ "passes": {
+ "name": "passes",
+ "tooltip": "게이트 단계별 패스 수. 2/4 = 고정. ramp_*: 샘플링이 끝에 가까워질수록 패스 수 증가(이음새가 가장 잘 보이는 곳에서 더 부드럽게 처리)."
+ },
+ "pattern": {
+ "name": "pattern",
+ "tooltip": "이동 레이아웃. single_shift: 자연스러운 패치 그리드에서 한 번, 나머지는 오프셋. symmetric: 모든 패스가 그리드에서 벗어나며, 이동이 원점을 중심으로 분할됨."
+ },
+ "start_percent": {
+ "name": "start_percent",
+ "tooltip": "블렌드가 켜지는 샘플링 진행도 (0=시작, 1=끝)."
+ },
+ "strength": {
+ "name": "strength",
+ "tooltip": "자연스러운 그리드 예측(0)과 평균 결과(1) 사이의 보간."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "컨디셔닝에 1~10개의 참조 이미지를 첨부합니다. 1개는 편집 지시, 여러 개는 주제 기반 개인화에 사용됩니다.",
+ "display_name": "HiDream-O1 Reference Images",
+ "inputs": {
+ "images": {
+ "name": "images",
+ "tooltip": "참조 이미지. 1개 = 편집 지시; 2~10개 = 다중 참조."
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "저해상도 이미지를 초고해상도로 업스케일하고, 아티팩트와 노이즈를 제거합니다. 최대 출력: 32 메가픽셀.",
"display_name": "HitPaw 일반 이미지 향상",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "배경 제거 모델 불러오기",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "이미지에서 배경을 제거하는 데 사용되는 모델"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "이미지 로드",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "ModelNoiseScale",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "noise_scale": {
+ "name": "noise_scale",
+ "tooltip": "절대 학습 노이즈 스케일. 예시 HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "모델 패치 로더",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "OpenAI의 GPT 이미지 엔드포인트를 통해 이미지를 생성합니다.",
+ "display_name": "OpenAI GPT 이미지 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "생성 후 제어"
+ },
+ "model": {
+ "name": "모델"
+ },
+ "model_background": {
+ "name": "배경"
+ },
+ "model_custom_height": {
+ "name": "사용자 지정 높이"
+ },
+ "model_custom_width": {
+ "name": "사용자 지정 너비"
+ },
+ "model_quality": {
+ "name": "품질"
+ },
+ "model_size": {
+ "name": "사이즈"
+ },
+ "n": {
+ "name": "개수",
+ "tooltip": "생성할 이미지 수"
+ },
+ "prompt": {
+ "name": "프롬프트",
+ "tooltip": "GPT 이미지용 텍스트 프롬프트"
+ },
+ "seed": {
+ "name": "시드",
+ "tooltip": "백엔드에서 아직 구현되지 않음"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "OpenAI 채팅 노드에 대한 입력으로 포함할 입력 파일(텍스트, PDF 등)을 로드하고 준비합니다. 파일은 OpenAI 모델이 응답을 생성할 때 읽힙니다. 🛈 팁: 다른 OpenAI 입력 파일 노드와 함께 연결하여 사용할 수 있습니다.",
"display_name": "OpenAI ChatGPT 입력 파일",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "배경 제거",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "마스크 생성을 위해 사용되는 배경 제거 모델"
+ },
+ "image": {
+ "name": "image",
+ "tooltip": "배경을 제거할 입력 이미지"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "생성된 전경 마스크"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "조정 가능한 단계별 노이즈를 가진 LCM 샘플러입니다. s_noise는 모델의 학습 노이즈 스케일에 곱해지는 값입니다.",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "단계별 노이즈를 +/- N*표준편차로 제한합니다. 0은 비활성화입니다."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "첫 번째 단계에서의 단계별 노이즈 배수 (1.0 = 학습과 동일)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "마지막 단계에서의 단계별 노이즈 배수. 일정한 스케줄을 원하면 s_noise와 동일하게 설정하세요."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "LCM 확대 샘플러",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "PBR"
},
"quad": {
- "name": "쿼드"
+ "name": "쿼드",
+ "tooltip": "이 매개변수는 더 이상 사용되지 않으며 아무런 동작도 하지 않습니다."
},
"texture": {
"name": "텍스처"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "오디오 특징을 비디오 모델에 주입할 때의 스케일입니다."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "오디오 길이와 비디오 프레임 수를 기반으로 계산된 fps입니다. 프롬프트에 사용됩니다."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "총 출력 프레임 계산 및 구간 오디오 추출에 사용할 오디오입니다."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "이 구간이 몇 번째인지 (첫 번째는 0, 두 번째는 1 등)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "이 구간의 길이 (보통 149 프레임)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "패딩된 키프레임 시퀀스"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "유효한 프레임을 나타내는 마스크"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "이 비디오 구간에 해당하는 오디오 구간"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "각 출력 구간에 맞게 오디오를 분할합니다."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "리스트로 출력할 패딩된 구간의 개수"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "각 구간의 길이 (보통 149 프레임)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "패딩된 키프레임 시퀀스들"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "유효한 프레임을 나타내는 마스크들"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "각 비디오 구간에 해당하는 오디오 구간"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "첫 번째 프레임의 CLIP 비전 임베딩입니다."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "참조 이미지의 CLIP 비전 임베딩입니다."
+ },
+ "height": {
+ "name": "높이"
+ },
+ "length": {
+ "name": "길이",
+ "tooltip": "생성된 비디오의 프레임 수입니다. WanDancer의 경우 149로 유지해야 합니다."
+ },
+ "mask": {
+ "name": "마스크",
+ "tooltip": "시작 이미지(들)에 대한 이미지 조건 마스크입니다. 흰색은 유지되고, 검은색은 생성됩니다. 로컬 생성에 사용됩니다."
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "시작 이미지",
+ "tooltip": "인코딩할 초기 이미지(들)입니다. 프레임 수는 자유롭게 설정할 수 있습니다."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "너비"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "비어 있는 latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WAN 비디오 생성 (시작-끝 프레임)",
"inputs": {
diff --git a/src/locales/localeConfig.ts b/src/locales/localeConfig.ts
new file mode 100644
index 0000000000..72359835c5
--- /dev/null
+++ b/src/locales/localeConfig.ts
@@ -0,0 +1,82 @@
+type LocaleJsonLoader = () => Promise<{
+ default: Record
+}>
+
+type LocaleLoaderBundle = {
+ main: LocaleJsonLoader
+ nodeDefs: LocaleJsonLoader
+ commands: LocaleJsonLoader
+ settings: LocaleJsonLoader
+}
+
+type LocaleDefinition = {
+ text: string
+ loaders: LocaleLoaderBundle | null
+}
+
+// Vite code-splits each matched module into its own async chunk; only the
+// resolved locale's bundle is fetched at runtime.
+const localeFiles = import.meta.glob<{ default: Record }>(
+ './*/{main,nodeDefs,commands,settings}.json'
+)
+
+function loadersFor(locale: string): LocaleLoaderBundle {
+ return {
+ main: localeFiles[`./${locale}/main.json`],
+ nodeDefs: localeFiles[`./${locale}/nodeDefs.json`],
+ commands: localeFiles[`./${locale}/commands.json`],
+ settings: localeFiles[`./${locale}/settings.json`]
+ }
+}
+
+export const localeDefinitions = {
+ en: { text: 'English', loaders: null },
+ zh: { text: '中文', loaders: loadersFor('zh') },
+ 'zh-TW': { text: '繁體中文', loaders: loadersFor('zh-TW') },
+ ru: { text: 'Русский', loaders: loadersFor('ru') },
+ ja: { text: '日本語', loaders: loadersFor('ja') },
+ ko: { text: '한국어', loaders: loadersFor('ko') },
+ fr: { text: 'Français', loaders: loadersFor('fr') },
+ es: { text: 'Español', loaders: loadersFor('es') },
+ ar: { text: 'عربي', loaders: loadersFor('ar') },
+ tr: { text: 'Türkçe', loaders: loadersFor('tr') },
+ 'pt-BR': { text: 'Português (BR)', loaders: loadersFor('pt-BR') },
+ fa: { text: 'فارسی', loaders: loadersFor('fa') }
+} as const satisfies Record
+
+export type SupportedLocale = keyof typeof localeDefinitions
+
+const SUPPORTED_LOCALES = Object.keys(localeDefinitions) as SupportedLocale[]
+
+export const SUPPORTED_LOCALE_OPTIONS = SUPPORTED_LOCALES.map((value) => ({
+ value,
+ text: localeDefinitions[value].text
+}))
+
+const supportedLocaleByLower = new Map(
+ SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])
+)
+
+function matchSingle(candidate: string): SupportedLocale | undefined {
+ const normalized = candidate.toLowerCase()
+ return (
+ supportedLocaleByLower.get(normalized) ??
+ supportedLocaleByLower.get(normalized.split('-')[0])
+ )
+}
+
+export function resolveSupportedLocale(
+ input?: string | readonly string[] | null
+): SupportedLocale {
+ const candidates = Array.isArray(input) ? input : input ? [input] : []
+ for (const candidate of candidates) {
+ if (!candidate) continue
+ const matched = matchSingle(candidate)
+ if (matched) return matched
+ }
+ return 'en'
+}
+
+export function getDefaultLocale(): SupportedLocale {
+ return resolveSupportedLocale(navigator.languages)
+}
diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json
index 0c453e0c7f..a6b50bcb04 100644
--- a/src/locales/pt-BR/main.json
+++ b/src/locales/pt-BR/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "CODIFICADOR DE ÁUDIO",
"AUDIO_ENCODER_OUTPUT": "SAÍDA DO CODIFICADOR DE ÁUDIO",
"AUDIO_RECORD": "GRAVAÇÃO DE ÁUDIO",
+ "BACKGROUND_REMOVAL": "REMOÇÃO_DE_FUNDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CONTROLE DE CÂMERA",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "avançado",
"animation": "animação",
- "api": "api",
"api node": "nó da API",
"attention_experiments": "experimentos_de_atenção",
"audio": "áudio",
+ "background removal": "remoção de fundo",
"batch": "lote",
- "camera": "câmera",
"chroma_radiance": "radiância_de_croma",
"clip": "clip",
"color": "cor",
@@ -2301,7 +2300,6 @@
"cond pair": "par_de_condições",
"cond single": "condição_simples",
"conditioning": "condicionamento",
- "context": "contexto",
"controlnet": "controlnet",
"create": "criar",
"custom_sampling": "amostragem_personalizada",
@@ -2310,6 +2308,7 @@
"deprecated": "obsoleto",
"detection": "detecção",
"edit_models": "editar_modelos",
+ "experimental": "experimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "orientação",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "máscara",
- "math": "matemática",
"model": "modelo",
"model_merging": "mesclagem_de_modelos",
"model_patches": "correções_de_modelo",
@@ -2342,7 +2340,6 @@
"save": "salvar",
"schedulers": "agendadores",
"scheduling": "agendamento",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
- "textgen": "textgen",
"training": "treinamento",
"transform": "transformar",
"unet": "unet",
diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json
index 599126ee22..5a0e027249 100644
--- a/src/locales/pt-BR/nodeDefs.json
+++ b/src/locales/pt-BR/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "tamanho_do_lote"
+ },
+ "height": {
+ "name": "altura"
+ },
+ "length": {
+ "name": "duração"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "start_image": {
+ "name": "imagem_inicial"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largura"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AddNoise",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "Geração unificada de texto para imagem e edição precisa de frases únicas em até 4K de resolução.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "controle após gerar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_fail_on_partial": {
+ "name": "falhar_em_parcial"
+ },
+ "model_height": {
+ "name": "altura"
+ },
+ "model_max_images": {
+ "name": "máx_imagens"
+ },
+ "model_size_preset": {
+ "name": "predefinição_de_tamanho"
+ },
+ "model_width": {
+ "name": "largura"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt de texto para criar ou editar uma imagem."
+ },
+ "seed": {
+ "name": "semente",
+ "tooltip": "Semente utilizada para a geração."
+ },
+ "watermark": {
+ "name": "marca d'água",
+ "tooltip": "Se deve adicionar uma marca d'água \"Gerado por IA\" à imagem."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "Gere vídeo usando modelos ByteDance via API com base no prompt",
"display_name": "ByteDance Texto para Vídeo",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "Latente no espaço de pixels vazia para HiDream-O1-Image. O modelo foi treinado em ~4 megapixels; resoluções menores saem da distribuição e a qualidade regrede visivelmente. Resoluções treinadas: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Imagem Latente Vazia HiDream-O1",
+ "inputs": {
+ "batch_size": {
+ "name": "tamanho_do_lote"
+ },
+ "height": {
+ "name": "altura"
+ },
+ "width": {
+ "name": "largura"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Gere imagens via Flux.2 [pro] ou Flux.2 [max] a partir de um prompt e imagens de referência opcionais.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "controle após gerar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_height": {
+ "name": "altura"
+ },
+ "model_width": {
+ "name": "largura"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt para a geração ou edição da imagem"
+ },
+ "seed": {
+ "name": "semente",
+ "tooltip": "A semente aleatória usada para criar o ruído."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "Gera imagens de forma síncrona com base no prompt e na resolução.",
"display_name": "Flux.2 [max] Imagem",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "Modifique uma imagem existente com base em um prompt de texto",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "controle após gerar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_aspect_ratio": {
+ "name": "proporção"
+ },
+ "model_number_of_images": {
+ "name": "número_de_imagens"
+ },
+ "model_resolution": {
+ "name": "resolução"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "O prompt de texto usado para gerar a imagem"
+ },
+ "seed": {
+ "name": "semente",
+ "tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "Gere imagens usando Grok com base em um prompt de texto",
"display_name": "Grok Image",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "Média da saída do modelo em várias posições de grade de patch deslocadas durante a fase final da amostragem. Cancela as emendas.",
+ "display_name": "Suavização de Emendas de Patches HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "mesclagem",
+ "tooltip": "average: média com pesos iguais. window: ponderação com janela de Hann favorecendo cada passagem longe dos limites do patch. median: mediana por pixel, rejeita passagens fora do padrão."
+ },
+ "end_percent": {
+ "name": "percentual_final",
+ "tooltip": "Progresso da amostragem em que a mesclagem é desativada."
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "passes": {
+ "name": "passagens",
+ "tooltip": "Número de passagens por etapa controlada. 2/4 = fixo. ramp_*: a contagem de passagens aumenta conforme a amostragem se aproxima do fim (mais suavização onde as emendas são mais visíveis)."
+ },
+ "pattern": {
+ "name": "padrão",
+ "tooltip": "Layout do deslocamento. single_shift: uma passagem na grade de patch natural + outras deslocadas. symmetric: todas as passagens fora da grade, deslocamentos divididos ao redor da origem."
+ },
+ "start_percent": {
+ "name": "percentual_inicial",
+ "tooltip": "Progresso da amostragem (0=início, 1=fim) em que a mesclagem é ativada."
+ },
+ "strength": {
+ "name": "força",
+ "tooltip": "Interpolação entre a predição da grade natural (0) e o resultado médio (1)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "Anexe de 1 a 10 imagens de referência ao condicionamento, uma para instrução de edição ou múltiplas para personalização orientada por sujeito.",
+ "display_name": "Imagens de Referência HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "imagens",
+ "tooltip": "Imagens de referência. 1 imagem = edição por instrução; 2-10 imagens = múltiplas referências."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "Aumente imagens de baixa resolução para super-resolução, eliminando artefatos e ruídos. Saída máxima: 32 megapixels.",
"display_name": "HitPaw General Image Enhance",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Carregar Modelo de Remoção de Fundo",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nome_remoção_fundo",
+ "tooltip": "O modelo usado para remover fundos de imagens"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modelo_fundo",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Carregar Imagem",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "Escala de Ruído do Modelo",
+ "inputs": {
+ "model": {
+ "name": "modelo"
+ },
+ "noise_scale": {
+ "name": "escala_de_ruído",
+ "tooltip": "Escala absoluta de ruído de treinamento. Por exemplo HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "Gera imagens via endpoint GPT Image da OpenAI.",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "controle após gerar"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "model_background": {
+ "name": "fundo"
+ },
+ "model_custom_height": {
+ "name": "altura_personalizada"
+ },
+ "model_custom_width": {
+ "name": "largura_personalizada"
+ },
+ "model_quality": {
+ "name": "qualidade"
+ },
+ "model_size": {
+ "name": "tamanho"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "Quantas imagens gerar"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Prompt de texto para o GPT Image"
+ },
+ "seed": {
+ "name": "semente",
+ "tooltip": "ainda não implementado no backend"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "Carrega e prepara arquivos de entrada (texto, pdf, etc.) para incluir como entradas no Nó de Chat da OpenAI. Os arquivos serão lidos pelo modelo OpenAI ao gerar uma resposta. 🛈 DICA: Pode ser encadeado com outros nós de Arquivo de Entrada OpenAI.",
"display_name": "Arquivos de Entrada do OpenAI ChatGPT",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Remover Fundo",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modelo_remoção_fundo",
+ "tooltip": "Modelo de remoção de fundo usado para gerar a máscara"
+ },
+ "image": {
+ "name": "imagem",
+ "tooltip": "Imagem de entrada para remover o fundo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "máscara",
+ "tooltip": "Máscara de primeiro plano gerada"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "Amostrador LCM com ruído ajustável por etapa. s_noise é um multiplicador na escala de ruído de treinamento do modelo",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Limita o ruído por etapa a +/- N*desvio padrão. 0 desativa."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "Multiplicador de ruído por etapa no primeiro passo (1.0 = igual ao treinamento)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Multiplicador de ruído por etapa no último passo. Defina igual ao s_noise para um agendamento constante."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "Este parâmetro está obsoleto e não faz nada."
},
"texture": {
"name": "textura"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "áudio"
+ },
+ "audio_inject_scale": {
+ "name": "escala_de_injeção_de_áudio",
+ "tooltip": "A escala para as características do áudio ao serem injetadas no modelo de vídeo."
+ },
+ "video_frames": {
+ "name": "quadros_de_vídeo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "saída_do_codificador_de_áudio",
+ "tooltip": null
+ },
+ "1": {
+ "name": "string_fps",
+ "tooltip": "O fps calculado com base na duração do áudio e no número de quadros de vídeo. Usado no prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "áudio",
+ "tooltip": "Áudio para calcular o total de quadros de saída e extrair o áudio do segmento."
+ },
+ "images": {
+ "name": "imagens"
+ },
+ "segment_index": {
+ "name": "índice_do_segmento",
+ "tooltip": "Qual segmento é este (0 para o primeiro, 1 para o segundo, etc.)"
+ },
+ "segment_length": {
+ "name": "comprimento_do_segmento",
+ "tooltip": "Comprimento deste segmento (geralmente 149 quadros)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "sequência_de_keyframes",
+ "tooltip": "Sequência de keyframes preenchida"
+ },
+ "1": {
+ "name": "máscara_de_keyframes",
+ "tooltip": "Máscara indicando quadros válidos"
+ },
+ "2": {
+ "name": "segmento_de_áudio",
+ "tooltip": "Segmento de áudio para este segmento de vídeo"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "áudio",
+ "tooltip": "Áudio a ser dividido para cada segmento emitido."
+ },
+ "images": {
+ "name": "imagens"
+ },
+ "num_segments": {
+ "name": "número_de_segmentos",
+ "tooltip": "Quantos segmentos preenchidos emitir como listas."
+ },
+ "segment_length": {
+ "name": "comprimento_do_segmento",
+ "tooltip": "Comprimento de cada segmento (geralmente 149 quadros)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "sequências_de_keyframes",
+ "tooltip": "Sequências de keyframes preenchidas"
+ },
+ "1": {
+ "name": "máscaras_de_keyframes",
+ "tooltip": "Máscaras indicando quadros válidos"
+ },
+ "2": {
+ "name": "segmento_de_áudio",
+ "tooltip": "Segmento de áudio para cada segmento de vídeo"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Os embeddings de visão do CLIP para o primeiro quadro."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Os embeddings de visão do CLIP para a imagem de referência."
+ },
+ "height": {
+ "name": "altura"
+ },
+ "length": {
+ "name": "duração",
+ "tooltip": "O número de quadros no vídeo gerado. Deve permanecer 149 para WanDancer."
+ },
+ "mask": {
+ "name": "máscara",
+ "tooltip": "Máscara de condicionamento de imagem para a(s) imagem(ns) inicial(is). Branco é mantido, preto é gerado. Usado para as gerações locais."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ },
+ "start_image": {
+ "name": "imagem_inicial",
+ "tooltip": "A(s) imagem(ns) inicial(is) a serem codificadas, pode ser qualquer quantidade de quadros."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largura"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latente",
+ "tooltip": "Latente vazio."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index 1b954b9dba..cd220e8a9f 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "АУДИО_КОДЕР",
"AUDIO_ENCODER_OUTPUT": "ВЫХОД_АУДИО_КОДЕРА",
"AUDIO_RECORD": "АУДИО_ЗАПИСЬ",
+ "BACKGROUND_REMOVAL": "УДАЛЕНИЕ_ФОНА",
"BOOLEAN": "БУЛЕВО",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "УПРАВЛЕНИЕ_КАМЕРОЙ",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_для_тестирования",
"advanced": "расширенный",
"animation": "анимация",
- "api": "api",
"api node": "api-узел",
"attention_experiments": "эксперименты_внимания",
"audio": "аудио",
+ "background removal": "удаление фона",
"batch": "пакет",
- "camera": "камера",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "цвет",
@@ -2301,7 +2300,6 @@
"cond pair": "условие_пара",
"cond single": "условие_одиночное",
"conditioning": "условие",
- "context": "контекст",
"controlnet": "controlnet",
"create": "создать",
"custom_sampling": "пользовательский_семплинг",
@@ -2310,6 +2308,7 @@
"deprecated": "устаревший",
"detection": "детекция",
"edit_models": "редактировать_модели",
+ "experimental": "экспериментальное",
"flux": "flux",
"gligen": "gligen",
"guidance": "направление",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "маска",
- "math": "математика",
"model": "модель",
"model_merging": "слияние_моделей",
"model_patches": "патчи_моделей",
@@ -2342,7 +2340,6 @@
"save": "сохранить",
"schedulers": "schedulers",
"scheduling": "scheduling",
- "sd": "sd",
"sd3": "sd3",
"shader": "шейдер",
"sigmas": "сигмы",
@@ -2350,7 +2347,6 @@
"style_model": "модель_стиля",
"supir": "supir",
"text": "текст",
- "textgen": "textgen",
"training": "обучение",
"transform": "преобразование",
"unet": "unet",
diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json
index 20d9ea08e1..f4d5310e1b 100644
--- a/src/locales/ru/nodeDefs.json
+++ b/src/locales/ru/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "размер_пакета"
+ },
+ "height": {
+ "name": "высота"
+ },
+ "length": {
+ "name": "длина"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ширина"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "Добавить шум",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "Унифицированная генерация изображений по тексту и точное редактирование по одному предложению с разрешением до 4K.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_fail_on_partial": {
+ "name": "fail_on_partial"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_max_images": {
+ "name": "max_images"
+ },
+ "model_size_preset": {
+ "name": "size_preset"
+ },
+ "model_width": {
+ "name": "width"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Текстовый запрос для создания или редактирования изображения."
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Сид для генерации."
+ },
+ "watermark": {
+ "name": "watermark",
+ "tooltip": "Добавлять ли водяной знак «AI generated» на изображение."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "Создать видео с использованием моделей ByteDance через API на основе промпта",
"display_name": "ByteDance Текст в Видео",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "Пустое латентное пространство пикселей для HiDream-O1-Image. Модель обучалась на разрешении около 4 мегапикселей; при меньших разрешениях качество заметно ухудшается и выходит за пределы распределения. Обученные разрешения: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Пустое HiDream-O1 латентное изображение",
+ "inputs": {
+ "batch_size": {
+ "name": "размер_пакета"
+ },
+ "height": {
+ "name": "высота"
+ },
+ "width": {
+ "name": "ширина"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Генерация изображений с помощью Flux.2 [pro] или Flux.2 [max] по текстовому запросу и, при необходимости, референсным изображениям.",
+ "display_name": "Flux.2 Image",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_height": {
+ "name": "height"
+ },
+ "model_width": {
+ "name": "width"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Запрос для генерации или редактирования изображения"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Случайный сид, используемый для создания шума."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "Генерирует изображения синхронно на основе запроса и разрешения.",
"display_name": "Flux.2 [max] Image",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "Изменение существующего изображения на основе текстового запроса",
+ "display_name": "Grok Image Edit",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_aspect_ratio": {
+ "name": "aspect_ratio"
+ },
+ "model_number_of_images": {
+ "name": "number_of_images"
+ },
+ "model_resolution": {
+ "name": "resolution"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Текстовый запрос, используемый для генерации изображения"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты не детерминированы независимо от сида."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "Генерировать изображения с помощью Grok на основе текстового запроса",
"display_name": "Grok Image",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "Усредняет выход модели по нескольким смещённым позициям сетки патчей на поздних этапах сэмплирования. Устраняет швы.",
+ "display_name": "Сглаживание швов патчей HiDream-O1",
+ "inputs": {
+ "blend": {
+ "name": "смешивание",
+ "tooltip": "average: среднее арифметическое. window: взвешивание по окну Ханна, отдавая предпочтение проходам вдали от границ патчей. median: медиана по каждому пикселю, отбрасывает выбросы на границах."
+ },
+ "end_percent": {
+ "name": "конечный_процент",
+ "tooltip": "Прогресс сэмплирования, при котором смешивание выключается."
+ },
+ "model": {
+ "name": "модель"
+ },
+ "passes": {
+ "name": "проходы",
+ "tooltip": "Количество проходов на каждом этапе. 2/4 = фиксировано. ramp_*: количество проходов увеличивается к концу сэмплирования (больше сглаживания там, где швы заметнее)."
+ },
+ "pattern": {
+ "name": "шаблон",
+ "tooltip": "Схема смещения. single_shift: один проход по естественной сетке патчей + другие со смещением. symmetric: все проходы вне сетки, смещения симметричны относительно центра."
+ },
+ "start_percent": {
+ "name": "начальный_процент",
+ "tooltip": "Прогресс сэмплирования (0=начало, 1=конец), при котором включается смешивание."
+ },
+ "strength": {
+ "name": "интенсивность",
+ "tooltip": "Интерполяция между предсказанием по естественной сетке (0) и усреднённым результатом (1)."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "Прикрепите 1-10 референсных изображений для кондиционирования: одно для редактирования по инструкции или несколько для персонализации по объекту.",
+ "display_name": "Референсные изображения HiDream-O1",
+ "inputs": {
+ "images": {
+ "name": "изображения",
+ "tooltip": "Референсные изображения. 1 изображение = редактирование по инструкции; 2-10 изображений = мульти-референс."
+ },
+ "negative": {
+ "name": "отрицательный"
+ },
+ "positive": {
+ "name": "положительный"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "положительный",
+ "tooltip": null
+ },
+ "1": {
+ "name": "отрицательный",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "Увеличение разрешения низкокачественных изображений до сверхвысокого, устранение артефактов и шума. Максимальный выход: 32 мегапикселя.",
"display_name": "HitPaw General Image Enhance",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Загрузить модель удаления фона",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "Модель, используемая для удаления фона с изображений"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Загрузить изображение",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "Масштаб шума модели",
+ "inputs": {
+ "model": {
+ "name": "модель"
+ },
+ "noise_scale": {
+ "name": "масштаб_шума",
+ "tooltip": "Абсолютный масштаб шума обучения. Например, HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "Генерирует изображения через конечную точку OpenAI GPT Image.",
+ "display_name": "OpenAI GPT Image 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "control after generate"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_background": {
+ "name": "background"
+ },
+ "model_custom_height": {
+ "name": "custom_height"
+ },
+ "model_custom_width": {
+ "name": "custom_width"
+ },
+ "model_quality": {
+ "name": "quality"
+ },
+ "model_size": {
+ "name": "size"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "Сколько изображений сгенерировать"
+ },
+ "prompt": {
+ "name": "prompt",
+ "tooltip": "Текстовый запрос для GPT Image"
+ },
+ "seed": {
+ "name": "seed",
+ "tooltip": "еще не реализовано на сервере"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "Загружает и подготавливает входные файлы (текст, pdf и т.д.) для включения в качестве входных данных для узла Чат OpenAI. Файлы будут прочитаны моделью OpenAI при генерации ответа. 🛈 СОВЕТ: Может быть объединен в цепочку с другими узлами Входных файлов OpenAI.",
"display_name": "Файлы ввода OpenAI ChatGPT",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Удалить фон",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "Модель удаления фона, используемая для создания маски"
+ },
+ "image": {
+ "name": "изображение",
+ "tooltip": "Входное изображение для удаления фона"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Сгенерированная маска переднего плана"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "LCM-семплер с настраиваемым уровнем шума на каждом шаге. s_noise — это множитель масштаба шума обучения модели",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Ограничить шум на каждом шаге до +/- N*std. 0 — отключить."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "Множитель шума на первом шаге (1.0 = соответствует обучению)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Множитель шума на последнем шаге. Установите равным s_noise для постоянного расписания."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "Сэмплер LCM Upscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "PBR"
},
"quad": {
- "name": "квад"
+ "name": "квад",
+ "tooltip": "Этот параметр устарел и больше не используется."
},
"texture": {
"name": "текстура"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "Масштаб аудиофич при внедрении в видеомодель."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Вычисленный fps на основе длины аудио и количества видеокадров. Используется в prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Аудио для расчёта общего количества выходных кадров и извлечения сегмента аудио."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Какой это сегмент (0 — первый, 1 — второй и т.д.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Длина этого сегмента (обычно 149 кадров)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Дополненная последовательность ключевых кадров"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Маска, указывающая валидные кадры"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Аудиосегмент для этого видеосегмента"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Аудио для нарезки для каждого выдаваемого сегмента."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Сколько дополненных сегментов выдавать в виде списков."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Длина каждого сегмента (обычно 149 кадров)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Дополненные последовательности ключевых кадров"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Маски, указывающие валидные кадры"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Аудиосегмент для каждого видеосегмента"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Визуальные эмбеддинги CLIP для первого кадра."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Визуальные эмбеддинги CLIP для референсного изображения."
+ },
+ "height": {
+ "name": "высота"
+ },
+ "length": {
+ "name": "длина",
+ "tooltip": "Количество кадров в сгенерированном видео. Для WanDancer должно оставаться 149."
+ },
+ "mask": {
+ "name": "маска",
+ "tooltip": "Маска для обработки изображения начального кадра(ов). Белое сохраняется, черное генерируется. Используется для локальной генерации."
+ },
+ "negative": {
+ "name": "негативный"
+ },
+ "positive": {
+ "name": "позитивный"
+ },
+ "start_image": {
+ "name": "начальное изображение",
+ "tooltip": "Исходное изображение(я) для кодирования, может быть любое количество кадров."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ширина"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "позитивный",
+ "tooltip": null
+ },
+ "1": {
+ "name": "негативный",
+ "tooltip": null
+ },
+ "2": {
+ "name": "латентный",
+ "tooltip": "Пустое латентное пространство."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json
index f4a109a759..64c60916e0 100644
--- a/src/locales/tr/main.json
+++ b/src/locales/tr/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "SES_KODLAYICI",
"AUDIO_ENCODER_OUTPUT": "SES_KODLAYICI_ÇIKIŞI",
"AUDIO_RECORD": "SES_KAYDI",
+ "BACKGROUND_REMOVAL": "ARKA_PLAN_KALDIRMA",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "KAMERA_KONTROL",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_test_için",
"advanced": "gelişmiş",
"animation": "animasyon",
- "api": "api",
"api node": "api düğümü",
"attention_experiments": "dikkat_deneyleri",
"audio": "ses",
+ "background removal": "arka plan kaldırma",
"batch": "toplu",
- "camera": "kamera",
"chroma_radiance": "chroma_radiance",
"clip": "klip",
"color": "renk",
@@ -2301,7 +2300,6 @@
"cond pair": "çift koşul",
"cond single": "tek koşul",
"conditioning": "koşullandırma",
- "context": "bağlam",
"controlnet": "controlnet",
"create": "oluştur",
"custom_sampling": "özel_örnekleme",
@@ -2310,6 +2308,7 @@
"deprecated": "kullanımdan kaldırılmış",
"detection": "tespit",
"edit_models": "modelleri_düzenle",
+ "experimental": "deneysel",
"flux": "flux",
"gligen": "gligen",
"guidance": "rehberlik",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "maske",
- "math": "matematik",
"model": "model",
"model_merging": "model_birleştirme",
"model_patches": "model_yamaları",
@@ -2342,7 +2340,6 @@
"save": "kaydet",
"schedulers": "zamanlayıcılar",
"scheduling": "zamanlama",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmalar",
@@ -2350,7 +2347,6 @@
"style_model": "stil_modeli",
"supir": "supir",
"text": "metin",
- "textgen": "textgen",
"training": "eğitim",
"transform": "dönüştür",
"unet": "unet",
diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json
index 96295d3b28..837d7f3ec4 100644
--- a/src/locales/tr/nodeDefs.json
+++ b/src/locales/tr/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "toplu_boyut"
+ },
+ "height": {
+ "name": "yükseklik"
+ },
+ "length": {
+ "name": "uzunluk"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "başlangıç_görseli"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "genişlik"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "Gürültü Ekle",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "Birleştirilmiş metinden-görüntüye üretim ve 4K çözünürlüğe kadar hassas tek cümle düzenleme.",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "oluşturduktan sonra kontrol"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_fail_on_partial": {
+ "name": "kısmi başarısızlıkta dur"
+ },
+ "model_height": {
+ "name": "yükseklik"
+ },
+ "model_max_images": {
+ "name": "maksimum_görüntü"
+ },
+ "model_size_preset": {
+ "name": "boyut_önayarı"
+ },
+ "model_width": {
+ "name": "genişlik"
+ },
+ "prompt": {
+ "name": "istem",
+ "tooltip": "Bir görüntü oluşturmak veya düzenlemek için metin istemi."
+ },
+ "seed": {
+ "name": "tohum",
+ "tooltip": "Üretim için kullanılacak tohum."
+ },
+ "watermark": {
+ "name": "filigran",
+ "tooltip": "Görüntüye \"Yapay Zeka ile oluşturuldu\" filigranı eklenip eklenmeyeceği."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "Prompt'a dayalı olarak api üzerinden ByteDance modellerini kullanarak video oluştur",
"display_name": "ByteDance Metinden Videoya",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "HiDream-O1-Image için boş piksel-uzayı latent. Model yaklaşık 4 megapikselde eğitildi; daha düşük çözünürlüklerde dağılım dışına çıkar ve kalite belirgin şekilde düşer. Eğitildiği çözünürlükler: 2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, 2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304.",
+ "display_name": "Boş HiDream-O1 Latent Görüntü",
+ "inputs": {
+ "batch_size": {
+ "name": "batch_size"
+ },
+ "height": {
+ "name": "yükseklik"
+ },
+ "width": {
+ "name": "genişlik"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "Boş Hunyuan Görüntü Gizli",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "Bir istem ve isteğe bağlı referans görüntülerden Flux.2 [pro] veya Flux.2 [max] ile görüntüler oluşturun.",
+ "display_name": "Flux.2 Görüntü",
+ "inputs": {
+ "control_after_generate": {
+ "name": "oluşturduktan sonra kontrol"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_height": {
+ "name": "yükseklik"
+ },
+ "model_width": {
+ "name": "genişlik"
+ },
+ "prompt": {
+ "name": "istem",
+ "tooltip": "Görüntü oluşturma veya düzenleme için istem"
+ },
+ "seed": {
+ "name": "tohum",
+ "tooltip": "Gürültü oluşturmak için kullanılan rastgele tohum."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "İstem ve çözünürlüğe göre senkron bir şekilde görseller üretir.",
"display_name": "Flux.2 [max] Görsel",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "Mevcut bir görüntüyü bir metin istemine göre değiştirin",
+ "display_name": "Grok Görüntü Düzenleme",
+ "inputs": {
+ "control_after_generate": {
+ "name": "oluşturduktan sonra kontrol"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_aspect_ratio": {
+ "name": "en-boy_oranı"
+ },
+ "model_number_of_images": {
+ "name": "görüntü_sayısı"
+ },
+ "model_resolution": {
+ "name": "çözünürlük"
+ },
+ "prompt": {
+ "name": "istem",
+ "tooltip": "Görüntü oluşturmak için kullanılan metin istemi"
+ },
+ "seed": {
+ "name": "tohum",
+ "tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirlemek için tohum; gerçek sonuçlar tohumdan bağımsız olarak belirlenemezdir."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "Bir metin istemine göre Grok ile görüntüler oluştur",
"display_name": "Grok Görüntü",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "Örneklemenin son kısmında model çıktısını birden fazla kaydırılmış yama-ızgara pozisyonunda ortalar. Dikişleri yok eder.",
+ "display_name": "HiDream-O1 Yama Dikiş Yumuşatma",
+ "inputs": {
+ "blend": {
+ "name": "karıştırma",
+ "tooltip": "average: eşit ağırlıklı ortalama. window: her geçişi yama sınırlarından uzaklaştıran Hann-pencereli ağırlıklandırma. median: piksel başına medyan, sarmalayan-aşırı geçişleri reddeder."
+ },
+ "end_percent": {
+ "name": "bitiş_yüzdesi",
+ "tooltip": "Karışımın KAPANDIĞI örnekleme ilerlemesi."
+ },
+ "model": {
+ "name": "model"
+ },
+ "passes": {
+ "name": "geçişler",
+ "tooltip": "Her adımda geçiş sayısı. 2/4 = sabit. ramp_*: örnekleme sona yaklaştıkça geçiş sayısı artar (dikişlerin en görünür olduğu yerde daha fazla yumuşatma)."
+ },
+ "pattern": {
+ "name": "desen",
+ "tooltip": "Kaydırma düzeni. single_shift: doğal yama ızgarasında bir geçiş + diğerleri kaydırılmış. symmetric: tüm geçişler ızgara dışında, kaydırmalar orijin etrafında bölünmüş."
+ },
+ "start_percent": {
+ "name": "başlangıç_yüzdesi",
+ "tooltip": "Karışımın AÇILDIĞI örnekleme ilerlemesi (0=başlangıç, 1=bitiş)."
+ },
+ "strength": {
+ "name": "güç",
+ "tooltip": "Doğal ızgara tahmini (0) ile ortalanmış sonuç (1) arasında enterpolasyon."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "Koşullandırmaya 1-10 referans görseli ekleyin; biri düzenleme talimatı, birden fazlası ise konuya özel kişiselleştirme için.",
+ "display_name": "HiDream-O1 Referans Görselleri",
+ "inputs": {
+ "images": {
+ "name": "görseller",
+ "tooltip": "Referans görselleri. 1 görsel = düzenleme talimatı; 2-10 görsel = çoklu referans."
+ },
+ "negative": {
+ "name": "negatif"
+ },
+ "positive": {
+ "name": "pozitif"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "pozitif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negatif",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "Düşük çözünürlüklü görüntüleri süper çözünürlüğe yükseltin, bozulmaları ve gürültüyü ortadan kaldırın. Maksimum çıktı: 32 megapiksel.",
"display_name": "HitPaw Genel Görüntü İyileştirme",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Arka Plan Kaldırma Modelini Yükle",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "arka_plan_kaldırma_adı",
+ "tooltip": "Görsellerden arka planı kaldırmak için kullanılan model"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Görüntü Yükle",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "ModelNoiseScale",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "noise_scale": {
+ "name": "gürültü_ölçeği",
+ "tooltip": "Mutlak eğitim gürültü ölçeği. Örneğin HiDream-O1 base: 8.0, dev: 7.5."
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "ModelPatchLoader",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "OpenAI'nin GPT Görüntü uç noktası ile görüntüler oluşturur.",
+ "display_name": "OpenAI GPT Görüntü 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "oluşturduktan sonra kontrol"
+ },
+ "model": {
+ "name": "model"
+ },
+ "model_background": {
+ "name": "arka_plan"
+ },
+ "model_custom_height": {
+ "name": "özel_yükseklik"
+ },
+ "model_custom_width": {
+ "name": "özel_genişlik"
+ },
+ "model_quality": {
+ "name": "kalite"
+ },
+ "model_size": {
+ "name": "boyut"
+ },
+ "n": {
+ "name": "n",
+ "tooltip": "Kaç görüntü oluşturulacak"
+ },
+ "prompt": {
+ "name": "istem",
+ "tooltip": "GPT Görüntü için metin istemi"
+ },
+ "seed": {
+ "name": "tohum",
+ "tooltip": "Henüz arka uçta uygulanmadı"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "OpenAI Sohbet Düğümü için girdi olarak eklemek üzere girdi dosyalarını (metin, pdf vb.) yükler ve hazırlar. Dosyalar, yanıt oluşturulurken OpenAI modeli tarafından okunacaktır. 🛈 İPUCU: Diğer OpenAI Girdi Dosyası düğümleriyle zincirlenebilir.",
"display_name": "OpenAI ChatGPT Girdi Dosyaları",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Arka Planı Kaldır",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "arka_plan_kaldırma_modeli",
+ "tooltip": "Maskeyi oluşturmak için kullanılan arka plan kaldırma modeli"
+ },
+ "image": {
+ "name": "görsel",
+ "tooltip": "Arka planı kaldırılacak giriş görseli"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Oluşturulan ön plan maskesi"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "YenidenNormalleştirCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "Ayarlanabilir adım başı gürültüye sahip LCM örnekleyici. s_noise, modelin eğitim gürültüsü ölçeğinde bir çarpandır.",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "Adım başı gürültüyü +/- N*std ile sınırla. 0 devre dışı bırakır."
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "İlk adımda adım başı gürültü çarpanı (1.0 = eğitimle aynı)."
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "Son adımda adım başı gürültü çarpanı. Sabit bir zaman çizelgesi için s_noise ile aynı değeri girin."
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "LCM Büyütme Örnekleyici",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "dörtgen"
+ "name": "dörtgen",
+ "tooltip": "Bu parametre kullanımdan kaldırılmıştır ve hiçbir şey yapmaz."
},
"texture": {
"name": "doku"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "ses"
+ },
+ "audio_inject_scale": {
+ "name": "ses_enjeksiyon_ölçeği",
+ "tooltip": "Ses özelliklerinin video modeline enjekte edilirken kullanılacak ölçek."
+ },
+ "video_frames": {
+ "name": "video_kareleri"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "ses_kodlayıcı_çıktısı",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_dizgesi",
+ "tooltip": "Ses uzunluğu ve video kare sayısına göre hesaplanan fps. İstem içinde kullanılır."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "ses",
+ "tooltip": "Toplam çıktı karelerini hesaplamak ve segment sesini çıkarmak için ses."
+ },
+ "images": {
+ "name": "görseller"
+ },
+ "segment_index": {
+ "name": "segment_indeksi",
+ "tooltip": "Bu hangi segment (ilk için 0, ikinci için 1, vb.)"
+ },
+ "segment_length": {
+ "name": "segment_uzunluğu",
+ "tooltip": "Bu segmentin uzunluğu (genellikle 149 kare)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "anahtar_kare_dizisi",
+ "tooltip": "Doldurulmuş anahtar kare dizisi"
+ },
+ "1": {
+ "name": "anahtar_kare_maskesi",
+ "tooltip": "Geçerli kareleri gösteren maske"
+ },
+ "2": {
+ "name": "ses_segmenti",
+ "tooltip": "Bu video segmenti için ses segmenti"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "ses",
+ "tooltip": "Her üretilen segment için bölünecek ses."
+ },
+ "images": {
+ "name": "görseller"
+ },
+ "num_segments": {
+ "name": "segment_sayısı",
+ "tooltip": "Liste olarak kaç doldurulmuş segment üretileceği."
+ },
+ "segment_length": {
+ "name": "segment_uzunluğu",
+ "tooltip": "Her segmentin uzunluğu (genellikle 149 kare)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "anahtar_kare_dizileri",
+ "tooltip": "Doldurulmuş anahtar kare dizileri"
+ },
+ "1": {
+ "name": "anahtar_kare_maskeleri",
+ "tooltip": "Geçerli kareleri gösteren maskeler"
+ },
+ "2": {
+ "name": "ses_segmenti",
+ "tooltip": "Her video segmenti için ses segmenti"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "İlk kare için CLIP vision gömüleri."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Referans görsel için CLIP vision gömüleri."
+ },
+ "height": {
+ "name": "yükseklik"
+ },
+ "length": {
+ "name": "uzunluk",
+ "tooltip": "Oluşturulan videodaki kare sayısı. WanDancer için 149 olarak kalmalıdır."
+ },
+ "mask": {
+ "name": "mask",
+ "tooltip": "Başlangıç görsel(ler)i için görsel koşullandırma maskesi. Beyaz korunur, siyah üretilir. Yerel üretimler için kullanılır."
+ },
+ "negative": {
+ "name": "negatif"
+ },
+ "positive": {
+ "name": "pozitif"
+ },
+ "start_image": {
+ "name": "başlangıç görseli",
+ "tooltip": "Kodlanacak ilk görsel(ler), herhangi bir kare sayısı olabilir."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "genişlik"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "pozitif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negatif",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Boş latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanİlkSonKaredenVideoya",
"inputs": {
diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json
index d021ce28fb..41a741b3ba 100644
--- a/src/locales/zh-TW/main.json
+++ b/src/locales/zh-TW/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "音訊編碼器",
"AUDIO_ENCODER_OUTPUT": "音訊編碼器輸出",
"AUDIO_RECORD": "音訊錄製",
+ "BACKGROUND_REMOVAL": "去背",
"BOOLEAN": "布林值",
"BOUNDING_BOX": "邊界框",
"CAMERA_CONTROL": "攝影機控制",
@@ -2284,15 +2285,13 @@
"Vidu": "維度",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "進階",
"animation": "動畫",
- "api": "API",
"api node": "API 節點",
"attention_experiments": "注意力實驗",
"audio": "音訊",
+ "background removal": "去背",
"batch": "批次",
- "camera": "相機",
"chroma_radiance": "色度光輝",
"clip": "CLIP",
"color": "顏色",
@@ -2301,7 +2300,6 @@
"cond pair": "條件配對",
"cond single": "單一條件",
"conditioning": "條件設定",
- "context": "上下文",
"controlnet": "ControlNet",
"create": "建立",
"custom_sampling": "自訂取樣",
@@ -2310,6 +2308,7 @@
"deprecated": "已棄用",
"detection": "偵測",
"edit_models": "編輯模型",
+ "experimental": "實驗性",
"flux": "Flux",
"gligen": "GLIGEN",
"guidance": "引導",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "遮罩",
- "math": "數學",
"model": "模型",
"model_merging": "模型合併",
"model_patches": "模型修補",
@@ -2342,7 +2340,6 @@
"save": "儲存",
"schedulers": "排程器",
"scheduling": "排程",
- "sd": "SD",
"sd3": "sd3",
"shader": "著色器",
"sigmas": "西格瑪值",
@@ -2350,7 +2347,6 @@
"style_model": "風格模型",
"supir": "supir",
"text": "文字",
- "textgen": "文字生成",
"training": "訓練",
"transform": "轉換",
"unet": "UNet",
diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json
index 031837db41..950995d469 100644
--- a/src/locales/zh-TW/nodeDefs.json
+++ b/src/locales/zh-TW/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "批次大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "長度"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "起始圖像"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "寬度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "新增雜訊",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "統一的文字轉圖與精確單句編輯,最高支援 4K 解析度。",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_fail_on_partial": {
+ "name": "部分失敗時終止"
+ },
+ "model_height": {
+ "name": "高度"
+ },
+ "model_max_images": {
+ "name": "最大圖像數"
+ },
+ "model_size_preset": {
+ "name": "尺寸預設"
+ },
+ "model_width": {
+ "name": "寬度"
+ },
+ "prompt": {
+ "name": "提示詞",
+ "tooltip": "用於創建或編輯圖像的文字提示。"
+ },
+ "seed": {
+ "name": "種子",
+ "tooltip": "用於生成的隨機種子。"
+ },
+ "watermark": {
+ "name": "浮水印",
+ "tooltip": "是否在圖像上添加「AI 生成」浮水印。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "透過 API 使用字節跳動模型根據提示生成影片",
"display_name": "字節跳動文字轉影片",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "HiDream-O1-Image 的空像素潛空間。該模型於約 4 百萬像素下訓練;較低解析度會偏離分布且品質明顯下降。訓練解析度:2048x2048、2304x1728、1728x2304、2560x1440、1440x2560、2496x1664、1664x2496、3104x1312、1312x3104、2304x1792、1792x2304。",
+ "display_name": "Empty HiDream-O1 潛空間圖像",
+ "inputs": {
+ "batch_size": {
+ "name": "批次大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "width": {
+ "name": "寬度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "EmptyHunyuanImageLatent",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "透過 Flux.2 [pro] 或 Flux.2 [max],根據提示詞與可選參考圖像生成圖像。",
+ "display_name": "Flux.2 圖像",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_height": {
+ "name": "高度"
+ },
+ "model_width": {
+ "name": "寬度"
+ },
+ "prompt": {
+ "name": "提示詞",
+ "tooltip": "用於圖像生成或編輯的提示詞"
+ },
+ "seed": {
+ "name": "種子",
+ "tooltip": "用於產生雜訊的隨機種子。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "根據提示詞與解析度同步生成圖像。",
"display_name": "Flux.2 [max] 圖像",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "根據文字提示修改現有圖像",
+ "display_name": "Grok 圖像編輯",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_aspect_ratio": {
+ "name": "長寬比"
+ },
+ "model_number_of_images": {
+ "name": "圖像數量"
+ },
+ "model_resolution": {
+ "name": "解析度"
+ },
+ "prompt": {
+ "name": "提示詞",
+ "tooltip": "用於生成圖像的文字提示"
+ },
+ "seed": {
+ "name": "種子",
+ "tooltip": "決定節點是否重新執行的種子;實際結果無論種子如何皆為非確定性。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "根據文字提示使用 Grok 生成圖像",
"display_name": "Grok 圖像",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "在取樣後期,於多個偏移的區塊網格位置平均模型輸出。可消除接縫。",
+ "display_name": "HiDream-O1 區塊接縫平滑",
+ "inputs": {
+ "blend": {
+ "name": "混合方式",
+ "tooltip": "average:等權重平均。window:Hann 窗加權,偏重遠離區塊邊界。median:逐像素中位數,排除環繞異常通過。"
+ },
+ "end_percent": {
+ "name": "結束百分比",
+ "tooltip": "混合關閉時的取樣進度。"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "passes": {
+ "name": "通過次數",
+ "tooltip": "每個閘控步驟的通過次數。2/4 = 固定。ramp_*:通過次數隨取樣接近結束而增加(接縫最明顯處加強平滑)。"
+ },
+ "pattern": {
+ "name": "模式",
+ "tooltip": "偏移佈局。single_shift:一次自然區塊網格 + 其他偏移。symmetric:全部偏移,對稱分布於原點。"
+ },
+ "start_percent": {
+ "name": "起始百分比",
+ "tooltip": "混合啟動時的取樣進度(0=開始,1=結束)。"
+ },
+ "strength": {
+ "name": "強度",
+ "tooltip": "在自然網格預測(0)與平均結果(1)間插值。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "附加 1-10 張參考圖像於條件設定,一張用於編輯指令,多張用於主題個人化。",
+ "display_name": "HiDream-O1 參考圖像",
+ "inputs": {
+ "images": {
+ "name": "參考圖像",
+ "tooltip": "參考圖像。1 張 = 編輯指令;2-10 張 = 多重參考。"
+ },
+ "negative": {
+ "name": "負向"
+ },
+ "positive": {
+ "name": "正向"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "正向",
+ "tooltip": null
+ },
+ "1": {
+ "name": "負向",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "將低解析度影像升級為超高解析度,消除雜訊與失真。最大輸出:32 百萬像素。",
"display_name": "HitPaw 通用影像增強",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "載入背景移除模型",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "用於從圖像中移除背景的模型"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "載入圖片",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "模型雜訊尺度",
+ "inputs": {
+ "model": {
+ "name": "模型"
+ },
+ "noise_scale": {
+ "name": "雜訊尺度",
+ "tooltip": "絕對訓練雜訊尺度。例如 HiDream-O1 base:8.0,dev:7.5。"
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "模型修補載入器",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "透過 OpenAI 的 GPT Image 端點生成圖像。",
+ "display_name": "OpenAI GPT 圖像 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成後控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_background": {
+ "name": "背景"
+ },
+ "model_custom_height": {
+ "name": "自訂高度"
+ },
+ "model_custom_width": {
+ "name": "自訂寬度"
+ },
+ "model_quality": {
+ "name": "品質"
+ },
+ "model_size": {
+ "name": "尺寸"
+ },
+ "n": {
+ "name": "數量",
+ "tooltip": "要生成多少張圖像"
+ },
+ "prompt": {
+ "name": "提示詞",
+ "tooltip": "GPT Image 的文字提示"
+ },
+ "seed": {
+ "name": "種子",
+ "tooltip": "後端尚未實作"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "載入並準備輸入文件(文字、pdf 等)以作為 OpenAI 聊天節點的輸入。生成回應時,OpenAI 模型將讀取這些文件。🛈 提示:可以與其他 OpenAI 輸入文件節點鏈接使用。",
"display_name": "OpenAI ChatGPT 輸入文件",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "移除背景",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "用於產生 mask 的背景移除模型"
+ },
+ "image": {
+ "name": "圖像",
+ "tooltip": "要移除背景的輸入圖像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "產生的前景 mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "具有可調整每步雜訊的 LCM 取樣器。s_noise 是模型訓練雜訊尺度的乘數",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "將每步雜訊限制在 ±N*std。設為 0 則停用。"
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "第一步的每步雜訊乘數(1.0 = 與訓練相符)。"
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "最後一步的每步雜訊乘數。若與 s_noise 相同則為固定排程。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "SamplerLCMUpscale",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "此參數已棄用,無任何作用。"
},
"texture": {
"name": "texture"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "將音訊特徵注入到影片模型時的縮放比例。"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "根據音訊長度與影片影格數計算出的 fps,會用於提示詞中。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "用於計算總輸出影格數並擷取片段音訊的音訊。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "這是第幾個片段(第一個為 0,第二個為 1,以此類推)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "此片段的長度(通常為 149 影格)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "已補齊的關鍵影格序列"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "標示有效影格的 mask"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "此影片片段對應的音訊片段"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "要為每個輸出片段切割的音訊。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "要以清單形式輸出的補齊片段數量。"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "每個片段的長度(通常為 149 影格)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "已補齊的關鍵影格序列"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "標示有效影格的 mask"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "每個影片片段對應的音訊片段"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "第一幀的 CLIP 視覺嵌入。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "參考圖像的 CLIP 視覺嵌入。"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "長度",
+ "tooltip": "生成影片的幀數。對於 WanDancer,應保持 149。"
+ },
+ "mask": {
+ "name": "遮罩",
+ "tooltip": "起始圖像的圖像處理遮罩。白色保留,黑色生成。用於局部生成。"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "起始圖像",
+ "tooltip": "要編碼的初始圖像,可為任意幀數。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "寬度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空的 latent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "Wan 首尾影格轉影片",
"inputs": {
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index 97f66694ec..6a580f0ee2 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "音频编码器",
"AUDIO_ENCODER_OUTPUT": "音频编码器输出",
"AUDIO_RECORD": "音频录制",
+ "BACKGROUND_REMOVAL": "背景移除",
"BOOLEAN": "布尔",
"BOUNDING_BOX": "边界框",
"CAMERA_CONTROL": "相机控制",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan万相",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_用于测试",
"advanced": "高级",
"animation": "动画",
- "api": "API",
"api node": "api 节点",
"attention_experiments": "注意力实验",
"audio": "音频",
+ "background removal": "背景移除",
"batch": "批处理",
- "camera": "相机",
"chroma_radiance": "chroma_radiance",
"clip": "CLIP",
"color": "颜色",
@@ -2301,7 +2300,6 @@
"cond pair": "条件对",
"cond single": "条件单",
"conditioning": "条件",
- "context": "上下文",
"controlnet": "ControlNet",
"create": "创建",
"custom_sampling": "自定义采样",
@@ -2310,6 +2308,7 @@
"deprecated": "已弃用",
"detection": "检测",
"edit_models": "编辑模型",
+ "experimental": "实验性",
"flux": "Flux",
"gligen": "GLIGEN",
"guidance": "引导",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "遮罩",
- "math": "数学",
"model": "模型",
"model_merging": "模型合并",
"model_patches": "模型微调",
@@ -2342,7 +2340,6 @@
"save": "保存",
"schedulers": "调度器",
"scheduling": "调度",
- "sd": "sd",
"sd3": "SD3",
"shader": "shader",
"sigmas": "Sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "风格模型",
"supir": "supir",
"text": "文本",
- "textgen": "textgen",
"training": "训练",
"transform": "变换",
"unet": "U-Net",
diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json
index a949ba29d1..a9683db499 100644
--- a/src/locales/zh/nodeDefs.json
+++ b/src/locales/zh/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "批量大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "长度"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "start_image": {
+ "name": "起始图像"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "宽度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "添加噪波",
"inputs": {
@@ -919,6 +953,50 @@
}
}
},
+ "ByteDanceSeedreamNodeV2": {
+ "description": "统一的文本生成图像与精确的单句编辑,最高支持4K分辨率。",
+ "display_name": "ByteDance Seedream 4.5 & 5.0",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成后控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_fail_on_partial": {
+ "name": "部分失败时终止"
+ },
+ "model_height": {
+ "name": "高度"
+ },
+ "model_max_images": {
+ "name": "最大图像数"
+ },
+ "model_size_preset": {
+ "name": "尺寸预设"
+ },
+ "model_width": {
+ "name": "宽度"
+ },
+ "prompt": {
+ "name": "提示词",
+ "tooltip": "用于创建或编辑图像的文本提示。"
+ },
+ "seed": {
+ "name": "种子",
+ "tooltip": "用于生成的种子。"
+ },
+ "watermark": {
+ "name": "水印",
+ "tooltip": "是否在图像上添加“AI生成”水印。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"ByteDanceTextToVideoNode": {
"description": "通过API基于提示使用字节跳动模型生成视频",
"display_name": "字节跳动文生视频",
@@ -1655,6 +1733,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "布尔值",
+ "tooltip": null
}
}
},
@@ -3074,6 +3156,26 @@
}
}
},
+ "EmptyHiDreamO1LatentImage": {
+ "description": "HiDream-O1-Image 的空像素空间潜空间。该模型在约 400 万像素下训练;分辨率过低会导致分布偏移,质量明显下降。训练分辨率:2048x2048、2304x1728、1728x2304、2560x1440、1440x2560、2496x1664、1664x2496、3104x1312、1312x3104、2304x1792、1792x2304。",
+ "display_name": "空 HiDream-O1 潜空间图像",
+ "inputs": {
+ "batch_size": {
+ "name": "批量大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "width": {
+ "name": "宽度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"EmptyHunyuanImageLatent": {
"display_name": "空Latent图像(Hunyuan)",
"inputs": {
@@ -3394,6 +3496,37 @@
}
}
},
+ "Flux2ImageNode": {
+ "description": "通过Flux.2 [pro]或Flux.2 [max],根据提示词和可选参考图像生成图像。",
+ "display_name": "Flux.2 图像",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成后控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_height": {
+ "name": "高度"
+ },
+ "model_width": {
+ "name": "宽度"
+ },
+ "prompt": {
+ "name": "提示词",
+ "tooltip": "用于图像生成或编辑的提示词"
+ },
+ "seed": {
+ "name": "种子",
+ "tooltip": "用于生成噪声的随机种子。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"Flux2MaxImageNode": {
"description": "根据提示词和分辨率同步生成图像。",
"display_name": "Flux.2 [max] 图像",
@@ -4417,6 +4550,40 @@
}
}
},
+ "GrokImageEditNodeV2": {
+ "description": "根据文本提示修改已有图像",
+ "display_name": "Grok 图像编辑",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成后控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_aspect_ratio": {
+ "name": "宽高比"
+ },
+ "model_number_of_images": {
+ "name": "图像数量"
+ },
+ "model_resolution": {
+ "name": "分辨率"
+ },
+ "prompt": {
+ "name": "提示词",
+ "tooltip": "用于生成图像的文本提示"
+ },
+ "seed": {
+ "name": "种子",
+ "tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"GrokImageNode": {
"description": "使用 Grok 根据文本提示生成图像",
"display_name": "Grok 图像",
@@ -4758,6 +4925,70 @@
}
}
},
+ "HiDreamO1PatchSeamSmoothing": {
+ "description": "在采样后期,将模型输出在多个偏移补丁网格位置上取平均。消除缝隙。",
+ "display_name": "HiDream-O1 补丁缝隙平滑",
+ "inputs": {
+ "blend": {
+ "name": "混合方式",
+ "tooltip": "average:等权重平均。window:Hann 窗加权,远离补丁边界权重更高。median:逐像素中值,剔除环绕异常遍数。"
+ },
+ "end_percent": {
+ "name": "结束百分比",
+ "tooltip": "混合关闭时的采样进度。"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "passes": {
+ "name": "遍数",
+ "tooltip": "每步的遍数。2/4 = 固定。ramp_*:采样接近结束时遍数增加(缝隙最明显处更多平滑)。"
+ },
+ "pattern": {
+ "name": "模式",
+ "tooltip": "偏移布局。single_shift:一次自然补丁网格+其他偏移。symmetric:全部偏移,偏移围绕原点对称分布。"
+ },
+ "start_percent": {
+ "name": "起始百分比",
+ "tooltip": "混合开启时的采样进度(0=开始,1=结束)。"
+ },
+ "strength": {
+ "name": "强度",
+ "tooltip": "在自然网格预测(0)与平均结果(1)之间插值。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
+ "HiDreamO1ReferenceImages": {
+ "description": "为条件添加 1-10 张参考图像,一张用于编辑指令,多张用于主体个性化。",
+ "display_name": "HiDream-O1 参考图像",
+ "inputs": {
+ "images": {
+ "name": "参考图像",
+ "tooltip": "参考图像。1 张 = 指令编辑;2-10 张 = 多参考。"
+ },
+ "negative": {
+ "name": "负向"
+ },
+ "positive": {
+ "name": "正向"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "正向",
+ "tooltip": null
+ },
+ "1": {
+ "name": "负向",
+ "tooltip": null
+ }
+ }
+ },
"HitPawGeneralImageEnhance": {
"description": "将低分辨率图像放大至超分辨率,消除伪影和噪点。最大输出:3200万像素。",
"display_name": "HitPaw 通用图像增强",
@@ -8011,6 +8242,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "加载背景移除模型",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "背景移除模型名称",
+ "tooltip": "用于从图像中移除背景的模型"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "加载图像",
"inputs": {
@@ -11492,6 +11738,18 @@
}
}
},
+ "ModelNoiseScale": {
+ "display_name": "模型噪声尺度",
+ "inputs": {
+ "model": {
+ "name": "模型"
+ },
+ "noise_scale": {
+ "name": "噪声尺度",
+ "tooltip": "绝对训练噪声尺度。例如 HiDream-O1 base: 8.0,dev: 7.5。"
+ }
+ }
+ },
"ModelPatchLoader": {
"display_name": "加载模型补丁",
"inputs": {
@@ -11912,6 +12170,50 @@
}
}
},
+ "OpenAIGPTImageNodeV2": {
+ "description": "通过OpenAI的GPT Image端点生成图像。",
+ "display_name": "OpenAI GPT 图像 2",
+ "inputs": {
+ "control_after_generate": {
+ "name": "生成后控制"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "model_background": {
+ "name": "背景"
+ },
+ "model_custom_height": {
+ "name": "自定义高度"
+ },
+ "model_custom_width": {
+ "name": "自定义宽度"
+ },
+ "model_quality": {
+ "name": "质量"
+ },
+ "model_size": {
+ "name": "尺寸"
+ },
+ "n": {
+ "name": "数量",
+ "tooltip": "要生成的图像数量"
+ },
+ "prompt": {
+ "name": "提示词",
+ "tooltip": "用于GPT Image的文本提示"
+ },
+ "seed": {
+ "name": "种子",
+ "tooltip": "后端尚未实现"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"OpenAIInputFiles": {
"description": "加载并准备输入文件(文本、PDF等)作为OpenAI聊天节点的输入。生成响应时,OpenAI模型将读取这些文件。🛈 提示:可与其他OpenAI输入文件节点链式连接。",
"display_name": "OpenAI ChatGPT Input Files",
@@ -13460,6 +13762,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "移除背景",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "背景移除模型",
+ "tooltip": "用于生成 mask 的背景移除模型"
+ },
+ "image": {
+ "name": "图像",
+ "tooltip": "要移除背景的输入图像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "生成的前景 mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14745,6 +15066,29 @@
}
}
},
+ "SamplerLCM": {
+ "description": "具有可调每步噪声的LCM采样器。s_noise是模型训练噪声尺度的倍数",
+ "display_name": "SamplerLCM",
+ "inputs": {
+ "noise_clip_std": {
+ "name": "noise_clip_std",
+ "tooltip": "将每步噪声限制在±N*标准差。0为禁用。"
+ },
+ "s_noise": {
+ "name": "s_noise",
+ "tooltip": "第一步的每步噪声倍数(1.0 = 匹配训练)。"
+ },
+ "s_noise_end": {
+ "name": "s_noise_end",
+ "tooltip": "最后一步的每步噪声倍数。与s_noise相同则为恒定调度。"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ }
+ }
+ },
"SamplerLCMUpscale": {
"display_name": "LCM缩放采样器",
"inputs": {
@@ -17353,7 +17697,8 @@
"name": "PBR"
},
"quad": {
- "name": "四边形"
+ "name": "四边形",
+ "tooltip": "此参数已弃用,无任何作用。"
},
"texture": {
"name": "纹理"
@@ -19321,6 +19666,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "音频"
+ },
+ "audio_inject_scale": {
+ "name": "音频注入比例",
+ "tooltip": "将音频特征注入到视频模型时的比例。"
+ },
+ "video_frames": {
+ "name": "视频帧"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "音频编码器输出",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps字符串",
+ "tooltip": "根据音频长度和视频帧数计算得到的fps。用于提示词中。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "音频",
+ "tooltip": "用于计算总输出帧数并提取片段音频。"
+ },
+ "images": {
+ "name": "图像"
+ },
+ "segment_index": {
+ "name": "片段索引",
+ "tooltip": "这是第几个片段(第一个为0,第二个为1,以此类推)"
+ },
+ "segment_length": {
+ "name": "片段长度",
+ "tooltip": "该片段的长度(通常为149帧)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "关键帧序列",
+ "tooltip": "填充后的关键帧序列"
+ },
+ "1": {
+ "name": "关键帧掩码",
+ "tooltip": "指示有效帧的掩码"
+ },
+ "2": {
+ "name": "音频片段",
+ "tooltip": "该视频片段对应的音频片段"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "音频",
+ "tooltip": "为每个输出片段切分音频。"
+ },
+ "images": {
+ "name": "图像"
+ },
+ "num_segments": {
+ "name": "片段数量",
+ "tooltip": "要以列表形式输出多少个填充片段。"
+ },
+ "segment_length": {
+ "name": "片段长度",
+ "tooltip": "每个片段的长度(通常为149帧)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "关键帧序列",
+ "tooltip": "填充后的关键帧序列"
+ },
+ "1": {
+ "name": "关键帧掩码",
+ "tooltip": "指示有效帧的掩码"
+ },
+ "2": {
+ "name": "音频片段",
+ "tooltip": "每个视频片段对应的音频片段"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "音频编码器输出"
+ },
+ "clip_vision_output": {
+ "name": "clip视觉输出",
+ "tooltip": "第一帧的CLIP视觉嵌入。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip视觉参考输出",
+ "tooltip": "参考图像的CLIP视觉嵌入。"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "长度",
+ "tooltip": "生成视频的帧数。对于WanDancer应保持为149。"
+ },
+ "mask": {
+ "name": "掩码",
+ "tooltip": "用于起始图像的图像条件掩码。白色保留,黑色生成。用于局部生成。"
+ },
+ "negative": {
+ "name": "负向"
+ },
+ "positive": {
+ "name": "正向"
+ },
+ "start_image": {
+ "name": "起始图像",
+ "tooltip": "要编码的初始图像,可以为任意帧数。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "宽度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "正向",
+ "tooltip": null
+ },
+ "1": {
+ "name": "负向",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空的latent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "Wan首尾帧视频",
"inputs": {
diff --git a/src/platform/assets/composables/media/assetMappers.test.ts b/src/platform/assets/composables/media/assetMappers.test.ts
index a4667a234e..4bea3580b6 100644
--- a/src/platform/assets/composables/media/assetMappers.test.ts
+++ b/src/platform/assets/composables/media/assetMappers.test.ts
@@ -21,21 +21,24 @@ describe('mapInputFileToAssetItem', () => {
expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
})
- it.each([
+ it.for([
['photo.png [input]', 'photo.png'],
['photo.png [output]', 'photo.png'],
['photo.png [temp]', 'photo.png'],
['clip.mp4[input]', 'clip.mp4'],
['MyFile.WEBP [Input]', 'MyFile.WEBP']
- ])('strips ComfyUI directory annotation: %s -> %s', (input, expectedName) => {
- const asset = mapInputFileToAssetItem(input, 1, 'input')
+ ])(
+ 'strips ComfyUI directory annotation: %s -> %s',
+ ([input, expectedName]) => {
+ const asset = mapInputFileToAssetItem(input, 1, 'input')
- expect(asset.name).toBe(expectedName)
- expect(asset.id).toBe(`input-1-${expectedName}`)
- expect(asset.preview_url).toBe(
- `/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
- )
- })
+ expect(asset.name).toBe(expectedName)
+ expect(asset.id).toBe(`input-1-${expectedName}`)
+ expect(asset.preview_url).toBe(
+ `/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
+ )
+ }
+ )
it('leaves non-annotation brackets in the filename intact', () => {
const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts
index 375a90abec..60165076c9 100644
--- a/src/platform/assets/composables/useMediaAssetActions.test.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.test.ts
@@ -78,13 +78,20 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
})
}))
+const mockExportWorkflowAction = vi.hoisted(() => vi.fn())
+const mockOpenWorkflowAction = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/core/services/workflowActionsService', () => ({
useWorkflowActionsService: () => ({
- openWorkflowAction: vi.fn(),
- exportWorkflowAction: vi.fn()
+ openWorkflowAction: mockOpenWorkflowAction,
+ exportWorkflowAction: mockExportWorkflowAction
})
}))
+const mockExtractWorkflowFromAsset = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
+ extractWorkflowFromAsset: mockExtractWorkflowFromAsset
+}))
+
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({
addNodeOnGraph: vi.fn().mockReturnValue(
@@ -167,6 +174,52 @@ vi.mock('@/scripts/api', () => ({
}
}))
+const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
+vi.mock('@/scripts/app', () => ({
+ app: {
+ get graph() {
+ return mockAppGraph.value
+ },
+ get rootGraph() {
+ return mockAppGraph.value
+ }
+ }
+}))
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({
+ removeNodeOutputs: mockRemoveNodeOutputs,
+ removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
+ })
+}))
+
+const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({
+ activeWorkflow: {
+ changeTracker: { captureCanvasState: mockCaptureCanvasState }
+ }
+ })
+}))
+
+const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
+ clearNodePreviewCacheForValues: mockClearNodePreviewCache,
+ findNodesReferencingValues: vi.fn(() => [])
+}))
+
+const mockClearWidgetValues = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
+ clearDeletedAssetWidgetValues: mockClearWidgetValues
+}))
+
+const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
+vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
+ markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
+}))
+
function createMockAsset(overrides: Partial = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -330,6 +383,109 @@ describe('useMediaAssetActions', () => {
})
})
+ describe('exportWorkflow', () => {
+ const successResult = { success: true } as const
+ const cancelledResult = { success: false, cancelled: true } as const
+ const failureResult = { success: false, error: 'boom' } as const
+ const noWorkflowResult = {
+ success: false,
+ error: 'No workflow data available'
+ } as const
+
+ beforeEach(() => {
+ mockExtractWorkflowFromAsset.mockResolvedValue({
+ workflow: { version: 0.4 },
+ filename: 'export.json'
+ })
+ })
+
+ it('does not show a toast when the user cancels the filename prompt', async () => {
+ mockExportWorkflowAction.mockResolvedValue(cancelledResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportWorkflow(createMockAsset())
+
+ expect(useToast().add).not.toHaveBeenCalled()
+ })
+
+ it('shows a success toast on successful export', async () => {
+ mockExportWorkflowAction.mockResolvedValue(successResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportWorkflow(createMockAsset())
+
+ expect(useToast().add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'success' })
+ )
+ })
+
+ it('shows an error toast on actual failure', async () => {
+ mockExportWorkflowAction.mockResolvedValue(failureResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportWorkflow(createMockAsset())
+
+ expect(useToast().add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'error' })
+ )
+ })
+
+ it('shows a warning toast when the workflow is missing', async () => {
+ mockExportWorkflowAction.mockResolvedValue(noWorkflowResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportWorkflow(createMockAsset())
+
+ expect(useToast().add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'warn' })
+ )
+ })
+
+ it('shows no toast when every asset in a bulk export is cancelled', async () => {
+ mockExportWorkflowAction.mockResolvedValue(cancelledResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportMultipleWorkflows([
+ createMockAsset({ id: 'a' }),
+ createMockAsset({ id: 'b' })
+ ])
+
+ expect(useToast().add).not.toHaveBeenCalled()
+ })
+
+ it('shows a success toast for the succeeded subset when some bulk exports are cancelled', async () => {
+ mockExportWorkflowAction
+ .mockResolvedValueOnce(successResult)
+ .mockResolvedValueOnce(cancelledResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportMultipleWorkflows([
+ createMockAsset({ id: 'a' }),
+ createMockAsset({ id: 'b' })
+ ])
+
+ expect(useToast().add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'success' })
+ )
+ })
+
+ it('shows a partial-success warning toast when some bulk exports fail outright', async () => {
+ mockExportWorkflowAction
+ .mockResolvedValueOnce(successResult)
+ .mockResolvedValueOnce(failureResult)
+ const actions = useMediaAssetActions()
+
+ await actions.exportMultipleWorkflows([
+ createMockAsset({ id: 'a' }),
+ createMockAsset({ id: 'b' })
+ ])
+
+ expect(useToast().add).toHaveBeenCalledWith(
+ expect.objectContaining({ severity: 'warn' })
+ )
+ })
+ })
+
describe('downloadAssets', () => {
it('downloads the injected media asset when called without explicit assets', () => {
const mediaAsset = createMockMediaAsset({
@@ -793,4 +949,120 @@ describe('useMediaAssetActions', () => {
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
+
+ describe('deleteAssets — FE-230 preview cache clearing', () => {
+ beforeEach(() => {
+ mockIsCloud.value = true
+ mockGetAssetType.mockReturnValue('input')
+ mockDeleteAsset.mockReset()
+ mockShowDialog.mockImplementation(
+ (opts: {
+ props: {
+ onConfirm: () => Promise | void
+ }
+ }) => {
+ void opts.props.onConfirm()
+ }
+ )
+ mockAppGraph.value = { _nodes: [] }
+ })
+
+ it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-match',
+ name: 'foo.png',
+ asset_hash: 'abc123.png',
+ tags: ['input']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [graphArg, valuesArg, removeArg] =
+ mockClearNodePreviewCache.mock.calls[0]
+ expect(graphArg).toBe(mockAppGraph.value)
+ expect(valuesArg).toEqual(
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+ expect(typeof removeArg).toBe('function')
+
+ const sampleNode = { id: 42 }
+ removeArg(sampleNode)
+ expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
+ // Locator is resolved from the node's own graph, not from the raw id —
+ // covers Load Image / Load Video nodes nested inside subgraphs.
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+
+ expect(mockClearWidgetValues).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ expect(mockMarkMissingMedia).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ // markMissing + previewCache must run before widget-value clearing,
+ // otherwise findNodesReferencingValues sees blanked widgets and matches
+ // nothing.
+ const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
+ const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
+ const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
+ expect(markOrder).toBeLessThan(clearOrder)
+ expect(cacheOrder).toBeLessThan(clearOrder)
+
+ // Programmatic widget mutation doesn't go through DOM events, so the
+ // workflow won't be flagged as modified unless we capture explicitly.
+ expect(mockCaptureCanvasState).toHaveBeenCalled()
+ })
+
+ it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ mockGetAssetType.mockReturnValue('output')
+ mockGetOutputAssetMetadata.mockReturnValue({
+ subfolder: 'outputs/2025'
+ })
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-output',
+ name: 'gen.png',
+ tags: ['output']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
+ expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
+ expect(valuesArg.has('gen.png')).toBe(false)
+ expect(valuesArg.has('gen.png [input]')).toBe(false)
+ })
+
+ it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
+ mockDeleteAsset.mockRejectedValue(new Error('boom'))
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-failed',
+ name: 'failed.png',
+ asset_hash: 'failhash.png'
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockDeleteAsset).toHaveBeenCalled()
+ })
+ expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
+ expect(mockClearWidgetValues).not.toHaveBeenCalled()
+ expect(mockMarkMissingMedia).not.toHaveBeenCalled()
+ expect(mockCaptureCanvasState).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts
index 3f9b8e6ab7..b45fed3458 100644
--- a/src/platform/assets/composables/useMediaAssetActions.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.ts
@@ -7,16 +7,22 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
+import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
+import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
+import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
+import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
+import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
@@ -30,6 +36,35 @@ import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
+/**
+ * Canonical widget-value strings that may reference this asset, scoped by the
+ * asset's source type so basenames cannot cross-match across input/output.
+ *
+ * Output assets emit ` [output]` (and the subfolder-prefixed form when
+ * present in metadata). Input/temp assets emit the bare name plus the explicit
+ * annotation. `asset_hash` is included whenever present, since cloud-stored
+ * assets can be referenced by hash.
+ */
+function widgetValueVariantsForAsset(asset: AssetItem): string[] {
+ const variants: string[] = []
+ const type = getAssetType(asset, 'input')
+ const name = asset.name
+ if (name) {
+ if (type === 'output') {
+ const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
+ const path = subfolder ? `${subfolder}/${name}` : name
+ variants.push(`${path} [output]`)
+ } else if (type === 'temp') {
+ variants.push(`${name} [temp]`)
+ } else {
+ variants.push(name)
+ variants.push(`${name} [input]`)
+ }
+ }
+ if (asset.asset_hash) variants.push(asset.asset_hash)
+ return variants
+}
+
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -343,6 +378,8 @@ export function useMediaAssetActions() {
filename
)
+ if (result.cancelled) return
+
if (!result.success) {
const isNoWorkflow = result.error?.includes('No workflow')
toast.add({
@@ -530,7 +567,7 @@ export function useMediaAssetActions() {
if (result.success) {
succeeded++
- } else {
+ } else if (!result.cancelled) {
failed++
}
} catch {
@@ -538,6 +575,9 @@ export function useMediaAssetActions() {
}
}
+ // All cancelled
+ if (succeeded === 0 && failed === 0) return
+
if (failed === 0) {
toast.add({
severity: 'success',
@@ -639,6 +679,31 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
+ const rootGraph = app.rootGraph
+ if (rootGraph) {
+ const deletedValues = new Set()
+ assetArray.forEach((asset, index) => {
+ if (results[index].status !== 'fulfilled') return
+ for (const value of widgetValueVariantsForAsset(asset)) {
+ deletedValues.add(value)
+ }
+ })
+ if (deletedValues.size > 0) {
+ const nodeOutputStore = useNodeOutputStore()
+ // Order matters: mark + cache-clear both look up nodes by
+ // current widget.value, so they must run before
+ // clearDeletedAssetWidgetValues blanks those values.
+ markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
+ clearNodePreviewCacheForValues(
+ rootGraph,
+ deletedValues,
+ (node) => nodeOutputStore.removeNodeOutputsForNode(node)
+ )
+ clearDeletedAssetWidgetValues(rootGraph, deletedValues)
+ useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
+ }
+ }
+
// Invalidate model caches for affected categories
const modelCategories = new Set()
diff --git a/src/platform/assets/mappings/modelNodeMappings.ts b/src/platform/assets/mappings/modelNodeMappings.ts
index 0368c7ca52..9ce9da8667 100644
--- a/src/platform/assets/mappings/modelNodeMappings.ts
+++ b/src/platform/assets/mappings/modelNodeMappings.ts
@@ -197,5 +197,18 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['mediapipe', 'LivePortraitLoadMediaPipeCropper', ''],
// ---- Superprompt text enhancement ----
- ['superprompt-v1', 'Superprompt', '']
+ ['superprompt-v1', 'Superprompt', ''],
+
+ // ---- ComfyUI core background removal (v0.21+) ----
+ ['background_removal', 'LoadBackgroundRemovalModel', 'bg_removal_name'],
+
+ // ---- ComfyUI core frame interpolation (v0.21+) ----
+ ['frame_interpolation', 'FrameInterpolationModelLoader', 'model_name'],
+
+ // ---- FILM frame interpolation (ComfyUI-Frame-Interpolation) ----
+ ['film', 'FILM VFI', 'ckpt_name'],
+
+ // ---- Ultralytics YOLO detectors (ComfyUI-Impact-Pack) ----
+ ['ultralytics/bbox', 'UltralyticsDetectorProvider', 'model_name'],
+ ['ultralytics/segm', 'UltralyticsDetectorProvider', 'model_name']
] as const satisfies ReadonlyArray
diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts
index b073bcce71..580919d10c 100644
--- a/src/platform/assets/services/assetService.test.ts
+++ b/src/platform/assets/services/assetService.test.ts
@@ -580,7 +580,7 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('offset')).toBe('2')
})
- it.each([
+ it.for([
{
name: 'missing has_more',
body: {
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 135303778d..3ac5dc2c71 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -480,12 +480,27 @@ function createAssetService() {
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise {
- const data = await handleAssetRequest(
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit,
+ offset,
+ signal
+ })
+
+ return data.assets
+ }
+
+ /**
+ * Gets one paginated asset response filtered by a specific tag.
+ */
+ async function getAssetsPageByTag(
+ tag: string,
+ includePublic: boolean = true,
+ { limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
+ ): Promise {
+ return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
-
- return data.assets
}
/**
@@ -511,16 +526,11 @@ function createAssetService() {
while (true) {
if (signal?.aborted) throw createAbortError()
- const data = await handleAssetRequest(
- {
- includeTags: [tag],
- limit: pageSize,
- offset,
- includePublic,
- signal
- },
- `assets for tag ${tag}`
- )
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit: pageSize,
+ offset,
+ signal
+ })
const batch = data.assets
if (batch.length === 0) {
return assets
@@ -935,6 +945,7 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
+ getAssetsPageByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts
index eabde69429..ff81ef0495 100644
--- a/src/platform/assets/utils/assetMetadataUtils.ts
+++ b/src/platform/assets/utils/assetMetadataUtils.ts
@@ -1,4 +1,5 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { isCivitaiUrl } from '@/utils/formatUtil'
/**
* Type-safe utilities for extracting metadata from assets.
@@ -126,16 +127,9 @@ export function getAssetAdditionalTags(asset: AssetItem): string[] {
* @returns Human-readable source name
*/
export function getSourceName(url: string): string {
+ if (isCivitaiUrl(url)) return 'Civitai'
try {
const hostname = new URL(url).hostname.toLowerCase()
- if (
- hostname === 'civitai.com' ||
- hostname.endsWith('.civitai.com') ||
- hostname === 'civitai.red' ||
- hostname.endsWith('.civitai.red')
- ) {
- return 'Civitai'
- }
if (hostname === 'huggingface.co' || hostname.endsWith('.huggingface.co')) {
return 'Hugging Face'
}
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
new file mode 100644
index 0000000000..239654eaa9
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+
+import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
+
+type MockWidget = {
+ name: string
+ value: unknown
+ callback?: (value: unknown) => void
+}
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearDeletedAssetWidgetValues', () => {
+ it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(callback).toHaveBeenCalledWith('')
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves untouched widgets that do not match deleted values', () => {
+ const matchedCallback = vi.fn()
+ const keptCallback = vi.fn()
+ const node: MockNode = {
+ id: 2,
+ widgets: [
+ {
+ name: 'image',
+ value: 'outputs/foo.png [output]',
+ callback: matchedCallback
+ },
+ { name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(node.widgets![1].value).toBe('inputs/keep.png')
+ expect(matchedCallback).toHaveBeenCalledWith('')
+ expect(keptCallback).not.toHaveBeenCalled()
+ })
+
+ it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 3,
+ widgets: [
+ {
+ name: 'image',
+ value: 'clipspace/clipspace-painted-masked-1.png [input]',
+ callback
+ }
+ ],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/some-other-asset.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe(
+ 'clipspace/clipspace-painted-masked-1.png [input]'
+ )
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted-values set is empty', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 4,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
+
+ expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('handles widgets without a callback (legacy nodes) without throwing', () => {
+ const node: MockNode = {
+ id: 5,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+
+ expect(node.widgets![0].value).toBe('')
+ })
+
+ it('clears all matching widgets across multiple nodes', () => {
+ const cbA = vi.fn()
+ const cbB = vi.fn()
+ const nodeA: MockNode = {
+ id: 6,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbA }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const nodeB: MockNode = {
+ id: 7,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbB }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([nodeA, nodeB]),
+ new Set(['outputs/a.png [output]'])
+ )
+
+ expect(nodeA.widgets![0].value).toBe('')
+ expect(nodeB.widgets![0].value).toBe('')
+ expect(cbA).toHaveBeenCalledWith('')
+ expect(cbB).toHaveBeenCalledWith('')
+ })
+
+ it('does not affect nodes without widgets', () => {
+ const node: MockNode = {
+ id: 8,
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+ })
+})
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
new file mode 100644
index 0000000000..daf3ba6299
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
@@ -0,0 +1,40 @@
+import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * Clear widget values that reference deleted assets so the persisted workflow
+ * JSON stops claiming the deleted asset is in use.
+ *
+ * Without this, after `useMediaAssetActions.deleteAssets` succeeds the
+ * in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
+ * widget value still points at the deleted asset. On reload the workflow JSON
+ * is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
+ * output assets the file is still served (history-soft-delete), so the
+ * preview re-renders despite the asset being "deleted" everywhere else.
+ *
+ * Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
+ * `widgets_values`) and invokes `widget.callback` so widgets like Load Image
+ * run their own change-handling (clearing `node.imgs`, calling
+ * `setNodeOutputs`, etc.).
+ *
+ * FE-230 — covers the post-reload case without re-introducing
+ * useMissingMediaPreviewSync, which couldn't distinguish deletion from
+ * verification false-positives (e.g. mask-editor saved values).
+ */
+export function clearDeletedAssetWidgetValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ if (!node.widgets) continue
+ for (const widget of node.widgets) {
+ if (typeof widget.value !== 'string') continue
+ if (!deletedValues.has(widget.value)) continue
+ widget.value = ''
+ widget.callback?.('')
+ }
+ node.graph?.setDirtyCanvas(true)
+ }
+}
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
new file mode 100644
index 0000000000..4d772db7f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+
+import {
+ clearNodePreviewCacheForValues,
+ findNodesReferencingValues
+} from './clearNodePreviewCacheForValues'
+
+type MockWidget = { name: string; value: unknown }
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ imgs?: unknown
+ videoContainer?: unknown
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearNodePreviewCacheForValues', () => {
+ it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 7,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves unrelated nodes untouched', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 8,
+ widgets: [{ name: 'image', value: 'unrelated.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted value set is empty', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 9,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('matches the [output]-annotated form for output assets', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 12,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches the subfolder-prefixed annotated form when provided', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 13,
+ widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const remove = vi.fn()
+ const inputNode: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:input' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const outputNode: MockNode = {
+ id: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:output' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inputNode.imgs).toBeUndefined()
+ expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
+ expect(remove).toHaveBeenCalledWith(inputNode)
+ expect(remove).not.toHaveBeenCalledWith(outputNode)
+ })
+
+ it('also clears videoContainer for video previews', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 15,
+ widgets: [{ name: 'video', value: 'clip.mp4' }],
+ videoContainer: { foo: 'bar' },
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.videoContainer).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches any widget on the node, not just "image"', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 10,
+ widgets: [
+ { name: 'seed', value: 42 },
+ { name: 'video', value: 'clip.mp4' }
+ ],
+ imgs: [{ src: 'blob:videostale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('walks subgraph interiors and matches nested nodes', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'nested.png [output]' }],
+ imgs: [{ src: 'blob:nested' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ const remove = vi.fn()
+
+ clearNodePreviewCacheForValues(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inner.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(inner)
+ })
+})
+
+describe('FE-230 findNodesReferencingValues', () => {
+ it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+
+ const matches = findNodesReferencingValues(
+ makeGraph([wrapper]),
+ new Set(['foo.png'])
+ )
+
+ expect(matches).toEqual([inner])
+ })
+})
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
new file mode 100644
index 0000000000..bbd6d9c5f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
@@ -0,0 +1,65 @@
+import type {
+ LGraph,
+ LGraphNode,
+ Subgraph
+} from '@/lib/litegraph/src/litegraph'
+import { collectAllNodes } from '@/utils/graphTraversalUtil'
+
+/**
+ * Clear cached Load Image / Load Video preview state on any node whose widget
+ * value matches one of the given values. Covers:
+ * - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
+ * - the Vue preview source — must be cleared via `removeOutputsForNode`
+ * so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
+ * not just the legacy `app.nodeOutputs` mirror
+ *
+ * Comparison is full-string against the widget value as stored — callers must
+ * provide the canonical widget-value variants for each deleted asset (e.g.
+ * `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, ``). This
+ * avoids false matches when two distinct assets share a basename across
+ * input/output sources.
+ *
+ * Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
+ * Video nodes inside subgraphs are also matched.
+ *
+ * FE-230 — invoked after successful asset deletion so the Load Image / Load
+ * Video node preview does not keep displaying a thumbnail for an asset that
+ * no longer exists.
+ */
+export function clearNodePreviewCacheForValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet,
+ removeOutputsForNode: (node: LGraphNode) => void
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ removeOutputsForNode(node)
+ node.imgs = undefined
+ node.videoContainer = undefined
+ node.graph?.setDirtyCanvas(true)
+ }
+}
+
+/**
+ * Walk the graph hierarchy and yield each leaf node whose widget value matches
+ * one of `deletedValues`. Used by both the preview-clearing path and the
+ * missing-media-marking path so the two stay in lockstep.
+ *
+ * Skips subgraph wrapper nodes — only their interior nodes are inspected.
+ */
+export function findNodesReferencingValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): LGraphNode[] {
+ if (deletedValues.size === 0) return []
+ const matches: LGraphNode[] = []
+ for (const node of collectAllNodes(rootGraph)) {
+ if (!node.widgets?.length) continue
+ if (node.isSubgraphNode?.()) continue
+ const referencesDeleted = node.widgets.some(
+ (w) => typeof w.value === 'string' && deletedValues.has(w.value)
+ )
+ if (referencesDeleted) matches.push(node)
+ }
+ return matches
+}
diff --git a/src/platform/assets/utils/createModelNodeFromAsset.test.ts b/src/platform/assets/utils/createModelNodeFromAsset.test.ts
index cdc42dc397..785fa0c82f 100644
--- a/src/platform/assets/utils/createModelNodeFromAsset.test.ts
+++ b/src/platform/assets/utils/createModelNodeFromAsset.test.ts
@@ -299,7 +299,7 @@ describe('createModelNodeFromAsset', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
- it.each([
+ it.for([
{
case: 'missing user_metadata with no fallback',
overrides: { user_metadata: undefined, metadata: undefined, name: '' },
@@ -329,7 +329,7 @@ describe('createModelNodeFromAsset', () => {
}
}
)
- it.each([
+ it.for([
{
case: 'no tags',
overrides: { tags: undefined },
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
new file mode 100644
index 0000000000..705d65499e
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
@@ -0,0 +1,185 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+
+import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: true
+}))
+
+const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
+ scanNodeMediaCandidates: mockScanNodeMediaCandidates
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({ currentGraph: null })
+}))
+
+function makeGraph(nodes: unknown[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockScanNodeMediaCandidates.mockReset()
+ mockScanNodeMediaCandidates.mockReturnValue([])
+ })
+
+ it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
+ const node = {
+ id: 1,
+ type: 'LoadImage',
+ widgets: [
+ { name: 'image', value: 'sub/foo.png [output]' },
+ { name: 'mask', value: 'unrelated.png' }
+ ]
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]'
+ },
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'mask',
+ mediaType: 'image',
+ name: 'unrelated.png'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const inputNode = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const outputNode = {
+ id: 3,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png'])
+ )
+
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
+ expect.anything(),
+ inputNode,
+ true
+ )
+ })
+
+ it('skips nodes with NEVER or BYPASS mode', () => {
+ const bypassed = {
+ id: 4,
+ type: 'LoadImage',
+ mode: 4,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+ const never = {
+ id: 5,
+ type: 'LoadImage',
+ mode: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([bypassed, never]),
+ new Set(['foo.png [output]'])
+ )
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('walks subgraph interiors and marks nested nodes', () => {
+ const inner = {
+ id: 100,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'nested.png [output]' }]
+ }
+ const wrapper = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('is a no-op when no nodes reference any deleted value', () => {
+ const node = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'kept.png' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('does nothing when the deleted value set is empty', () => {
+ markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+})
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
new file mode 100644
index 0000000000..800e685147
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
@@ -0,0 +1,50 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import { isCloud } from '@/platform/distribution/types'
+import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * After a successful asset deletion, surface the affected Load Image / Load
+ * Video / Load Audio nodes through the missing-media store. Without this, UI
+ * surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
+ * widget dropdown) keep listing the deleted asset because the verification
+ * pipeline only runs on workflow load — there is no signal that the live
+ * deletion just invalidated some references.
+ *
+ * Walks the full graph hierarchy (including subgraphs) and skips bypassed /
+ * never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
+ * path stays in lockstep with the workflow-load verification.
+ *
+ * Comparison is full-string against the widget value, so two distinct assets
+ * that share a basename across input/output sources do not cross-match.
+ */
+export function markDeletedAssetsAsMissingMedia(
+ rootGraph: LGraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+
+ const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
+ if (!matchedNodes.length) return
+
+ const candidates: MissingMediaCandidate[] = []
+ for (const node of matchedNodes) {
+ if (
+ node.mode === LGraphEventMode.NEVER ||
+ node.mode === LGraphEventMode.BYPASS
+ )
+ continue
+ for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
+ if (!deletedValues.has(candidate.name)) continue
+ candidates.push({ ...candidate, isMissing: true })
+ }
+ }
+
+ if (candidates.length) {
+ useMissingMediaStore().addMissingMedia(candidates)
+ }
+}
diff --git a/src/platform/cloud/subscription/utils/tierBenefits.test.ts b/src/platform/cloud/subscription/utils/tierBenefits.test.ts
index 81adab9d82..815539654d 100644
--- a/src/platform/cloud/subscription/utils/tierBenefits.test.ts
+++ b/src/platform/cloud/subscription/utils/tierBenefits.test.ts
@@ -45,7 +45,7 @@ describe('getCommonTierBenefits', () => {
expect(benefits.some((b) => b.key === 'monthlyCredits')).toBe(false)
})
- it.each(['free', 'standard', 'creator', 'pro', 'founder'] as const)(
+ it.for(['free', 'standard', 'creator', 'pro', 'founder'] as const)(
'includes a tier-scoped maxDuration metric for %s',
(tier) => {
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
@@ -70,7 +70,7 @@ describe('getCommonTierBenefits', () => {
})
})
- it.each(['standard', 'creator', 'pro', 'founder'] as const)(
+ it.for(['standard', 'creator', 'pro', 'founder'] as const)(
'adds the addCredits benefit for %s tier',
(tier) => {
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
@@ -83,7 +83,7 @@ describe('getCommonTierBenefits', () => {
expect(freeBenefits.some((b) => b.key === 'addCredits')).toBe(false)
})
- it.each(['creator', 'pro'] as const)(
+ it.for(['creator', 'pro'] as const)(
'includes customLoRAs for %s tier',
(tier) => {
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
@@ -91,7 +91,7 @@ describe('getCommonTierBenefits', () => {
}
)
- it.each(['free', 'standard', 'founder'] as const)(
+ it.for(['free', 'standard', 'founder'] as const)(
'omits customLoRAs for %s tier',
(tier) => {
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
diff --git a/src/platform/keybindings/keyCombo.test.ts b/src/platform/keybindings/keyCombo.test.ts
index 6fe8ecc92c..082dd3993c 100644
--- a/src/platform/keybindings/keyCombo.test.ts
+++ b/src/platform/keybindings/keyCombo.test.ts
@@ -15,7 +15,7 @@ describe('KeyComboImpl', () => {
}
describe('getKeySequences', () => {
- it.each([
+ it.for([
{
event: { key: 'Shift', shiftKey: true },
expected: ['Shift'],
@@ -66,7 +66,7 @@ describe('KeyComboImpl', () => {
})
describe('isBrowserReserved', () => {
- it.each([
+ it.for([
{ key: 't', ctrl: true, label: 'Ctrl + t' },
{ key: 'w', ctrl: true, label: 'Ctrl + w' },
{ key: 'F12', label: 'F12' },
@@ -83,7 +83,7 @@ describe('KeyComboImpl', () => {
expect(combo.isBrowserReserved).toBe(true)
})
- it.each([
+ it.for([
{ key: 'k', ctrl: true, label: 'Ctrl + k' },
{ key: 's', alt: true, label: 'Alt + s' },
{ key: 'z', ctrl: true, label: 'Ctrl + z' },
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.test.ts b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
new file mode 100644
index 0000000000..d1393df680
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+
+describe('normalizeAnnotatedMediaPathForDetection', () => {
+ it.for([
+ ['photo.png [input]', 'photo.png'],
+ ['result.png [output]', 'result.png'],
+ ['photo.png [input]', 'photo.png'],
+ ['with spaces.png [output]', 'with spaces.png'],
+ ['nested/folder/video.mp4 [output]', 'nested/folder/video.mp4']
+ ])('strips Core-style annotation from %s', ([value, expected]) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(expected)
+ })
+
+ it.for([
+ ['photo.png[input]', 'photo.png'],
+ ['result.png[output]', 'result.png'],
+ ['with spaces.png [output]', 'with spaces.png']
+ ])('strips Cloud compact annotation from %s', ([value, expected]) => {
+ expect(
+ normalizeAnnotatedMediaPathForDetection(value, {
+ allowCompactSuffix: true
+ })
+ ).toBe(expected)
+ })
+
+ it('does not strip compact annotations in Core mode', () => {
+ expect(normalizeAnnotatedMediaPathForDetection('photo.png[input]')).toBe(
+ 'photo.png[input]'
+ )
+ })
+
+ it.for(['photo.png [draft]', 'photo [output] copy.png', 'photo.png', ''])(
+ 'leaves non-matching values unchanged: %s',
+ (value) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(value)
+ }
+ )
+})
+
+describe('getMediaPathDetectionNames', () => {
+ it('returns raw and normalized names when an annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png [input]')).toEqual([
+ 'photo.png [input]',
+ 'photo.png'
+ ])
+ })
+
+ it('returns only the raw name when no annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png')).toEqual(['photo.png'])
+ })
+})
+
+describe('getAnnotatedMediaPathTypeForDetection', () => {
+ it.for([
+ ['photo.png [input]', 'input'],
+ ['photo.png [output]', 'output']
+ ])('returns the Core-style annotation type from %s', ([value, expected]) => {
+ expect(getAnnotatedMediaPathTypeForDetection(value)).toBe(expected)
+ })
+
+ it('returns the compact annotation type in Cloud mode', () => {
+ expect(
+ getAnnotatedMediaPathTypeForDetection('photo.png[output]', {
+ allowCompactSuffix: true
+ })
+ ).toBe('output')
+ })
+
+ it('returns undefined when no supported annotation is present', () => {
+ expect(getAnnotatedMediaPathTypeForDetection('photo.png [draft]')).toBe(
+ undefined
+ )
+ })
+})
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.ts b/src/platform/missingMedia/mediaPathDetectionUtil.ts
new file mode 100644
index 0000000000..2e27311f08
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.ts
@@ -0,0 +1,44 @@
+// Missing-media-scoped helpers for deriving comparison keys from media widget paths.
+const CORE_ANNOTATED_MEDIA_PATTERN = /\s+\[(input|output)\]$/
+const CLOUD_ANNOTATED_MEDIA_PATTERN = /\s*\[(input|output)\]$/
+
+type AnnotatedMediaPathType = 'input' | 'output'
+
+interface AnnotatedMediaPathOptions {
+ allowCompactSuffix?: boolean
+}
+
+function getAnnotatedMediaPathMatch(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): RegExpMatchArray | null {
+ const pattern = options.allowCompactSuffix
+ ? CLOUD_ANNOTATED_MEDIA_PATTERN
+ : CORE_ANNOTATED_MEDIA_PATTERN
+ return value.match(pattern)
+}
+
+export function getAnnotatedMediaPathTypeForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): AnnotatedMediaPathType | undefined {
+ return getAnnotatedMediaPathMatch(value, options)?.[1] as
+ | AnnotatedMediaPathType
+ | undefined
+}
+
+export function normalizeAnnotatedMediaPathForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string {
+ const match = getAnnotatedMediaPathMatch(value, options)
+ return match ? value.slice(0, match.index) : value
+}
+
+export function getMediaPathDetectionNames(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string[] {
+ const normalized = normalizeAnnotatedMediaPathForDetection(value, options)
+ return normalized === value ? [value] : [value, normalized]
+}
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.test.ts b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
new file mode 100644
index 0000000000..c6eee64c47
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
@@ -0,0 +1,325 @@
+import { fromAny } from '@total-typescript/shoehorn'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
+
+vi.mock('@/platform/assets/services/assetService', async () => {
+ const actual = await vi.importActual(
+ '@/platform/assets/services/assetService'
+ )
+
+ return {
+ ...actual,
+ assetService: {
+ ...actual.assetService,
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
+ }
+ }
+})
+
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
+function makeAsset(name: string, assetHash: string | null = null): AssetItem {
+ return {
+ id: name,
+ name,
+ asset_hash: assetHash,
+ mime_type: null,
+ tags: ['input']
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
+function makeHistoryPage(
+ jobs: JobListItem[],
+ options: { offset?: number; hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ jobs,
+ total: options.total ?? jobs.length,
+ offset: options.offset ?? 0,
+ limit: 200,
+ hasMore: options.hasMore ?? false
+ }
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+describe('resolveMissingMediaAssetSources', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
+ })
+
+ it('loads cloud input assets when requested', async () => {
+ const inputAsset = makeAsset('photo.png')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+
+ expect(result.inputAssets).toEqual([inputAsset])
+ expect(result.generatedAssets).toEqual([])
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('loads cloud output assets by tag when generated candidates need verification', async () => {
+ const outputAsset = makeAsset('output.png')
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['output.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([outputAsset])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading cloud output asset pages once all requested names are found', async () => {
+ const target = 'target-output.png'
+ mockGetAssetsPageByTag.mockResolvedValueOnce(
+ makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([makeAsset(target)])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+ })
+
+ it('aborts cloud output asset loading when input asset loading fails', async () => {
+ const inputError = new Error('input failed')
+ let rejectInputAssets!: (err: Error) => void
+ let resolveOutputAssets!: (page: ReturnType) => void
+ mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
+ new Promise((_, reject) => {
+ rejectInputAssets = reject
+ })
+ )
+ mockGetAssetsPageByTag.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOutputAssets = resolve
+ })
+ )
+
+ const promise = resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['target.png']),
+ allowCompactSuffix: true
+ })
+
+ await Promise.resolve()
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+
+ rejectInputAssets(inputError)
+ await expect(promise).rejects.toBe(inputError)
+
+ resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
+ await Promise.resolve()
+
+ const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
+ expect(outputSignal).toBeInstanceOf(AbortSignal)
+ expect(outputSignal.aborted).toBe(true)
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading generated history once all requested names are found', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ hasMore: true,
+ total: 400
+ })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(result.generatedAssets[0].name).toBe(target)
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('advances pagination from the requested offset, not the echoed offset', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage(
+ Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ { offset: 0, hasMore: true, total: 201 }
+ )
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ offset: 0,
+ hasMore: true,
+ total: 201
+ })
+ )
+
+ await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ })
+
+ it('stops if history reports hasMore but returns an empty page', async () => {
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([], { hasMore: true, total: 1 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([])
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('stops if history repeats the same job page', async () => {
+ const repeatedJob = makeHistoryJob('other.png', { id: 'same-job' })
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { hasMore: true, total: 2 })
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { offset: 1, hasMore: true, total: 2 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
+ })
+
+ it('includes slash and backslash subfolder identifiers for detection', () => {
+ const names = getAssetDetectionNames(
+ {
+ ...makeAsset('child\\photo.png', 'hash.png'),
+ user_metadata: { subfolder: 'nested\\folder' }
+ },
+ { allowCompactSuffix: true }
+ )
+
+ expect(names).toEqual(
+ expect.arrayContaining([
+ 'child\\photo.png',
+ 'hash.png',
+ 'nested/folder/child/photo.png',
+ 'nested\\folder\\child\\photo.png'
+ ])
+ )
+ expect(names).not.toContain('nested/folder/hash.png')
+ expect(names).not.toContain('nested\\folder\\hash.png')
+ })
+})
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.ts b/src/platform/missingMedia/missingMediaAssetResolver.ts
new file mode 100644
index 0000000000..00732f8dc5
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.ts
@@ -0,0 +1,286 @@
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { assetService } from '@/platform/assets/services/assetService'
+import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
+import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
+
+const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
+const CLOUD_OUTPUT_ASSETS_PAGE_SIZE = 500
+
+interface MediaPathDetectionOptions {
+ allowCompactSuffix: boolean
+}
+
+export interface MissingMediaAssetSources {
+ inputAssets: AssetItem[]
+ generatedAssets: AssetItem[]
+}
+
+export interface ResolveMissingMediaAssetSourcesOptions {
+ signal?: AbortSignal
+ isCloud: boolean
+ includeGeneratedAssets: boolean
+ generatedMatchNames: ReadonlySet
+ allowCompactSuffix: boolean
+}
+
+export type MissingMediaAssetResolver = (
+ options: ResolveMissingMediaAssetSourcesOptions
+) => Promise
+
+export async function resolveMissingMediaAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets,
+ generatedMatchNames,
+ allowCompactSuffix
+}: ResolveMissingMediaAssetSourcesOptions): Promise {
+ const pathOptions = { allowCompactSuffix }
+
+ const controller = new AbortController()
+ const abortFromCaller = () => controller.abort(signal?.reason)
+ if (signal?.aborted) {
+ abortFromCaller()
+ } else {
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
+ }
+
+ try {
+ const [inputAssets, generatedAssets] = await Promise.all([
+ abortSiblingsOnFailure(
+ isCloud
+ ? assetService.getInputAssetsIncludingPublic(controller.signal)
+ : Promise.resolve([]),
+ controller
+ ),
+ abortSiblingsOnFailure(
+ includeGeneratedAssets
+ ? fetchGeneratedAssets(controller.signal, {
+ isCloud,
+ generatedMatchNames,
+ pathOptions
+ })
+ : Promise.resolve([]),
+ controller
+ )
+ ])
+
+ return { inputAssets, generatedAssets }
+ } finally {
+ signal?.removeEventListener('abort', abortFromCaller)
+ }
+}
+
+interface FetchGeneratedAssetsOptions {
+ isCloud: boolean
+ generatedMatchNames: ReadonlySet
+ pathOptions: MediaPathDetectionOptions
+}
+
+export function getAssetDetectionNames(
+ asset: AssetItem,
+ options: MediaPathDetectionOptions
+): string[] {
+ const names = new Set()
+ // Treat names and hashes as opaque match keys because Cloud may use either in widget values.
+ addPathDetectionNames(names, asset.asset_hash, options)
+ addPathDetectionNames(names, asset.name, options)
+
+ const subfolder = asset.user_metadata?.subfolder
+ if (typeof subfolder === 'string' && subfolder) {
+ addSubfolderPathDetectionNames(names, subfolder, asset.name, options)
+ }
+
+ return Array.from(names)
+}
+
+async function fetchGeneratedAssets(
+ signal: AbortSignal | undefined,
+ { isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
+): Promise {
+ if (isCloud) {
+ return await fetchCloudGeneratedAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+ }
+
+ return await fetchGeneratedHistoryAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+}
+
+async function fetchCloudGeneratedAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const assetPage = await assetService.getAssetsPageByTag('output', true, {
+ limit: CLOUD_OUTPUT_ASSETS_PAGE_SIZE,
+ offset,
+ signal
+ })
+
+ signal?.throwIfAborted()
+
+ const batch = assetPage.assets
+ if (batch.length === 0) return assets
+
+ for (const asset of batch) {
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !assetPage.has_more ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset += batch.length
+ }
+}
+
+async function fetchGeneratedHistoryAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ const seenJobIds = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const requestedOffset = offset
+ const historyPage = await fetchHistoryPage(
+ api.fetchApi.bind(api),
+ HISTORY_MEDIA_ASSETS_PAGE_SIZE,
+ requestedOffset
+ )
+
+ signal?.throwIfAborted()
+
+ let newJobCount = 0
+ for (const job of historyPage.jobs) {
+ if (seenJobIds.has(job.id)) continue
+ seenJobIds.add(job.id)
+ newJobCount += 1
+
+ const asset = mapHistoryJobToAsset(job)
+ if (!asset) continue
+
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !historyPage.hasMore ||
+ historyPage.jobs.length === 0 ||
+ newJobCount === 0 ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset = requestedOffset + historyPage.jobs.length
+ }
+}
+
+async function abortSiblingsOnFailure(
+ promise: Promise,
+ controller: AbortController
+): Promise {
+ try {
+ return await promise
+ } catch (err) {
+ if (!controller.signal.aborted) controller.abort(err)
+ throw err
+ }
+}
+
+function addPathDetectionNames(
+ names: Set,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+ for (const name of getMediaPathDetectionNames(value, options)) {
+ names.add(name)
+ }
+}
+
+function addSubfolderPathDetectionNames(
+ names: Set,
+ subfolder: string,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+
+ const filePath = joinFilePath(subfolder, value)
+ for (const path of getFilePathSeparatorVariants(filePath)) {
+ addPathDetectionNames(names, path, options)
+ }
+}
+
+function rememberResolvedTargetNames(
+ asset: AssetItem,
+ targetNames: ReadonlySet,
+ foundTargetNames: Set,
+ options: MediaPathDetectionOptions
+) {
+ if (targetNames.size === 0) return
+
+ for (const name of getAssetDetectionNames(asset, options)) {
+ if (targetNames.has(name)) foundTargetNames.add(name)
+ }
+}
+
+function hasResolvedAllTargetNames(
+ targetNames: ReadonlySet,
+ foundTargetNames: ReadonlySet
+): boolean {
+ return targetNames.size > 0 && foundTargetNames.size === targetNames.size
+}
+
+function mapHistoryJobToAsset(job: JobListItem): AssetItem | null {
+ const output = job.preview_output
+ if (job.status !== 'completed' || !output?.filename) return null
+
+ return {
+ id: `${job.id}-${output.filename}`,
+ name: output.filename,
+ display_name: output.display_name,
+ mime_type: null,
+ tags: ['output'],
+ user_metadata: {
+ subfolder: output.subfolder
+ }
+ }
+}
diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts
index 275e2450d9..80580a8960 100644
--- a/src/platform/missingMedia/missingMediaScan.test.ts
+++ b/src/platform/missingMedia/missingMediaScan.test.ts
@@ -6,17 +6,26 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
- verifyCloudMediaCandidates,
+ verifyMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
-const { mockGetInputAssetsIncludingPublic } = vi.hoisted(() => ({
- mockGetInputAssetsIncludingPublic: vi.fn()
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
@@ -36,11 +45,23 @@ vi.mock('@/platform/assets/services/assetService', async () => {
...actual,
assetService: {
...actual.assetService,
- getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
}
}
})
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
function makeCandidate(
nodeId: string,
name: string,
@@ -100,6 +121,43 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
}
}
+function makeAssetResolver(
+ inputAssets: AssetItem[],
+ generatedAssets: AssetItem[] = []
+): MissingMediaAssetResolver {
+ return vi.fn(async () => ({ inputAssets, generatedAssets }))
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -145,6 +203,173 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
+
+ it.for([false, true])(
+ 'returns empty while a media upload is pending on the node (isCloud: %s)',
+ (isCloud) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadVideo',
+ [makeMediaCombo('file', 'clip.mp4', [])],
+ 0
+ )
+ node.isUploading = true
+
+ const result = scanNodeMediaCandidates(graph, node, isCloud)
+
+ expect(result).toEqual([])
+ }
+ )
+
+ it('detects missing media again after upload state clears', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadVideo',
+ [makeMediaCombo('file', 'clip.mp4', [])],
+ 0
+ )
+
+ node.isUploading = true
+ expect(scanNodeMediaCandidates(graph, node, false)).toEqual([])
+
+ node.isUploading = false
+ expect(scanNodeMediaCandidates(graph, node, false)).toEqual([
+ expect.objectContaining({
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ name: 'clip.mp4',
+ isMissing: true
+ })
+ ])
+ })
+
+ it.for([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'photo.png [input]',
+ option: 'photo.png'
+ },
+ {
+ nodeType: 'LoadImageMask',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'mask.png [input]',
+ option: 'mask.png'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ value: 'clip.mp4 [input]',
+ option: 'clip.mp4'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ value: 'sound.wav [input]',
+ option: 'sound.wav'
+ }
+ ])(
+ 'matches annotated $nodeType values against clean OSS options',
+ ({ nodeType, widgetName, mediaType, value, option }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, [option])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ mediaType,
+ name: value,
+ isMissing: false
+ })
+ }
+ )
+
+ it.for([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ value: 'photo.png [output]'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ value: 'clip.mp4 [output]'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ value: 'sound.wav [output]'
+ }
+ ])(
+ 'leaves OSS $nodeType output annotations pending when not in options',
+ ({ nodeType, widgetName, value }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, ['other-file.png', value])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ name: value,
+ isMissing: undefined
+ })
+ }
+ )
+
+ it('marks OSS input annotations missing when the clean option is absent', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: true
+ })
+ })
+
+ it('does not treat compact Cloud annotations as valid OSS options', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png[input]',
+ isMissing: true
+ })
+ })
})
describe('scanAllMediaCandidates', () => {
@@ -261,7 +486,7 @@ describe('groupCandidatesByMediaType', () => {
})
})
-describe('verifyCloudMediaCandidates', () => {
+describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
@@ -270,6 +495,14 @@ describe('verifyCloudMediaCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue({
+ jobs: [],
+ total: 0,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
})
it('matches candidates by available input asset name or hash', async () => {
@@ -278,16 +511,25 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('2', existingHash, { isMissing: undefined }),
makeCandidate('3', missingHash, { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => [
+ const resolveAssetSources = makeAssetResolver([
makeAsset('photo.png', existingHash)
])
- await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(false)
expect(candidates[2].isMissing).toBe(true)
- expect(fetchInputAssets).toHaveBeenCalledOnce()
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
})
it('matches asset names when asset_hash is null', async () => {
@@ -295,22 +537,202 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => [
+ const resolveAssetSources = makeAssetResolver([
makeAsset('legacy-photo.png', null)
])
- await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
+ it('matches annotated candidate names against clean asset names', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
+ makeCandidate('2', 'clip.mp4[input]', {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ isMissing: undefined
+ }),
+ makeCandidate('3', 'missing.wav [output]', {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ isMissing: undefined
+ })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [makeAsset('photo.png'), makeAsset('clip.mp4')],
+ []
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: false
+ })
+ expect(candidates[1]).toMatchObject({
+ name: 'clip.mp4[input]',
+ isMissing: false
+ })
+ expect(candidates[2]).toMatchObject({
+ name: 'missing.wav [output]',
+ isMissing: true
+ })
+ })
+
+ it('matches output hash filenames against generated media assets', async () => {
+ const candidates = [
+ makeCandidate(
+ '1',
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ {
+ isMissing: undefined
+ }
+ )
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [
+ makeAsset(
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ )
+ ]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ ]),
+ allowCompactSuffix: true
+ })
+ expect(candidates[0]).toMatchObject({
+ name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not satisfy output annotations with input assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('does not satisfy input candidates with output assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('verifies OSS output candidates against generated history without cloud assets', async () => {
+ const candidates = [
+ makeCandidate('1', 'subfolder/photo.png [output]', {
+ isMissing: undefined
+ })
+ ]
+
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
+ total: 1,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
+ expect(mockFetchHistoryPage).toHaveBeenCalledWith(
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(candidates[0]).toMatchObject({
+ name: 'subfolder/photo.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not normalize compact annotations when verifying OSS candidates', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: false,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: false,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: false
+ })
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('matches when the asset identifier itself is annotated', async () => {
+ const candidates = [
+ makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [makeAsset('clip.mp4 [output]')]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
it('marks pending candidates missing when no input assets are available', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, undefined, async () => [])
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources: makeAssetResolver([])
+ })
expect(candidates[0].isMissing).toBe(true)
})
@@ -323,10 +745,104 @@ describe('verifyCloudMediaCandidates', () => {
makeAsset('stored-photo.png', existingHash)
])
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('reads cloud output assets by tag for output candidates', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockGetAssetsPageByTag.mockResolvedValue(
+ makeAssetPage([makeAsset(outputHash)])
+ )
+
+ await verifyMediaCandidates(candidates, { isCloud: true })
+
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('walks OSS generated history pages until hasMore is false', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockFetchHistoryPage
+ .mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 201,
+ offset: 0,
+ limit: 200,
+ hasMore: true
+ })
+ .mockResolvedValueOnce({
+ jobs: [makeHistoryJob(outputHash)],
+ total: 201,
+ offset: 200,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('trusts OSS history hasMore instead of page length', async () => {
+ const candidates = [
+ makeCandidate('1', 'missing-output.png [output]', {
+ isMissing: undefined
+ })
+ ]
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 200,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ expect(candidates[0].isMissing).toBe(true)
})
it('respects abort signal before execution', async () => {
@@ -337,7 +853,10 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', missingHash, { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, controller.signal)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
expect(candidates[0].isMissing).toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -348,16 +867,19 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
- return [makeAsset('stored-photo.png', existingHash)]
+ return {
+ inputAssets: [makeAsset('stored-photo.png', existingHash)],
+ generatedAssets: []
+ }
})
- await verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- fetchInputAssets
- )
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBeUndefined()
})
@@ -365,7 +887,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(true)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -374,7 +896,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -383,7 +905,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
@@ -398,9 +920,11 @@ describe('verifyCloudMediaCandidates', () => {
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
expect(candidates[0].isMissing).toBe(false)
})
@@ -411,17 +935,17 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
- verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- fetchInputAssets
- )
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
@@ -434,18 +958,24 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
- controller.abort()
- throw abortError
- })
+ let serviceSignal: AbortSignal | undefined
+ mockGetInputAssetsIncludingPublic.mockImplementationOnce(
+ async (signal?: AbortSignal) => {
+ serviceSignal = signal
+ controller.abort()
+ throw abortError
+ }
+ )
await expect(
- verifyCloudMediaCandidates(candidates, controller.signal)
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
).resolves.toBeUndefined()
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
- controller.signal
- )
+ expect(serviceSignal).toBeInstanceOf(AbortSignal)
+ expect(serviceSignal?.aborted).toBe(true)
expect(candidates[0].isMissing).toBeUndefined()
})
})
diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts
index b8a2257c64..9adb179f82 100644
--- a/src/platform/missingMedia/missingMediaScan.ts
+++ b/src/platform/missingMedia/missingMediaScan.ts
@@ -19,8 +19,17 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
-import { assetService } from '@/platform/assets/services/assetService'
import { isAbortError } from '@/utils/typeGuardUtil'
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -28,6 +37,7 @@ const MEDIA_NODE_WIDGETS: Record<
{ widgetName: string; mediaType: MediaType }
> = {
LoadImage: { widgetName: 'image', mediaType: 'image' },
+ LoadImageMask: { widgetName: 'image', mediaType: 'image' },
LoadVideo: { widgetName: 'file', mediaType: 'video' },
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
}
@@ -39,7 +49,8 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
- * OSS: `isMissing` resolved immediately via widget options.
+ * OSS: `isMissing` is resolved immediately via widget options unless an
+ * output annotation needs generated-history verification.
* Cloud: `isMissing` left `undefined` for async verification.
*/
export function scanAllMediaCandidates(
@@ -76,6 +87,7 @@ export function scanNodeMediaCandidates(
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
if (!mediaInfo) return []
+ if (node.isUploading) return []
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) return []
@@ -92,8 +104,17 @@ export function scanNodeMediaCandidates(
if (isCloud) {
isMissing = undefined
} else {
- const options = resolveComboValues(widget)
- isMissing = !options.includes(value)
+ const type = getAnnotatedMediaPathTypeForDetection(value)
+ if (type === 'output') {
+ isMissing = undefined
+ } else {
+ const options = resolveComboValues(widget)
+ const detectionNames = getMediaPathDetectionNames(value)
+ const existsInOptions = detectionNames.some((name) =>
+ options.includes(name)
+ )
+ isMissing = !existsInOptions
+ }
}
candidates.push({
@@ -109,29 +130,57 @@ export function scanNodeMediaCandidates(
return candidates
}
-type InputAssetFetcher = (signal?: AbortSignal) => Promise
+interface MediaVerificationOptions {
+ isCloud: boolean
+ signal?: AbortSignal
+ resolveAssetSources?: MissingMediaAssetResolver
+}
/**
- * Verify cloud media candidates against input assets available to the user,
- * including public assets returned by the asset list API.
+ * Verify media candidates against assets available to the current runtime.
*
* A candidate's `name` may be either a filename or an opaque asset hash.
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
- * match against the union of `asset.name` and `asset.asset_hash`.
+ * match against the union of `asset.name` and `asset.asset_hash`. Output
+ * candidates are matched against Cloud output assets or Core generated-history
+ * assets because Core resolves those annotations against output folders, not
+ * input files.
+ * Cloud accepts compact annotated media paths, so only Cloud verification
+ * normalizes compact suffixes.
*/
-export async function verifyCloudMediaCandidates(
+export async function verifyMediaCandidates(
candidates: MissingMediaCandidate[],
- signal?: AbortSignal,
- fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
+ {
+ isCloud,
+ signal,
+ resolveAssetSources = resolveMissingMediaAssetSources
+ }: MediaVerificationOptions
): Promise {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
+ // Core stores spaced annotations such as `file.png [output]`; Cloud also
+ // accepts compact forms such as `file.png[output]`.
+ const pathOptions = { allowCompactSuffix: isCloud }
+ const generatedMatchNames = getGeneratedCandidateMatchNames(
+ pending,
+ pathOptions
+ )
+
let inputAssets: AssetItem[]
+ let generatedAssets: AssetItem[]
try {
- inputAssets = await fetchInputAssets(signal)
+ const assetSources = await resolveAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets: generatedMatchNames.size > 0,
+ generatedMatchNames,
+ allowCompactSuffix: isCloud
+ })
+ inputAssets = assetSources.inputAssets
+ generatedAssets = assetSources.generatedAssets
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
@@ -139,21 +188,62 @@ export async function verifyCloudMediaCandidates(
if (signal?.aborted) return
- const assetIdentifiers = new Set()
- for (const asset of inputAssets) {
- if (asset.asset_hash) assetIdentifiers.add(asset.asset_hash)
- if (asset.name) assetIdentifiers.add(asset.name)
- }
+ const inputAssetIdentifiers = new Set()
+ const outputAssetIdentifiers = new Set()
+ addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
+ addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
for (const candidate of pending) {
- candidate.isMissing = !assetIdentifiers.has(candidate.name)
+ const detectionNames = getMediaPathDetectionNames(
+ candidate.name,
+ pathOptions
+ )
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ const identifiers =
+ type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
+ candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
}
}
-async function fetchMissingInputAssets(
- signal?: AbortSignal
-): Promise {
- return await assetService.getInputAssetsIncludingPublic(signal)
+function getGeneratedCandidateMatchNames(
+ candidates: MissingMediaCandidate[],
+ pathOptions: { allowCompactSuffix: boolean }
+): Set {
+ const names = new Set()
+ for (const candidate of candidates) {
+ if (!isGeneratedCandidate(candidate, pathOptions)) continue
+
+ names.add(
+ normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
+ )
+ }
+ return names
+}
+
+function isGeneratedCandidate(
+ candidate: MissingMediaCandidate,
+ pathOptions: { allowCompactSuffix: boolean }
+): boolean {
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ return type === 'output'
+}
+
+function addAssetIdentifiers(
+ identifiers: Set,
+ assets: AssetItem[],
+ pathOptions: { allowCompactSuffix: boolean }
+) {
+ for (const asset of assets) {
+ for (const name of getAssetDetectionNames(asset, pathOptions)) {
+ identifiers.add(name)
+ }
+ }
}
/** Group confirmed-missing candidates by file name into view models. */
diff --git a/src/platform/missingMedia/types.ts b/src/platform/missingMedia/types.ts
index a07433dc34..8f1f08a69b 100644
--- a/src/platform/missingMedia/types.ts
+++ b/src/platform/missingMedia/types.ts
@@ -16,7 +16,9 @@ export interface MissingMediaCandidate {
/**
* - `true` — confirmed missing
* - `false` — confirmed present
- * - `undefined` — pending async verification (cloud only)
+ * - `undefined` — pending async verification. Cloud candidates start pending;
+ * OSS output annotated paths may also be deferred to generated-history
+ * verification.
*/
isMissing: boolean | undefined
}
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
index 41b01606e2..53ad431f84 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
extractWorkflow,
fetchHistory,
+ fetchHistoryPage,
fetchJobDetail,
fetchQueue
} from '@/platform/remote/comfyui/jobs/fetchJobs'
@@ -29,15 +30,16 @@ function createMockJob(
function createMockResponse(
jobs: RawJobListItem[],
- total: number = jobs.length
+ total: number = jobs.length,
+ pagination: Partial = {}
): JobsListResponse {
return {
jobs,
pagination: {
- offset: 0,
- limit: 200,
+ offset: pagination.offset ?? 0,
+ limit: pagination.limit ?? 200,
total,
- has_more: false
+ has_more: pagination.has_more ?? false
}
}
}
@@ -100,7 +102,8 @@ describe('fetchJobs', () => {
createMockJob('job4', 'completed'),
createMockJob('job5', 'completed')
],
- 10 // total of 10 jobs
+ 10, // total of 10 jobs
+ { offset: 5 }
)
)
})
@@ -185,6 +188,36 @@ describe('fetchJobs', () => {
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
+
+ it('returns server pagination metadata for history pages', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job4', 'completed'),
+ createMockJob('job5', 'completed')
+ ],
+ 10,
+ { offset: 5, limit: 2, has_more: true }
+ )
+ )
+ })
+
+ const result = await fetchHistoryPage(mockFetch, 2, 5)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed,failed,cancelled&limit=2&offset=5'
+ )
+ expect(result.jobs).toHaveLength(2)
+ expect(result.offset).toBe(5)
+ expect(result.limit).toBe(2)
+ expect(result.total).toBe(10)
+ expect(result.hasMore).toBe(true)
+ expect(result.jobs[0].priority).toBe(5)
+ expect(result.jobs[1].priority).toBe(4)
+ })
})
describe('fetchQueue', () => {
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.ts b/src/platform/remote/comfyui/jobs/fetchJobs.ts
index 6eee0e959c..25790a5ecd 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.ts
@@ -22,6 +22,16 @@ interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
+ limit: number
+ hasMore: boolean
+}
+
+export interface FetchHistoryPageResult {
+ jobs: JobListItem[]
+ total: number
+ offset: number
+ limit: number
+ hasMore: boolean
}
/**
@@ -40,13 +50,25 @@ async function fetchJobsRaw(
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
- return { jobs: [], total: 0, offset: 0 }
+ return {
+ jobs: [],
+ total: 0,
+ offset,
+ limit: maxItems,
+ hasMore: false
+ }
}
const data = zJobsListResponse.parse(await res.json())
- return { jobs: data.jobs, total: data.pagination.total, offset }
+ return {
+ jobs: data.jobs,
+ total: data.pagination.total,
+ offset: data.pagination.offset,
+ limit: data.pagination.limit,
+ hasMore: data.pagination.has_more
+ }
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
- return { jobs: [], total: 0, offset: 0 }
+ return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
}
}
@@ -76,14 +98,33 @@ export async function fetchHistory(
maxItems: number = 200,
offset: number = 0
): Promise {
- const { jobs, total } = await fetchJobsRaw(
+ const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
+ return jobs
+}
+
+/**
+ * Fetches one page of history with server-provided pagination metadata.
+ */
+export async function fetchHistoryPage(
+ fetchApi: (url: string) => Promise,
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
offset
)
+
// History gets priority based on total count (lower than queue)
- return assignPriority(jobs, total - offset)
+ return {
+ jobs: assignPriority(result.jobs, result.total - result.offset),
+ total: result.total,
+ offset: result.offset,
+ limit: result.limit,
+ hasMore: result.hasMore
+ }
}
/**
diff --git a/src/platform/settings/components/SettingGroup.vue b/src/platform/settings/components/SettingGroup.vue
index 2ca49f5afd..de78f6938b 100644
--- a/src/platform/settings/components/SettingGroup.vue
+++ b/src/platform/settings/components/SettingGroup.vue
@@ -19,7 +19,7 @@
v-for="setting in group.settings.filter((s) => !s.deprecated)"
:key="setting.id"
:data-setting-id="setting.id"
- class="setting-item mb-2"
+ class="setting-item mb-3"
>
diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts
index 4a6a6f79fa..1e5739b1ec 100644
--- a/src/platform/settings/constants/coreSettings.ts
+++ b/src/platform/settings/constants/coreSettings.ts
@@ -1,4 +1,8 @@
import { LinkMarkerShape, LiteGraph } from '@/lib/litegraph/src/litegraph'
+import {
+ getDefaultLocale,
+ SUPPORTED_LOCALE_OPTIONS
+} from '@/locales/localeConfig'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
@@ -439,21 +443,8 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Locale',
name: 'Language',
type: 'combo',
- options: [
- { value: 'en', text: 'English' },
- { value: 'zh', text: '中文' },
- { value: 'zh-TW', text: '繁體中文' },
- { value: 'ru', text: 'Русский' },
- { value: 'ja', text: '日本語' },
- { value: 'ko', text: '한국어' },
- { value: 'fr', text: 'Français' },
- { value: 'es', text: 'Español' },
- { value: 'ar', text: 'عربي' },
- { value: 'tr', text: 'Türkçe' },
- { value: 'pt-BR', text: 'Português (BR)' },
- { value: 'fa', text: 'فارسی' }
- ],
- defaultValue: () => navigator.language.split('-')[0] || 'en'
+ options: SUPPORTED_LOCALE_OPTIONS,
+ defaultValue: getDefaultLocale
},
{
id: 'Comfy.NodeBadge.NodeSourceBadgeMode',
diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
index a5f507b77b..16a1b0f747 100644
--- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
+++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
@@ -167,13 +167,18 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
expect(mockMixpanel.track).not.toHaveBeenCalled()
})
- it.each([
+ it.for<
+ [
+ 'opened' | 'requested' | 'completed',
+ (typeof TelemetryEvents)[keyof typeof TelemetryEvents]
+ ]
+ >([
['opened' as const, TelemetryEvents.USER_EMAIL_VERIFY_OPENED],
['requested' as const, TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED],
['completed' as const, TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED]
])(
'trackEmailVerification(%s) dispatches %s',
- async (stage, expectedEvent) => {
+ async ([stage, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
@@ -184,13 +189,18 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
}
)
- it.each([
+ it.for<
+ [
+ 'modal_opened' | 'subscribe_clicked',
+ (typeof TelemetryEvents)[keyof typeof TelemetryEvents]
+ ]
+ >([
[
'modal_opened' as const,
TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
],
['subscribe_clicked' as const, TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED]
- ])('trackSubscription(%s) dispatches %s', async (event, expectedEvent) => {
+ ])('trackSubscription(%s) dispatches %s', async ([event, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
@@ -282,7 +292,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
const authMetadata: AuthMetadata = {}
- it.each<
+ it.for<
[string, Trackable, (typeof TelemetryEvents)[keyof typeof TelemetryEvents]]
>([
[
@@ -365,7 +375,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackSignupOpened(),
TelemetryEvents.USER_SIGN_UP_OPENED
]
- ])('%s dispatches %s', async (_name, invoke, expectedEvent) => {
+ ])('%s dispatches %s', async ([_name, invoke, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
diff --git a/src/platform/workflow/core/services/workflowActionsService.test.ts b/src/platform/workflow/core/services/workflowActionsService.test.ts
new file mode 100644
index 0000000000..c2b2e9b659
--- /dev/null
+++ b/src/platform/workflow/core/services/workflowActionsService.test.ts
@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
+import { useWorkflowActionsService } from './workflowActionsService'
+
+const mockPrompt = vi.hoisted(() => vi.fn())
+vi.mock('@/services/dialogService', () => ({
+ useDialogService: () => ({ prompt: mockPrompt })
+}))
+
+const mockGetSetting = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({ get: mockGetSetting })
+}))
+
+const mockDownloadBlob = vi.hoisted(() => vi.fn())
+vi.mock('@/scripts/utils', () => ({
+ downloadBlob: mockDownloadBlob
+}))
+
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({ createTemporary: vi.fn() })
+}))
+
+vi.mock('@/platform/workflow/core/services/workflowService', () => ({
+ useWorkflowService: () => ({ openWorkflow: vi.fn() })
+}))
+
+const minimalWorkflow: ComfyWorkflowJSON = {
+ version: 0.4,
+ last_node_id: 0,
+ last_link_id: 0,
+ nodes: [],
+ links: []
+}
+
+describe('workflowActionsService.exportWorkflowAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns { cancelled: true } when the user dismisses the filename prompt', async () => {
+ mockGetSetting.mockReturnValue(true)
+ mockPrompt.mockResolvedValue(null)
+ const { exportWorkflowAction } = useWorkflowActionsService()
+
+ const result = await exportWorkflowAction(minimalWorkflow, 'wf.json')
+
+ expect(result).toEqual({ success: false, cancelled: true })
+ expect(mockDownloadBlob).not.toHaveBeenCalled()
+ })
+
+ it('downloads with the prompted filename and returns success', async () => {
+ mockGetSetting.mockReturnValue(true)
+ mockPrompt.mockResolvedValue('custom')
+ const { exportWorkflowAction } = useWorkflowActionsService()
+
+ const result = await exportWorkflowAction(minimalWorkflow, 'wf.json')
+
+ expect(result).toEqual({ success: true })
+ expect(mockDownloadBlob).toHaveBeenCalledWith(
+ 'custom.json',
+ expect.any(Blob)
+ )
+ })
+
+ it('skips the prompt and uses the default filename when the setting is off', async () => {
+ mockGetSetting.mockReturnValue(false)
+ const { exportWorkflowAction } = useWorkflowActionsService()
+
+ const result = await exportWorkflowAction(minimalWorkflow, 'default.json')
+
+ expect(result).toEqual({ success: true })
+ expect(mockPrompt).not.toHaveBeenCalled()
+ expect(mockDownloadBlob).toHaveBeenCalledWith(
+ 'default.json',
+ expect.any(Blob)
+ )
+ })
+
+ it('returns the no-workflow error when given null', async () => {
+ const { exportWorkflowAction } = useWorkflowActionsService()
+
+ const result = await exportWorkflowAction(null, 'wf.json')
+
+ expect(result).toEqual({
+ success: false,
+ error: 'No workflow data available'
+ })
+ expect(mockDownloadBlob).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/platform/workflow/core/services/workflowActionsService.ts b/src/platform/workflow/core/services/workflowActionsService.ts
index ffff13fb76..bd7c8d8a6e 100644
--- a/src/platform/workflow/core/services/workflowActionsService.ts
+++ b/src/platform/workflow/core/services/workflowActionsService.ts
@@ -42,6 +42,7 @@ export function useWorkflowActionsService() {
defaultFilename: string
): Promise<{
success: boolean
+ cancelled?: boolean
error?: string
}> => {
if (!workflow) {
@@ -59,7 +60,7 @@ export function useWorkflowActionsService() {
defaultValue: filename
})
// User cancelled the prompt
- if (!input) return { success: false }
+ if (!input) return { success: false, cancelled: true }
filename = appendJsonExt(input)
}
diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.test.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.test.ts
index a5fe0fe6e5..852b3c9beb 100644
--- a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.test.ts
+++ b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.test.ts
@@ -76,15 +76,25 @@ vi.mock(
})
)
+const commandStoreMocks = vi.hoisted(() => ({
+ execute: vi.fn()
+}))
+
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
- execute: vi.fn()
+ execute: commandStoreMocks.execute
})
}))
+const routeMocks = vi.hoisted(() => ({
+ query: {} as Record
+}))
+
vi.mock('vue-router', () => ({
useRoute: () => ({
- query: {}
+ get query() {
+ return routeMocks.query
+ }
}),
useRouter: () => ({
replace: vi.fn()
@@ -97,13 +107,30 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
+const preservedQueryMocks = vi.hoisted(() => ({
+ payloads: {} as Record | undefined>
+}))
+
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
- mergePreservedQueryIntoQuery: vi.fn(() => null)
+ mergePreservedQueryIntoQuery: vi.fn(
+ (namespace: string, query: Record = {}) => {
+ const payload = preservedQueryMocks.payloads[namespace]
+ if (!payload) return undefined
+ const next: Record = { ...query }
+ let changed = false
+ for (const [key, value] of Object.entries(payload)) {
+ if (typeof next[key] === 'string') continue
+ next[key] = value
+ changed = true
+ }
+ return changed ? next : undefined
+ }
+ )
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
- PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
+ PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -178,6 +205,9 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
+ commandStoreMocks.execute.mockReset()
+ routeMocks.query = {}
+ preservedQueryMocks.payloads = {}
})
afterEach(() => {
@@ -357,4 +387,43 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
+
+ describe('loadDefaultWorkflow', () => {
+ it('opens templates browser for first-time users', async () => {
+ const { initializeWorkflow } = useWorkflowPersistenceV2()
+ await initializeWorkflow()
+
+ expect(loadBlankWorkflowMock).toHaveBeenCalled()
+ expect(commandStoreMocks.execute).toHaveBeenCalledWith(
+ 'Comfy.BrowseTemplates'
+ )
+ })
+
+ it('does not open templates browser when share param is in URL', async () => {
+ routeMocks.query = { share: 'test-share-id' }
+
+ const { initializeWorkflow } = useWorkflowPersistenceV2()
+ await initializeWorkflow()
+
+ expect(loadBlankWorkflowMock).toHaveBeenCalled()
+ expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
+ 'Comfy.BrowseTemplates'
+ )
+ })
+
+ it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
+ // No-local-user flow: ?share=... was captured into sessionStorage and the
+ // URL query was dropped during the /user-select redirect before
+ // initializeWorkflow() runs.
+ preservedQueryMocks.payloads.share = { share: 'test-share-id' }
+
+ const { initializeWorkflow } = useWorkflowPersistenceV2()
+ await initializeWorkflow()
+
+ expect(loadBlankWorkflowMock).toHaveBeenCalled()
+ expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
+ 'Comfy.BrowseTemplates'
+ )
+ })
+ })
})
diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
index 4091472d94..561399bccf 100644
--- a/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
+++ b/src/platform/workflow/persistence/composables/useWorkflowPersistenceV2.ts
@@ -48,6 +48,7 @@ export function useWorkflowPersistenceV2() {
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
+ const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
@@ -160,11 +161,20 @@ export function useWorkflowPersistenceV2() {
})
}
+ const hasSharedWorkflowIntent = () => {
+ if (typeof route.query.share === 'string') return true
+ hydratePreservedQuery(SHARE_NAMESPACE)
+ const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
+ return typeof merged?.share === 'string'
+ }
+
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
- await useCommandStore().execute('Comfy.BrowseTemplates')
+ if (!hasSharedWorkflowIntent()) {
+ await useCommandStore().execute('Comfy.BrowseTemplates')
+ }
} else {
await comfyApp.loadGraphData()
}
diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts
index e6c608de47..1df45c09aa 100644
--- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts
+++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts
@@ -199,7 +199,7 @@ describe('parseComfyWorkflow', () => {
'valid/valid',
'valid-username-with-dash/valid_github-repo-name-with-underscore'
]
- it.each(validAuxIds)('valid aux_id: %s', async (aux_id) => {
+ it.for(validAuxIds)('valid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
@@ -210,7 +210,7 @@ describe('parseComfyWorkflow', () => {
'github-name/invalid spaces in repo',
'not-both-names-with-slash'
]
- it.each(invalidAuxIds)('invalid aux_id: %s', async (aux_id) => {
+ it.for(invalidAuxIds)('invalid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
@@ -219,14 +219,14 @@ describe('parseComfyWorkflow', () => {
describe('workflow.nodes.properties.cnr_id', () => {
const validCnrIds = ['valid', 'valid-with-dash', 'valid_with_underscores']
- it.each(validCnrIds)('valid cnr_id: %s', async (cnr_id) => {
+ it.for(validCnrIds)('valid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidCnrIds = ['invalid cnr-id', 'invalid^cnr-id', 'invalid cnr id']
- it.each(invalidCnrIds)('invalid cnr_id: %s', async (cnr_id) => {
+ it.for(invalidCnrIds)('invalid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
@@ -248,7 +248,7 @@ describe('parseComfyWorkflow', () => {
'v0.3.9-7-g1419dee',
'v0.3.9-7-g1419dee-dirty'
]
- it.each(validVersionStrings)('valid version: %s', async (ver) => {
+ it.for(validVersionStrings)('valid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
@@ -262,7 +262,7 @@ describe('parseComfyWorkflow', () => {
// Git hash
'080e6d4af809a46852d1c4b7ed85f06e8a3a72be-invalid'
]
- it.each(invalidVersionStrings)('invalid version: %s', async (ver) => {
+ it.for(invalidVersionStrings)('invalid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
diff --git a/src/platform/workspace/composables/useWorkspaceBilling.test.ts b/src/platform/workspace/composables/useWorkspaceBilling.test.ts
index 41d7785208..4253ece0c5 100644
--- a/src/platform/workspace/composables/useWorkspaceBilling.test.ts
+++ b/src/platform/workspace/composables/useWorkspaceBilling.test.ts
@@ -369,25 +369,22 @@ describe('useWorkspaceBilling', () => {
)
})
- it.each([
+ it.for([
['empty string', ''],
['null', null]
- ])(
- 'does not open a window when API returns %s url',
- async (_label, url) => {
- const openSpy = vi.fn()
- vi.stubGlobal('open', openSpy)
+ ])('does not open a window when API returns %s url', async ([, url]) => {
+ const openSpy = vi.fn()
+ vi.stubGlobal('open', openSpy)
- mockWorkspaceApi.getPaymentPortalUrl.mockResolvedValue({
- url: url as string
- })
+ mockWorkspaceApi.getPaymentPortalUrl.mockResolvedValue({
+ url: url as string
+ })
- const billing = setupBilling()
- await billing.manageSubscription()
+ const billing = setupBilling()
+ await billing.manageSubscription()
- expect(openSpy).not.toHaveBeenCalled()
- }
- )
+ expect(openSpy).not.toHaveBeenCalled()
+ })
it('records error when API call fails', async () => {
mockWorkspaceApi.getPaymentPortalUrl.mockRejectedValue(
diff --git a/src/renderer/core/canvas/pathRenderer.test.ts b/src/renderer/core/canvas/pathRenderer.test.ts
index 031c472e7a..5d14b09e51 100644
--- a/src/renderer/core/canvas/pathRenderer.test.ts
+++ b/src/renderer/core/canvas/pathRenderer.test.ts
@@ -940,7 +940,7 @@ describe('CanvasPathRenderer', () => {
})
describe('direction offsets', () => {
- it.each([
+ it.for([
{ dir: 'left', expectedInnerA: [-15, 0] },
{ dir: 'right', expectedInnerA: [15, 0] },
{ dir: 'up', expectedInnerA: [0, -15] },
diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
index b45fce2fe1..7a1f2e6c1e 100644
--- a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
+++ b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
@@ -134,6 +134,29 @@ describe('ImagePreview', () => {
screen.getByRole('button', { name: 'Edit or mask image' })
})
+ it('hides mask and download buttons when image fails to load', async () => {
+ renderImagePreview({
+ imageUrls: [defaultProps.imageUrls[0]]
+ })
+
+ expect(
+ screen.getByRole('button', { name: 'Edit or mask image' })
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Download image' })
+ ).toBeInTheDocument()
+
+ await fireEvent.error(screen.getByTestId('main-image'))
+ await nextTick()
+
+ expect(
+ screen.queryByRole('button', { name: 'Edit or mask image' })
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Download image' })
+ ).not.toBeInTheDocument()
+ })
+
it('handles download button click', async () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue
index 5548f55d40..1fe1b8162e 100644
--- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue
+++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue
@@ -80,7 +80,7 @@
>
{
mockClientPosToCanvasPos.mockClear()
})
- it.each([
+ it.for([
{ type: 'input' as const, isInput: true },
{ type: 'output' as const, isInput: false }
])('cleans up $type slot layout on unmount', async ({ type, isInput }) => {
diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue
index 2580fd6a5b..8348bf45aa 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue
@@ -9,6 +9,8 @@ const {
searcher = async () => {},
updateKey,
autofocus = false,
+ debounceMs = 250,
+ debounceMaxWaitMs = 1000,
class: customClass
} = defineProps<{
searcher?: (
@@ -17,14 +19,16 @@ const {
) => Promise
updateKey?: MaybeRefOrGetter
autofocus?: boolean
+ debounceMs?: number
+ debounceMaxWaitMs?: number
class?: HTMLAttributes['class']
}>()
const searchQuery = defineModel({ default: '' })
const isQuerying = ref(false)
-const debouncedSearchQuery = refDebounced(searchQuery, 250, {
- maxWait: 1000
+const debouncedSearchQuery = refDebounced(searchQuery, debounceMs, {
+ maxWait: debounceMaxWaitMs
})
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
index 6d514c096f..08cede8aa0 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
@@ -349,7 +349,7 @@ describe('useComboWidget', () => {
const HASH_FILENAME_2 =
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
- it.each([
+ it.for([
{ nodeClass: 'LoadImage', inputName: 'image' },
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
index 924a3a6f65..5b6bff0145 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
@@ -871,4 +871,136 @@ describe('useWidgetSelectItems', () => {
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
})
})
+
+ describe('FE-230 missing-media filtering', () => {
+ it('drops input items whose name is in the missing-media store', async () => {
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'photo_abc.jpg',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('photo_abc.jpg')
+ expect(names).toContain('img_001.png')
+ })
+
+ it('drops output items whose annotated path is in the missing-media store', async () => {
+ mockMediaAssets = createMockMediaAssets()
+ mockMediaAssets.media.value = [
+ {
+ id: 'a1',
+ name: 'gone.png',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem,
+ {
+ id: 'a2',
+ name: 'kept.png',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem
+ ]
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '7',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'gone.png [output]',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(
+ createDefaultOptions({
+ values: () => [],
+ outputMediaAssets: mockMediaAssets
+ })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('gone.png [output]')
+ expect(names).toContain('kept.png [output]')
+ })
+
+ it('does not cross-match basenames across input and output sources', async () => {
+ mockMediaAssets = createMockMediaAssets()
+ mockMediaAssets.media.value = [
+ {
+ id: 'a1',
+ name: 'photo_abc.jpg',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem
+ ]
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'photo_abc.jpg',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(
+ createDefaultOptions({ outputMediaAssets: mockMediaAssets })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('photo_abc.jpg')
+ expect(names).toContain('photo_abc.jpg [output]')
+ })
+
+ it('does not surface a missing-value placeholder when the modelValue is confirmed missing', async () => {
+ const modelValue = ref('gone.png [output]')
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '7',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'gone.png [output]',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems, selectedSet } = useWidgetSelectItems(
+ createDefaultOptions({ modelValue, values: () => [] })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('gone.png [output]')
+ expect(selectedSet.value.size).toBe(0)
+ })
+ })
})
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
index 86a11321aa..8450bb0f46 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
@@ -5,6 +5,7 @@ import type { MaybeRefOrGetter, Ref } from 'vue'
import { t } from '@/i18n'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
filterItemByBaseModels,
filterItemByOwnership
@@ -72,6 +73,14 @@ interface UseWidgetSelectItemsOptions {
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const { modelValue, outputMediaAssets, assetData } = options
+ const missingMediaStore = useMissingMediaStore()
+ const missingMediaValues = computed>(
+ () =>
+ new Set(
+ missingMediaStore.missingMediaCandidates?.map((c) => c.name) ?? []
+ )
+ )
+
const filterSelected = ref('all')
const filterOptions = computed(() => {
const isAsset = toValue(options.isAssetMode)
@@ -153,12 +162,15 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
- return values.map((value, index) => ({
- id: `input-${index}`,
- preview_url: getMediaUrl(String(value), 'input', kind),
- name: String(value),
- label: getDisplayLabel(String(value), labelFn)
- }))
+ const missing = missingMediaValues.value
+ return values
+ .filter((value) => !missing.has(String(value)))
+ .map((value, index) => ({
+ id: `input-${index}`,
+ preview_url: getMediaUrl(String(value), 'input', kind),
+ name: String(value),
+ label: getDisplayLabel(String(value), labelFn)
+ }))
})
const outputItems = computed(() => {
@@ -176,6 +188,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
return resolved ?? [asset]
})
+ const missing = missingMediaValues.value
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
@@ -188,6 +201,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
? `${subfolder}/${asset.name}`
: asset.name
const annotatedPath = `${pathWithSubfolder} [output]`
+ if (missing.has(annotatedPath)) continue
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
items.push({
id: `output-${asset.id}`,
@@ -209,6 +223,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
+ if (missingMediaValues.value.has(currentValue)) return undefined
+
if (toValue(options.isAssetMode) && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
diff --git a/src/schemas/nodeDef/migration.test.ts b/src/schemas/nodeDef/migration.test.ts
index 7250918f11..61bfd0bb50 100644
--- a/src/schemas/nodeDef/migration.test.ts
+++ b/src/schemas/nodeDef/migration.test.ts
@@ -519,7 +519,7 @@ describe('ComfyNodeDefImpl', () => {
expect(result.inputs['floatInput']).toBeDefined()
})
- it.each([
+ it.for([
{ api_node: true, expected: true },
{ api_node: false, expected: false },
{ api_node: undefined, expected: false }
diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts
index 14135ab121..e1040a4fcb 100644
--- a/src/schemas/nodeDefSchema.ts
+++ b/src/schemas/nodeDefSchema.ts
@@ -1,379 +1 @@
-import { z } from 'zod'
-import { fromZodError } from 'zod-validation-error'
-
-import { resultItemType } from '@/schemas/apiSchema'
-import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
-
-const zComboOption = z.union([z.string(), z.number()])
-const zRemoteWidgetConfig = z.object({
- route: z.string().url().or(z.string().startsWith('/')),
- refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
- response_key: z.string().optional(),
- query_params: z.record(z.string(), z.string()).optional(),
- refresh_button: z.boolean().optional(),
- control_after_refresh: z.enum(['first', 'last']).optional(),
- timeout: z.number().gte(0).optional(),
- max_retries: z.number().gte(0).optional()
-})
-const zMultiSelectOption = z.object({
- placeholder: z.string().optional(),
- chip: z.boolean().optional()
-})
-
-export const zBaseInputOptions = z
- .object({
- default: z.any().optional(),
- defaultInput: z.boolean().optional(),
- display_name: z.string().optional(),
- forceInput: z.boolean().optional(),
- tooltip: z.string().optional(),
- socketless: z.boolean().optional(),
- hidden: z.boolean().optional(),
- advanced: z.boolean().optional(),
- widgetType: z.string().optional(),
- /** Backend-only properties. */
- rawLink: z.boolean().optional(),
- lazy: z.boolean().optional()
- })
- .passthrough()
-
-const zNumericInputOptions = zBaseInputOptions.extend({
- min: z.number().optional(),
- max: z.number().optional(),
- step: z.number().optional(),
- /** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
- default: z.union([z.number(), z.array(z.number())]).optional(),
- display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
-})
-
-export const zIntInputOptions = zNumericInputOptions.extend({
- /**
- * If true, a linked widget will be added to the node to select the mode
- * of `control_after_generate`.
- */
- control_after_generate: z
- .union([z.boolean(), z.enum(CONTROL_OPTIONS)])
- .optional()
-})
-
-export const zColorStop = z.object({
- offset: z.number(),
- color: z.tuple([z.number(), z.number(), z.number()])
-})
-
-export const zFloatInputOptions = zNumericInputOptions.extend({
- round: z.union([z.number(), z.literal(false)]).optional(),
- gradient_stops: z.array(zColorStop).optional()
-})
-
-export const zBooleanInputOptions = zBaseInputOptions.extend({
- label_on: z.string().optional(),
- label_off: z.string().optional(),
- default: z.boolean().optional()
-})
-
-export const zStringInputOptions = zBaseInputOptions.extend({
- default: z.string().optional(),
- multiline: z.boolean().optional(),
- dynamicPrompts: z.boolean().optional(),
-
- // Multiline-only fields
- defaultVal: z.string().optional(),
- placeholder: z.string().optional()
-})
-
-export const zComboInputOptions = zBaseInputOptions.extend({
- control_after_generate: z
- .union([z.boolean(), z.enum(CONTROL_OPTIONS)])
- .optional(),
- image_upload: z.boolean().optional(),
- image_folder: resultItemType.optional(),
- allow_batch: z.boolean().optional(),
- video_upload: z.boolean().optional(),
- audio_upload: z.boolean().optional(),
- mesh_upload: z.boolean().optional(),
- upload_subfolder: z.string().optional(),
- animated_image_upload: z.boolean().optional(),
- options: z.array(zComboOption).optional(),
- remote: zRemoteWidgetConfig.optional(),
- /** Whether the widget is a multi-select widget. */
- multi_select: zMultiSelectOption.optional()
-})
-
-const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
-const zFloatInputSpec = z.tuple([
- z.literal('FLOAT'),
- zFloatInputOptions.optional()
-])
-const zBooleanInputSpec = z.tuple([
- z.literal('BOOLEAN'),
- zBooleanInputOptions.optional()
-])
-const zStringInputSpec = z.tuple([
- z.literal('STRING'),
- zStringInputOptions.optional()
-])
-/**
- * Legacy combo syntax.
- * @deprecated Use `zComboInputSpecV2` instead.
- */
-const zComboInputSpec = z.tuple([
- z.array(zComboOption),
- zComboInputOptions.optional()
-])
-const zComboInputSpecV2 = z.tuple([
- z.literal('COMBO'),
- zComboInputOptions.optional()
-])
-
-export function isComboInputSpecV1(
- inputSpec: InputSpec
-): inputSpec is ComboInputSpec {
- return Array.isArray(inputSpec[0])
-}
-
-export function isIntInputSpec(
- inputSpec: InputSpec
-): inputSpec is IntInputSpec {
- return inputSpec[0] === 'INT'
-}
-
-export function isFloatInputSpec(
- inputSpec: InputSpec
-): inputSpec is FloatInputSpec {
- return inputSpec[0] === 'FLOAT'
-}
-
-export function isComboInputSpecV2(
- inputSpec: InputSpec
-): inputSpec is ComboInputSpecV2 {
- return inputSpec[0] === 'COMBO'
-}
-
-export function isComboInputSpec(
- inputSpec: InputSpec
-): inputSpec is ComboInputSpec | ComboInputSpecV2 {
- return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
-}
-
-export function isMediaUploadComboInput(inputSpec: InputSpec): boolean {
- const [inputName, inputOptions] = inputSpec
- if (!inputOptions) return false
-
- const isUploadInput =
- inputOptions['image_upload'] === true ||
- inputOptions['video_upload'] === true ||
- inputOptions['animated_image_upload'] === true
-
- return (
- isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
- )
-}
-
-/**
- * Get the type of an input spec.
- *
- * @param inputSpec - The input spec to get the type of.
- * @returns The type of the input spec.
- */
-export function getInputSpecType(inputSpec: InputSpec): string {
- return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
-}
-
-/**
- * Get the combo options from a combo input spec.
- *
- * @param inputSpec - The input spec to get the combo options from.
- * @returns The combo options.
- */
-export function getComboSpecComboOptions(
- inputSpec: ComboInputSpec | ComboInputSpecV2
-): (number | string)[] {
- return (
- (isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
- )
-}
-
-const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
-const zCustomInputSpec = z.tuple([
- z.string().refine((value) => !excludedLiterals.has(value)),
- zBaseInputOptions.optional()
-])
-
-const zInputSpec = z.union([
- zIntInputSpec,
- zFloatInputSpec,
- zBooleanInputSpec,
- zStringInputSpec,
- zComboInputSpec,
- zComboInputSpecV2,
- zCustomInputSpec
-])
-
-const zComfyInputsSpec = z.object({
- required: z.record(zInputSpec).optional(),
- optional: z.record(zInputSpec).optional(),
- // Frontend repo is not using it, but some custom nodes are using the
- // hidden field to pass various values.
- hidden: z.record(z.any()).optional()
-})
-
-const zComfyNodeDataType = z.string()
-const zComfyComboOutput = z.array(zComboOption)
-const zComfyOutputTypesSpec = z.array(
- z.union([zComfyNodeDataType, zComfyComboOutput])
-)
-
-/**
- * Widget dependency with type information.
- * Provides strong type enforcement for JSONata evaluation context.
- */
-const zWidgetDependency = z.object({
- name: z.string(),
- type: z.string()
-})
-
-export type WidgetDependency = z.infer
-
-/**
- * Schema for price badge depends_on field.
- * Specifies which widgets and inputs the pricing expression depends on.
- * Widgets must be specified as objects with name and type.
- */
-const zPriceBadgeDepends = z.object({
- widgets: z.array(zWidgetDependency).optional().default([]),
- inputs: z.array(z.string()).optional().default([]),
- /**
- * Autogrow input group names to track.
- * For each group, the count of connected inputs will be available in the
- * JSONata context as `g.`.
- * Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
- * available with the count of connected inputs like `reference_videos.character1`, etc.
- */
- input_groups: z.array(z.string()).optional().default([])
-})
-
-/**
- * Schema for price badge definition.
- * Used to calculate and display pricing information for API nodes.
- * The `expr` field contains a JSONata expression that returns a PricingResult.
- */
-const zPriceBadge = z.object({
- engine: z.literal('jsonata').optional().default('jsonata'),
- depends_on: zPriceBadgeDepends
- .optional()
- .default({ widgets: [], inputs: [], input_groups: [] }),
- expr: z.string()
-})
-
-export type PriceBadge = z.infer
-
-export const zComfyNodeDef = z.object({
- input: zComfyInputsSpec.optional(),
- output: zComfyOutputTypesSpec.optional(),
- output_is_list: z.array(z.boolean()).optional(),
- output_name: z.array(z.string()).optional(),
- output_tooltips: z.array(z.string()).optional(),
- output_matchtypes: z.array(z.string().optional()).optional(),
- name: z.string(),
- display_name: z.string(),
- description: z.string(),
- help: z.string().optional(),
- category: z.string(),
- main_category: z.string().optional(),
- output_node: z.boolean(),
- python_module: z.string(),
- deprecated: z.boolean().optional(),
- experimental: z.boolean().optional(),
- dev_only: z.boolean().optional(),
- /**
- * Whether the node is an API node. Running API nodes requires login to
- * Comfy Org account.
- * https://docs.comfy.org/tutorials/api-nodes/overview
- */
- api_node: z.boolean().optional(),
- /**
- * Specifies the order of inputs for each input category.
- * Used to ensure consistent widget ordering regardless of JSON serialization.
- * Keys are 'required', 'optional', etc., values are arrays of input names.
- */
- input_order: z.record(z.array(z.string())).optional(),
- /**
- * Alternative names for search. Useful for synonyms, abbreviations,
- * or old names after renaming a node.
- */
- search_aliases: z.array(z.string()).optional(),
- /**
- * Price badge definition for API nodes.
- * Contains a JSONata expression to calculate pricing based on widget values
- * and input connectivity.
- */
- price_badge: zPriceBadge.optional(),
- /** Category for the Essentials tab. If set, the node appears in Essentials. */
- essentials_category: z.string().optional(),
- /** Whether the blueprint is a global/installed blueprint (not user-created). */
- isGlobal: z.boolean().optional()
-})
-
-export const zAutogrowOptions = z.object({
- ...zBaseInputOptions.shape,
- template: z.object({
- input: zComfyInputsSpec,
- names: z.array(z.string()).optional(),
- max: z.number().optional(),
- //Backend defines as mandatory with min 1, Frontend is more forgiving
- min: z.number().optional(),
- prefix: z.string().optional()
- })
-})
-
-export const zDynamicComboInputSpec = z.tuple([
- z.literal('COMFY_DYNAMICCOMBO_V3'),
- zBaseInputOptions.extend({
- options: z.array(
- z.object({
- inputs: zComfyInputsSpec,
- key: z.string()
- })
- )
- })
-])
-
-export const zMatchTypeOptions = z.object({
- ...zBaseInputOptions.shape,
- type: z.literal('COMFY_MATCHTYPE_V3'),
- template: z.object({
- allowed_types: z.string(),
- template_id: z.string()
- })
-})
-
-// `/object_info`
-export type ComfyInputsSpec = z.infer
-export type ComfyOutputTypesSpec = z.infer
-export type ComfyNodeDef = z.infer
-export type RemoteWidgetConfig = z.infer
-
-export type ComboInputOptions = z.infer
-export type NumericInputOptions = z.infer
-
-export type IntInputSpec = z.infer
-export type FloatInputSpec = z.infer
-export type ComboInputSpec = z.infer
-export type ComboInputSpecV2 = z.infer
-export type InputSpec = z.infer
-
-export function validateComfyNodeDef(
- data: unknown,
- onError: (error: string) => void = console.warn
-): ComfyNodeDef | null {
- const result = zComfyNodeDef.safeParse(data)
- if (!result.success) {
- const zodError = fromZodError(result.error)
- onError(
- `Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
- )
- return null
- }
- return result.data
-}
+export * from '@comfyorg/object-info-parser'
diff --git a/src/scripts/api.ts b/src/scripts/api.ts
index 23f074b99c..1acc69717d 100644
--- a/src/scripts/api.ts
+++ b/src/scripts/api.ts
@@ -1005,13 +1005,14 @@ export class ComfyApi extends EventTarget {
* Gets the current state of the queue
* @returns The currently running and queued items
*/
- async getQueue(): Promise<{
+ async getQueue(options?: { throwOnError?: boolean }): Promise<{
Running: JobListItem[]
Pending: JobListItem[]
}> {
try {
return await fetchQueue(this.fetchApi.bind(this))
} catch (error) {
+ if (options?.throwOnError) throw error
console.error('Failed to fetch queue:', error)
return { Running: [], Pending: [] }
}
diff --git a/src/scripts/app.test.ts b/src/scripts/app.test.ts
index 138a759376..548bf08ca4 100644
--- a/src/scripts/app.test.ts
+++ b/src/scripts/app.test.ts
@@ -436,4 +436,30 @@ describe('ComfyApp', () => {
)
})
})
+
+ describe('drop handler', () => {
+ it('syncs graph_mouse from the drop event before downstream handlers run', async () => {
+ // graph_mouse is only updated on mousemove, so when files are dragged in
+ // from another window the canvas-space cursor is stale. The drop handler
+ // must derive the position from the drop event itself.
+ const graphMouse: [number, number] = [-999, -999]
+ const adjustMouseEvent = vi.fn((e: DragEvent) => {
+ ;(e as DragEvent & { canvasX: number; canvasY: number }).canvasX = 123
+ ;(e as DragEvent & { canvasX: number; canvasY: number }).canvasY = 456
+ })
+ app.canvas = {
+ ...mockCanvas,
+ graph_mouse: graphMouse,
+ adjustMouseEvent
+ } as unknown as LGraphCanvas
+
+ ;(app as unknown as { addDropHandler(): void }).addDropHandler()
+
+ document.dispatchEvent(new DragEvent('drop'))
+ await Promise.resolve()
+
+ expect(adjustMouseEvent).toHaveBeenCalledTimes(1)
+ expect(graphMouse).toEqual([123, 456])
+ })
+ })
})
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index 5b6c8b214b..d6a7702ffd 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -96,7 +96,7 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import {
scanAllMediaCandidates,
- verifyCloudMediaCandidates
+ verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
@@ -591,6 +591,13 @@ export class ComfyApp {
event.preventDefault()
event.stopPropagation()
+ // graph_mouse is only updated on mousemove, so when files are dragged
+ // in from another window the canvas-space cursor is stale. Sync it
+ // from the drop event so nodes created below land at the cursor.
+ this.canvas.adjustMouseEvent(event)
+ this.canvas.graph_mouse[0] = event.canvasX
+ this.canvas.graph_mouse[1] = event.canvasY
+
const n = this.dragOverNode
this.dragOverNode = null
// Node handles file drop, we dont use the built in onDropFile handler as its buggy
@@ -1508,9 +1515,13 @@ export class ComfyApp {
return
}
- if (isCloud) {
+ const pending = candidates.some((c) => c.isMissing === undefined)
+ if (pending) {
const controller = missingMediaStore.createVerificationAbortController()
- void verifyCloudMediaCandidates(candidates, controller.signal)
+ void verifyMediaCandidates(candidates, {
+ isCloud,
+ signal: controller.signal
+ })
.then(() => {
if (controller.signal.aborted) return
// Re-check ancestor after async verification (see model pipeline).
diff --git a/src/scripts/changeTracker.ts b/src/scripts/changeTracker.ts
index 0218a793ae..da7d861405 100644
--- a/src/scripts/changeTracker.ts
+++ b/src/scripts/changeTracker.ts
@@ -24,20 +24,12 @@ function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
-const reportedInactiveCalls = new Set()
-
/**
* Report a ChangeTracker method being called on an inactive tracker —
* a lifecycle violation that usually indicates stale extension state or
- * an incorrect call ordering. Reports once per method per workflow per
- * session so the signal is not drowned out by hot-path invocations while
- * still distinguishing between workflows.
+ * an incorrect call ordering.
*/
function reportInactiveTrackerCall(method: string, workflowPath: string) {
- const key = `${method}:${workflowPath}`
- if (reportedInactiveCalls.has(key)) return
- reportedInactiveCalls.add(key)
-
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
if (isDesktop) {
diff --git a/src/services/useNewUserService.test.ts b/src/services/useNewUserService.test.ts
index 1fd590b21a..9a6f7a54d9 100644
--- a/src/services/useNewUserService.test.ts
+++ b/src/services/useNewUserService.test.ts
@@ -26,9 +26,6 @@ vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
-//@ts-expect-error Define global for the test
-global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
-
import { useNewUserService } from '@/services/useNewUserService'
describe('useNewUserService', () => {
@@ -120,6 +117,73 @@ describe('useNewUserService', () => {
expect(service.isNewUser()).toBe(false)
})
+ it('should identify existing user when V1 draft store keys exist', async () => {
+ mockSettingStore.settingValues = {}
+ mockSettingStore.get.mockReturnValue(undefined)
+ mockLocalStorage.getItem.mockImplementation((key: string) => {
+ if (key === 'Comfy.Workflow.Drafts') return '{}'
+ return null
+ })
+
+ await service.initializeIfNewUser()
+
+ expect(service.isNewUser()).toBe(false)
+ })
+
+ it('should identify existing user when V1 draft order key exists', async () => {
+ mockSettingStore.settingValues = {}
+ mockSettingStore.get.mockReturnValue(undefined)
+ mockLocalStorage.getItem.mockImplementation((key: string) => {
+ if (key === 'Comfy.Workflow.DraftOrder') return '[]'
+ return null
+ })
+
+ await service.initializeIfNewUser()
+
+ expect(service.isNewUser()).toBe(false)
+ })
+
+ it('should identify existing user when V2 draft index has entries', async () => {
+ mockSettingStore.settingValues = {}
+ mockSettingStore.get.mockReturnValue(undefined)
+ mockLocalStorage.getItem.mockImplementation((key: string) => {
+ if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
+ return '{"v":2,"updatedAt":1,"order":["abc"],"entries":{"abc":{"path":"workflows/Untitled.json","name":"Untitled","isTemporary":true,"updatedAt":1}}}'
+ return null
+ })
+
+ await service.initializeIfNewUser()
+
+ expect(service.isNewUser()).toBe(false)
+ })
+
+ it('should identify new user when V2 draft index exists but is empty', async () => {
+ mockSettingStore.settingValues = {}
+ mockSettingStore.get.mockReturnValue(undefined)
+ mockLocalStorage.getItem.mockImplementation((key: string) => {
+ if (key === 'Comfy.Workflow.DraftIndex.v2:personal')
+ return '{"v":2,"updatedAt":1,"order":[],"entries":{}}'
+ return null
+ })
+
+ await service.initializeIfNewUser()
+
+ expect(service.isNewUser()).toBe(true)
+ })
+
+ it('should identify new user when V2 draft index is malformed', async () => {
+ mockSettingStore.settingValues = {}
+ mockSettingStore.get.mockReturnValue(undefined)
+ mockLocalStorage.getItem.mockImplementation((key: string) => {
+ if (key === 'Comfy.Workflow.DraftIndex.v2:personal') return 'not json'
+ return null
+ })
+
+ await service.initializeIfNewUser()
+
+ expect(service.isNewUser()).toBe(true)
+ })
+
it('should identify new user when tutorial is explicitly false', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {
diff --git a/src/services/useNewUserService.ts b/src/services/useNewUserService.ts
index 093c273bd2..6eef5b8c7b 100644
--- a/src/services/useNewUserService.ts
+++ b/src/services/useNewUserService.ts
@@ -2,6 +2,24 @@ import { ref, shallowRef } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useSettingStore } from '@/platform/settings/settingStore'
+function hasV2DraftHistory(raw: string | null): boolean {
+ if (!raw) return false
+ try {
+ const parsed = JSON.parse(raw) as {
+ order?: unknown
+ entries?: unknown
+ }
+ const orderLength = Array.isArray(parsed.order) ? parsed.order.length : 0
+ const entriesCount =
+ parsed.entries && typeof parsed.entries === 'object'
+ ? Object.keys(parsed.entries as Record).length
+ : 0
+ return orderLength > 0 || entriesCount > 0
+ } catch {
+ return false
+ }
+}
+
function _useNewUserService() {
const settingStore = useSettingStore()
const pendingCallbacks = shallowRef Promise>>([])
@@ -18,12 +36,32 @@ function _useNewUserService() {
const isNewUserSettings =
Object.keys(settingStore.settingValues).length === 0 ||
!settingStore.get('Comfy.TutorialCompleted')
- const hasNoWorkflow = !localStorage.getItem('workflow')
- const hasNoPreviousWorkflow = !localStorage.getItem(
- 'Comfy.PreviousWorkflow'
+
+ // Legacy keys (pre-V1 and V1 persistence)
+ const hasNoLegacyWorkflow =
+ !localStorage.getItem('workflow') &&
+ !localStorage.getItem('Comfy.PreviousWorkflow')
+
+ // V1 draft store keys
+ const hasNoV1Drafts =
+ !localStorage.getItem('Comfy.Workflow.Drafts') &&
+ !localStorage.getItem('Comfy.Workflow.DraftOrder')
+
+ // V2 draft index key (scoped to personal workspace; cloud workspace id
+ // comes from sessionStorage which may not be set yet at this point).
+ // Check for actual draft history rather than key existence: an empty
+ // index is written by `migrateV1toV2()` for genuine new users during
+ // startup, so key presence alone is not evidence of prior usage.
+ const hasNoV2DraftIndex = !hasV2DraftHistory(
+ localStorage.getItem('Comfy.Workflow.DraftIndex.v2:personal')
)
- return isNewUserSettings && hasNoWorkflow && hasNoPreviousWorkflow
+ return (
+ isNewUserSettings &&
+ hasNoLegacyWorkflow &&
+ hasNoV1Drafts &&
+ hasNoV2DraftIndex
+ )
}
async function registerInitCallback(callback: () => Promise) {
diff --git a/src/stores/authStore.test.ts b/src/stores/authStore.test.ts
index 0b4080bf04..4f9ec2a483 100644
--- a/src/stores/authStore.test.ts
+++ b/src/stores/authStore.test.ts
@@ -593,7 +593,7 @@ describe('useAuthStore', () => {
)
})
- it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
+ it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=true when Firebase says new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
@@ -610,7 +610,7 @@ describe('useAuthStore', () => {
}
)
- it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
+ it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=true when UI options say new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
@@ -627,7 +627,7 @@ describe('useAuthStore', () => {
}
)
- it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
+ it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=false when neither source says new user',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue({
@@ -644,7 +644,7 @@ describe('useAuthStore', () => {
}
)
- it.each(['loginWithGoogle', 'loginWithGithub'] as const)(
+ it.for(['loginWithGoogle', 'loginWithGithub'] as const)(
'%s should track is_new_user=false when getAdditionalUserInfo returns null',
async (method) => {
vi.mocked(firebaseAuth.getAdditionalUserInfo).mockReturnValue(null)
diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts
index dc143d844f..392883f7a7 100644
--- a/src/stores/executionStore.test.ts
+++ b/src/stores/executionStore.test.ts
@@ -440,6 +440,57 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
+describe('useExecutionStore - clearActiveJobIfStale', () => {
+ let store: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ store = useExecutionStore()
+ })
+
+ it('clears the active job and progress state when not in the active set', () => {
+ store.activeJobId = 'job-1'
+ store.queuedJobs = { 'job-1': { nodes: { 'node-1': false } } }
+ store.nodeProgressStates = {
+ 'node-1': {
+ value: 5,
+ max: 10,
+ state: 'running',
+ node_id: 'node-1',
+ display_node_id: 'node-1',
+ prompt_id: 'job-1'
+ }
+ }
+
+ store.clearActiveJobIfStale(new Set(['job-2']))
+
+ expect(store.activeJobId).toBeNull()
+ expect(store.queuedJobs['job-1']).toBeUndefined()
+ expect(store.nodeProgressStates).toEqual({})
+ })
+
+ it('preserves the active job when present in the active set', () => {
+ store.activeJobId = 'job-1'
+ store.queuedJobs = { 'job-1': { nodes: {} } }
+
+ store.clearActiveJobIfStale(new Set(['job-1', 'job-2']))
+
+ expect(store.activeJobId).toBe('job-1')
+ expect(store.queuedJobs['job-1']).toBeDefined()
+ })
+
+ it('is a no-op when there is no active job', () => {
+ store.activeJobId = null
+ store.queuedJobs = { other: { nodes: {} } }
+
+ store.clearActiveJobIfStale(new Set())
+
+ expect(store.activeJobId).toBeNull()
+ expect(store.queuedJobs['other']).toBeDefined()
+ })
+})
+
describe('useExecutionStore - progress_text startup guard', () => {
let store: ReturnType
diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts
index 1df4702858..d438dfd220 100644
--- a/src/stores/executionStore.ts
+++ b/src/stores/executionStore.ts
@@ -485,6 +485,16 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobIds(orphaned)
}
+ /**
+ * Clears the active job if the server's queue snapshot doesn't list it.
+ * Used after WS reconnect to recover from stale state when a job finished
+ * during the disconnect window.
+ */
+ function clearActiveJobIfStale(activeJobIds: Set) {
+ const id = activeJobId.value
+ if (id && !activeJobIds.has(id)) resetExecutionState(id)
+ }
+
function isJobInitializing(jobId: JobId | number | undefined): boolean {
if (!jobId) return false
return initializingJobIds.value.has(String(jobId))
@@ -643,6 +653,7 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobId,
clearInitializationByJobIds,
reconcileInitializingJobs,
+ clearActiveJobIfStale,
bindExecutionEvents,
unbindExecutionEvents,
storeJob,
diff --git a/src/stores/maskEditorStore.test.ts b/src/stores/maskEditorStore.test.ts
index 5c7e6f1851..a3f21d2309 100644
--- a/src/stores/maskEditorStore.test.ts
+++ b/src/stores/maskEditorStore.test.ts
@@ -256,13 +256,13 @@ describe('maskEditorStore', () => {
})
describe('canvas → ctx watchers', () => {
- it.each([
+ it.for([
['maskCanvas', 'maskCtx'],
['rgbCanvas', 'rgbCtx'],
['imgCanvas', 'imgCtx']
] as const)(
'should derive %s using getContext with willReadFrequently',
- async (canvasKey, ctxKey) => {
+ async ([canvasKey, ctxKey]) => {
const store = useMaskEditorStore()
const canvas = makeCanvas()
@@ -276,13 +276,13 @@ describe('maskEditorStore', () => {
}
)
- it.each([
+ it.for([
['maskCanvas', 'maskCtx'],
['rgbCanvas', 'rgbCtx'],
['imgCanvas', 'imgCtx']
] as const)(
'should leave existing %s ctx untouched when canvas is cleared',
- async (canvasKey, ctxKey) => {
+ async ([canvasKey, ctxKey]) => {
const store = useMaskEditorStore()
const canvas = makeCanvas()
store[canvasKey] = canvas
diff --git a/src/stores/missingMediaPreviewRegression.test.ts b/src/stores/missingMediaPreviewRegression.test.ts
new file mode 100644
index 0000000000..d8f349c18b
--- /dev/null
+++ b/src/stores/missingMediaPreviewRegression.test.ts
@@ -0,0 +1,93 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({ removeNodeOutputs: mockRemoveNodeOutputs })
+}))
+
+const mockApp = vi.hoisted(() => ({
+ isGraphReady: true,
+ rootGraph: { nodes: [], _nodes: [] } as unknown as LGraph
+}))
+vi.mock('@/scripts/app', () => ({ app: mockApp }))
+
+const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
+vi.mock('@/utils/graphTraversalUtil', async () => {
+ const actual = await vi.importActual(
+ '@/utils/graphTraversalUtil'
+ )
+ return {
+ ...actual,
+ getNodeByExecutionId: mockGetNodeByExecutionId
+ }
+})
+
+vi.mock('@/i18n', () => ({
+ st: vi.fn((_key: string, fallback: string) => fallback)
+}))
+
+vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
+
+vi.mock('@/stores/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
+}))
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
+}))
+
+vi.mock(
+ '@/platform/missingModel/composables/useMissingModelInteractions',
+ () => ({ clearMissingModelState: vi.fn() })
+)
+
+import { useExecutionErrorStore } from './executionErrorStore'
+
+function makeNodeWithPreview(id: number): LGraphNode {
+ return {
+ id,
+ imgs: [{ src: 'blob:mask-edited' }],
+ videoContainer: undefined,
+ graph: { setDirtyCanvas: vi.fn() }
+ } as unknown as LGraphNode
+}
+
+describe('FE-230 regression — workflow-load missing-media flagging must not wipe node previews', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockApp.isGraphReady = true
+ mockApp.rootGraph = { nodes: [], _nodes: [] } as unknown as LGraph
+ mockRemoveNodeOutputs.mockReset()
+ mockGetNodeByExecutionId.mockReset()
+ })
+
+ it('does not clear node.imgs when verification flags a Load Image as missing on workflow load (e.g. mask-editor saved value)', async () => {
+ const node = makeNodeWithPreview(42)
+ mockGetNodeByExecutionId.mockReturnValue(node)
+
+ useExecutionErrorStore()
+ const missingMediaStore = useMissingMediaStore()
+
+ missingMediaStore.setMissingMedia([
+ {
+ nodeId: '42',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'clipspace/clipspace-painted-masked-1.png [input]',
+ isMissing: true
+ }
+ ])
+ await nextTick()
+ await nextTick()
+
+ expect(node.imgs).toEqual([{ src: 'blob:mask-edited' }])
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/stores/modelToNodeStore.test.ts b/src/stores/modelToNodeStore.test.ts
index 0af45fca80..af577d9fb8 100644
--- a/src/stores/modelToNodeStore.test.ts
+++ b/src/stores/modelToNodeStore.test.ts
@@ -253,7 +253,7 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('')
})
- it.each([
+ it.for([
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
['sams', 'SAMLoader', 'model_name'],
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
@@ -264,7 +264,7 @@ describe('useModelToNodeStore', () => {
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name']
])(
'should return correct provider for %s',
- (modelType, expectedNodeName, expectedKey) => {
+ ([modelType, expectedNodeName, expectedKey]) => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
@@ -274,7 +274,7 @@ describe('useModelToNodeStore', () => {
}
)
- it.each([['ultralytics'], ['ultralytics/bbox'], ['ultralytics/segm']])(
+ it.for(['ultralytics', 'ultralytics/bbox', 'ultralytics/segm'])(
'should not register %s as a default provider, so the node falls back to its static combo (regression for #8468)',
(modelType) => {
const modelToNodeStore = useModelToNodeStore()
diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts
index 74cdcdedeb..e8cc0752fb 100644
--- a/src/stores/nodeOutputStore.ts
+++ b/src/stores/nodeOutputStore.ts
@@ -367,22 +367,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
- /**
- * Remove node outputs for a specific node
- * Clears both outputs and preview images
- */
- function removeNodeOutputs(nodeId: number | string) {
- const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
- if (!nodeLocatorId) return false
-
- // Clear from app.nodeOutputs
+ function removeOutputsByLocatorId(nodeLocatorId: NodeLocatorId) {
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
delete app.nodeOutputs[nodeLocatorId]
-
- // Clear from reactive state
delete nodeOutputs.value[nodeLocatorId]
- // Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (previews?.[Symbol.iterator]) {
@@ -397,6 +386,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
return hadOutputs
}
+ /**
+ * Remove node outputs for a specific node
+ * Clears both outputs and preview images
+ */
+ function removeNodeOutputs(nodeId: number | string) {
+ const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
+ if (!nodeLocatorId) return false
+ return removeOutputsByLocatorId(nodeLocatorId)
+ }
+
+ // Resolves the locator from the node's own graph, so interior subgraph nodes
+ // are addressed correctly even when the user has a different graph active.
+ function removeNodeOutputsForNode(node: LGraphNode) {
+ return removeOutputsByLocatorId(nodeToNodeLocatorId(node))
+ }
+
function snapshotOutputs(): Record {
return clone(app.nodeOutputs)
}
@@ -493,6 +498,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeAllPreviews,
revokeSubgraphPreviews,
removeNodeOutputs,
+ removeNodeOutputsForNode,
snapshotOutputs,
restoreOutputs,
resetAllOutputsAndPreviews,
diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts
index 9b5a62d2d6..2f5a72611a 100644
--- a/src/stores/queueStore.test.ts
+++ b/src/stores/queueStore.test.ts
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
+import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
// Fixture factory for JobListItem
@@ -340,11 +341,11 @@ describe('useQueueStore', () => {
expect(store.isLoading).toBe(false)
})
- it('should clear loading state even if API fails', async () => {
+ it('should clear loading state even if the queue fetch fails', async () => {
mockGetQueue.mockRejectedValue(new Error('API error'))
mockGetHistory.mockResolvedValue([])
- await expect(store.update()).rejects.toThrow('API error')
+ await store.update()
expect(store.isLoading).toBe(false)
})
})
@@ -1018,10 +1019,9 @@ describe('useQueueStore', () => {
const firstUpdate = store.update()
void store.update() // coalesces, sets dirty
- // First call rejects — but dirty flag triggers re-fetch
- await expect(firstUpdate).rejects.toThrow('network error')
-
- // Re-fetch was triggered
+ // First call resolves (allSettled absorbs the failure) but the dirty
+ // flag still triggers a re-fetch when the in-flight request finishes.
+ await firstUpdate
expect(mockGetQueue).toHaveBeenCalledTimes(2)
resolveSecond({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
@@ -1032,4 +1032,86 @@ describe('useQueueStore', () => {
expect(store.isLoading).toBe(false)
})
})
+
+ describe('update() partial failures', () => {
+ it('reconciles when the queue fetch succeeds, even with an empty snapshot', async () => {
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue([])
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+
+ expect(reconcileSpy).toHaveBeenCalledWith(new Set())
+ })
+
+ it('preserves prior queue state and skips reconcile when the queue fetch fails', async () => {
+ mockGetQueue
+ .mockResolvedValueOnce({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('network down'))
+ mockGetHistory.mockResolvedValue([])
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+ await store.update()
+
+ // First update reconciles with run-1; second update's queue fetch
+ // rejects, so reconcile must not be called again.
+ expect(reconcileSpy).toHaveBeenCalledTimes(1)
+ expect(reconcileSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ })
+
+ it('still updates history when only the queue fetch fails', async () => {
+ mockGetQueue.mockRejectedValue(new Error('queue down'))
+ mockGetHistory.mockResolvedValue([createHistoryJob(0, 'hist-1')])
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].jobId).toBe('hist-1')
+ })
+
+ it('still updates queue when only the history fetch fails', async () => {
+ mockGetQueue.mockResolvedValue({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ mockGetHistory.mockRejectedValue(new Error('history down'))
+
+ await store.update()
+
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ })
+
+ it('preserves prior state and skips reconcile when both fetches fail', async () => {
+ mockGetQueue
+ .mockResolvedValueOnce({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('queue down'))
+ mockGetHistory
+ .mockResolvedValueOnce([createHistoryJob(0, 'hist-1')])
+ .mockRejectedValueOnce(new Error('history down'))
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+ await store.update()
+
+ expect(reconcileSpy).toHaveBeenCalledTimes(1)
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].jobId).toBe('hist-1')
+ expect(store.isLoading).toBe(false)
+ })
+ })
})
diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts
index f0f660c1db..d1a909fe09 100644
--- a/src/stores/queueStore.ts
+++ b/src/stores/queueStore.ts
@@ -525,68 +525,74 @@ export const useQueueStore = defineStore('queue', () => {
dirty = false
isLoading.value = true
try {
- const [queue, history] = await Promise.all([
- api.getQueue(),
+ const [queueResult, historyResult] = await Promise.allSettled([
+ api.getQueue({ throwOnError: true }),
api.getHistory(maxHistoryItems.value)
])
- // API returns pre-sorted data (sort_by=create_time&order=desc)
- runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
- pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
+ if (queueResult.status === 'fulfilled') {
+ const queue = queueResult.value
+ // API returns pre-sorted data (sort_by=create_time&order=desc)
+ runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
+ pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
- const currentHistory = toValue(historyTasks)
+ const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
+ const executionStore = useExecutionStore()
+ appearedTasks.forEach((task) => {
+ const jobIdString = String(task.jobId)
+ const workflowId = task.workflowId
+ if (workflowId && jobIdString) {
+ executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
+ }
+ })
- const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
- const executionStore = useExecutionStore()
- appearedTasks.forEach((task) => {
- const jobIdString = String(task.jobId)
- const workflowId = task.workflowId
- if (workflowId && jobIdString) {
- executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
- }
- })
-
- // Only reconcile when the queue fetch returned data. api.getQueue()
- // returns empty Running/Pending on transient errors, which would
- // incorrectly clear all initializing prompts.
- const queueHasData = queue.Running.length > 0 || queue.Pending.length > 0
- if (queueHasData) {
const activeJobIds = new Set([
...queue.Running.map((j) => j.id),
...queue.Pending.map((j) => j.id)
])
executionStore.reconcileInitializingJobs(activeJobIds)
+ } else {
+ console.error('Failed to fetch queue:', queueResult.reason)
}
- // Sort by create_time descending and limit to maxItems
- const sortedHistory = [...history]
- .sort((a, b) => b.create_time - a.create_time)
- .slice(0, toValue(maxHistoryItems))
+ if (historyResult.status === 'fulfilled') {
+ const history = historyResult.value
+ const currentHistory = toValue(historyTasks)
- // Reuse existing TaskItemImpl instances or create new
- // Must recreate if outputs_count changed (e.g., API started returning it)
- const existingByJobId = new Map(
- currentHistory.map((impl) => [impl.jobId, impl])
- )
+ // Sort by create_time descending and limit to maxItems
+ const sortedHistory = [...history]
+ .sort((a, b) => b.create_time - a.create_time)
+ .slice(0, toValue(maxHistoryItems))
- const nextHistoryTasks = sortedHistory.map((job) => {
- const existing = existingByJobId.get(job.id)
- if (!existing) return new TaskItemImpl(job)
- // Recreate if outputs_count changed to ensure lazy loading works
- if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
- return new TaskItemImpl(job)
+ // Reuse existing TaskItemImpl instances or create new
+ // Must recreate if outputs_count changed (e.g., API started returning it)
+ const existingByJobId = new Map(
+ currentHistory.map((impl) => [impl.jobId, impl])
+ )
+
+ const nextHistoryTasks = sortedHistory.map((job) => {
+ const existing = existingByJobId.get(job.id)
+ if (!existing) return new TaskItemImpl(job)
+ // Recreate if outputs_count changed to ensure lazy loading works
+ if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
+ return new TaskItemImpl(job)
+ }
+ return existing
+ })
+
+ const isHistoryUnchanged =
+ nextHistoryTasks.length === currentHistory.length &&
+ nextHistoryTasks.every(
+ (task, index) => task === currentHistory[index]
+ )
+
+ if (!isHistoryUnchanged) {
+ historyTasks.value = nextHistoryTasks
}
- return existing
- })
-
- const isHistoryUnchanged =
- nextHistoryTasks.length === currentHistory.length &&
- nextHistoryTasks.every((task, index) => task === currentHistory[index])
-
- if (!isHistoryUnchanged) {
- historyTasks.value = nextHistoryTasks
+ hasFetchedHistorySnapshot.value = true
+ } else {
+ console.error('Failed to fetch history:', historyResult.reason)
}
- hasFetchedHistorySnapshot.value = true
} finally {
isLoading.value = false
inFlight = false
diff --git a/src/types/nodeSource.ts b/src/types/nodeSource.ts
index b2089d7625..e1040a4fcb 100644
--- a/src/types/nodeSource.ts
+++ b/src/types/nodeSource.ts
@@ -1,97 +1 @@
-export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
-
-export enum NodeSourceType {
- Core = 'core',
- CustomNodes = 'custom_nodes',
- Blueprint = 'blueprint',
- Essentials = 'essentials',
- Unknown = 'unknown'
-}
-export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
-
-export type NodeSource = {
- type: NodeSourceType
- className: string
- displayText: string
- badgeText: string
-}
-
-const UNKNOWN_NODE_SOURCE: NodeSource = {
- type: NodeSourceType.Unknown,
- className: 'comfy-unknown',
- displayText: 'Unknown',
- badgeText: '?'
-}
-
-function shortenNodeName(name: string) {
- return name
- .replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
- .replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
-}
-
-export function getNodeSource(
- python_module?: string,
- essentials_category?: string
-): NodeSource {
- if (!python_module) {
- return UNKNOWN_NODE_SOURCE
- }
- const modules = python_module.split('.')
- if (essentials_category) {
- const moduleName = modules[1] ?? modules[0] ?? 'essentials'
- const displayName = shortenNodeName(moduleName.split('@')[0])
- return {
- type: NodeSourceType.Essentials,
- className: 'comfy-essentials',
- displayText: displayName,
- badgeText: displayName
- }
- } else if (CORE_NODE_MODULES.includes(modules[0])) {
- return {
- type: NodeSourceType.Core,
- className: 'comfy-core',
- displayText: 'Comfy Core',
- badgeText: '🦊'
- }
- } else if (modules[0] === 'blueprint') {
- return {
- type: NodeSourceType.Blueprint,
- className: 'blueprint',
- displayText: 'Blueprint',
- badgeText: 'bp'
- }
- } else if (modules[0] === 'custom_nodes') {
- const moduleName = modules[1]
- if (!moduleName) {
- return UNKNOWN_NODE_SOURCE
- }
- const customNodeName = moduleName.split('@')[0]
- const displayName = shortenNodeName(customNodeName)
- return {
- type: NodeSourceType.CustomNodes,
- className: 'comfy-custom-nodes',
- displayText: displayName,
- badgeText: displayName
- }
- } else {
- return UNKNOWN_NODE_SOURCE
- }
-}
-
-interface NodeDefLike {
- nodeSource: NodeSource
-}
-
-export function isEssentialNode(node: NodeDefLike): boolean {
- return node.nodeSource.type === NodeSourceType.Essentials
-}
-
-export function isCustomNode(node: NodeDefLike): boolean {
- return node.nodeSource.type === NodeSourceType.CustomNodes
-}
-
-export enum NodeBadgeMode {
- None = 'None',
- ShowAll = 'Show all',
- HideBuiltIn = 'Hide built-in'
-}
+export * from '@comfyorg/object-info-parser'
diff --git a/src/utils/categoryUtil.test.ts b/src/utils/categoryUtil.test.ts
index dede54c0e9..9e06ba1e05 100644
--- a/src/utils/categoryUtil.test.ts
+++ b/src/utils/categoryUtil.test.ts
@@ -28,6 +28,7 @@ describe('getProviderIcon', () => {
it('returns icon class for simple provider name', () => {
expect(getProviderIcon('BFL')).toBe('icon-[comfy--bfl]')
expect(getProviderIcon('OpenAI')).toBe('icon-[comfy--openai]')
+ expect(getProviderIcon('Anthropic')).toBe('icon-[comfy--anthropic]')
})
it('converts spaces to hyphens', () => {
@@ -47,6 +48,7 @@ describe('getProviderBorderStyle', () => {
expect(getProviderBorderStyle('BFL')).toBe('#ffffff')
expect(getProviderBorderStyle('OpenAI')).toBe('#B6B6B6')
expect(getProviderBorderStyle('Bria')).toBe('#B6B6B6')
+ expect(getProviderBorderStyle('Anthropic')).toBe('#D97757')
})
it('returns gradient for dual-color providers', () => {
diff --git a/src/utils/categoryUtil.ts b/src/utils/categoryUtil.ts
index 240713e50b..876a3c5192 100644
--- a/src/utils/categoryUtil.ts
+++ b/src/utils/categoryUtil.ts
@@ -56,6 +56,7 @@ export const getCategoryIcon = (categoryId: string): string => {
* Each entry can be a single color or [color1, color2] for gradient.
*/
const PROVIDER_COLORS: Record = {
+ anthropic: '#D97757',
bfl: '#ffffff',
bria: '#B6B6B6',
elevenlabs: '#B6B6B6',
diff --git a/src/utils/colorUtil.test.ts b/src/utils/colorUtil.test.ts
index b0c9672f6f..c1a3746124 100644
--- a/src/utils/colorUtil.test.ts
+++ b/src/utils/colorUtil.test.ts
@@ -218,7 +218,7 @@ describe('colorUtil conversions', () => {
})
describe('parseToRgb edge cases', () => {
- it.each(['', 'not-a-color', '#GGGGGG', 'cmky(1,2,3,4)'])(
+ it.for(['', 'not-a-color', '#GGGGGG', 'cmky(1,2,3,4)'])(
'returns black for unrecognized input %s',
(input) => {
expect(parseToRgb(input)).toEqual({ r: 0, g: 0, b: 0 })
@@ -309,8 +309,8 @@ describe('colorUtil - adjustColor', () => {
})
}
- describe.each(Object.entries(colors))('%s color', (_colorName, color) => {
- describe.each(formats)('%s format', (format) => {
+ describe.for(Object.entries(colors))('%s color', ([_colorName, color]) => {
+ describe.for(formats)('%s format', (format) => {
runAdjustColorTests(color, format as ColorFormat)
})
})
diff --git a/src/utils/hostWhitelist.test.ts b/src/utils/hostWhitelist.test.ts
index 9cf71b6a54..bb634b2131 100644
--- a/src/utils/hostWhitelist.test.ts
+++ b/src/utils/hostWhitelist.test.ts
@@ -4,7 +4,7 @@ import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
describe('hostWhitelist utils', () => {
describe('normalizeHost', () => {
- it.each([
+ it.for([
['LOCALHOST', 'localhost'],
['localhost.', 'localhost'], // trims trailing dot
['localhost:5173', 'localhost'], // strips :port
@@ -17,7 +17,7 @@ describe('hostWhitelist utils', () => {
['example.com.', 'example.com'], // trims trailing dot
['[2001:db8::1]:8443', '2001:db8::1'], // IPv6 with brackets+port
['2001:db8::1', '2001:db8::1'] // plain IPv6 stays
- ])('normalizeHost(%o) -> %o', (input, expected) => {
+ ])('normalizeHost(%o) -> %o', ([input, expected]) => {
expect(normalizeHost(input)).toBe(expected)
})
@@ -29,7 +29,7 @@ describe('hostWhitelist utils', () => {
describe('isHostWhitelisted', () => {
describe('localhost label', () => {
- it.each([
+ it.for([
'localhost',
'LOCALHOST',
'localhost.',
@@ -42,7 +42,7 @@ describe('hostWhitelist utils', () => {
expect(isHostWhitelisted(input)).toBe(true)
})
- it.each([
+ it.for([
'localhost.com',
'evil-localhost',
'notlocalhost',
@@ -53,7 +53,7 @@ describe('hostWhitelist utils', () => {
})
describe('IPv4 127/8 loopback', () => {
- it.each([
+ it.for([
'127.0.0.1',
'127.1.2.3',
'127.255.255.255',
@@ -64,7 +64,7 @@ describe('hostWhitelist utils', () => {
expect(isHostWhitelisted(input)).toBe(true)
})
- it.each([
+ it.for([
'126.0.0.1',
'127.256.0.1',
'127.-1.0.1',
@@ -82,7 +82,7 @@ describe('hostWhitelist utils', () => {
})
describe('IPv6 loopback ::1 (all textual forms)', () => {
- it.each([
+ it.for([
'::1',
'[::1]',
'[::1]:5173',
@@ -97,7 +97,7 @@ describe('hostWhitelist utils', () => {
expect(isHostWhitelisted(input)).toBe(true)
})
- it.each([
+ it.for([
'::2',
'::',
'::0',
@@ -121,7 +121,7 @@ describe('hostWhitelist utils', () => {
})
describe('comfy.org hosts', () => {
- it.each([
+ it.for([
'staging.comfy.org',
'stagingcloud.comfy.org',
'pr-123.testingcloud.comfy.org',
@@ -130,7 +130,7 @@ describe('hostWhitelist utils', () => {
expect(isHostWhitelisted(input)).toBe(true)
})
- it.each([
+ it.for([
'comfy.org.evil.com',
'evil-comfy.org',
'comfy.organization',
diff --git a/src/utils/litegraphUtil.test.ts b/src/utils/litegraphUtil.test.ts
index fecea538f2..67e67947e9 100644
--- a/src/utils/litegraphUtil.test.ts
+++ b/src/utils/litegraphUtil.test.ts
@@ -1,9 +1,20 @@
-import { describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
+import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
-import { resolveNode } from './litegraphUtil'
+import { createNode, resolveNode } from './litegraphUtil'
+
+const mockBringNodeToFront = vi.fn()
+
+vi.mock('@/renderer/extensions/vueNodes/composables/useNodeZIndex', () => ({
+ useNodeZIndex: () => ({ bringNodeToFront: mockBringNodeToFront })
+}))
+
+vi.mock('@/platform/updates/common/toastStore', () => ({
+ useToastStore: () => ({ addAlert: vi.fn() })
+}))
describe('resolveNode', () => {
it('returns undefined when graph is null', () => {
@@ -68,3 +79,66 @@ describe('resolveNode', () => {
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
})
+
+describe('createNode', () => {
+ function makeCanvas(graph: LGraph): LGraphCanvas {
+ return {
+ graph,
+ graph_mouse: [100, 200] as [number, number]
+ } as Partial as LGraphCanvas
+ }
+
+ beforeEach(() => {
+ mockBringNodeToFront.mockClear()
+ })
+
+ it('returns null when name is empty', async () => {
+ const result = await createNode(makeCanvas(new LGraph()), '')
+ expect(result).toBeNull()
+ expect(mockBringNodeToFront).not.toHaveBeenCalled()
+ })
+
+ it('places the new node at the canvas graph_mouse position', async () => {
+ const newNode = new LGraphNode('LoadImage')
+ const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
+ const graph = new LGraph()
+
+ const result = await createNode(makeCanvas(graph), 'LoadImage')
+
+ expect(result).toBe(newNode)
+ expect(Array.from(newNode.pos)).toEqual([100, 200])
+ spy.mockRestore()
+ })
+
+ it('brings the new node to front so it renders above existing nodes', async () => {
+ const newNode = new LGraphNode('LoadImage')
+ const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
+ const graph = new LGraph()
+
+ const result = await createNode(makeCanvas(graph), 'LoadImage')
+
+ expect(result).toBe(newNode)
+ expect(mockBringNodeToFront).toHaveBeenCalledTimes(1)
+ expect(mockBringNodeToFront).toHaveBeenCalledWith(newNode.id)
+ spy.mockRestore()
+ })
+
+ it('does not bring node to front when LiteGraph.createNode returns null', async () => {
+ const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(null)
+ await createNode(makeCanvas(new LGraph()), 'NonexistentNode')
+ expect(mockBringNodeToFront).not.toHaveBeenCalled()
+ spy.mockRestore()
+ })
+
+ it('does not bring node to front when graph.add returns null', async () => {
+ const newNode = new LGraphNode('LoadImage')
+ const spy = vi.spyOn(LiteGraph, 'createNode').mockReturnValue(newNode)
+ const graph = new LGraph()
+ vi.spyOn(graph, 'add').mockReturnValue(null as unknown as LGraphNode)
+
+ await createNode(makeCanvas(graph), 'LoadImage')
+
+ expect(mockBringNodeToFront).not.toHaveBeenCalled()
+ spy.mockRestore()
+ })
+})
diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts
index e140d5a7df..e794db591f 100644
--- a/src/utils/litegraphUtil.ts
+++ b/src/utils/litegraphUtil.ts
@@ -24,6 +24,7 @@ import type {
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
+import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { app } from '@/scripts/app'
import { t } from '@/i18n'
@@ -57,7 +58,10 @@ export async function createNode(
newNode.pos = [posX, posY]
const addedNode = graph.add(newNode) ?? null
- if (addedNode) graph.change()
+ if (addedNode) {
+ useNodeZIndex().bringNodeToFront(addedNode.id)
+ graph.change()
+ }
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
diff --git a/src/utils/migration/migrateReroute.test.ts b/src/utils/migration/migrateReroute.test.ts
index a6fa00bf6a..d80bc125de 100644
--- a/src/utils/migration/migrateReroute.test.ts
+++ b/src/utils/migration/migrateReroute.test.ts
@@ -14,7 +14,7 @@ describe('migrateReroute', () => {
return JSON.parse(fileContent) as WorkflowJSON04
}
- it.each([
+ it.for([
'branching.json',
'single_connected.json',
'floating.json',
diff --git a/src/views/GraphView.test.ts b/src/views/GraphView.test.ts
new file mode 100644
index 0000000000..a1fc5242c2
--- /dev/null
+++ b/src/views/GraphView.test.ts
@@ -0,0 +1,214 @@
+import { createTestingPinia } from '@pinia/testing'
+import { render } from '@testing-library/vue'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import type * as VueUseCore from '@vueuse/core'
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
+import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
+import type * as DistTypes from '@/platform/distribution/types'
+import type * as I18nModule from '@/i18n'
+
+const apiMock = vi.hoisted(() => new EventTarget())
+
+vi.mock('@/scripts/api', () => ({ api: apiMock }))
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ rootGraph: { getNodeById: vi.fn(), nodes: [] },
+ ui: {
+ menuContainer: { style: { setProperty: vi.fn() } },
+ restoreMenuPosition: vi.fn()
+ }
+ }
+}))
+
+vi.mock('@/composables/useReconnectQueueRefresh', () => {
+ const refreshOnReconnect = vi.fn(async () => {})
+ return { useReconnectQueueRefresh: () => refreshOnReconnect }
+})
+
+vi.mock('@/composables/useReconnectingNotification', () => {
+ const onReconnected = vi.fn()
+ const onReconnecting = vi.fn()
+ return {
+ useReconnectingNotification: () => ({ onReconnected, onReconnecting })
+ }
+})
+
+vi.mock('@vueuse/core', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useIntervalFn: vi.fn(() => ({ pause: vi.fn() })) }
+})
+
+vi.mock('@/base/common/async', () => ({ runWhenGlobalIdle: vi.fn() }))
+vi.mock('@/composables/useBrowserTabTitle', () => ({
+ useBrowserTabTitle: vi.fn()
+}))
+vi.mock('@/composables/useCoreCommands', () => ({ useCoreCommands: () => [] }))
+vi.mock('@/platform/remote/comfyui/useQueuePolling', () => ({
+ useQueuePolling: vi.fn()
+}))
+vi.mock('@/composables/useErrorHandling', () => ({
+ useErrorHandling: () => ({
+ wrapWithErrorHandling: (f: unknown) => f,
+ wrapWithErrorHandlingAsync: (f: unknown) => f
+ })
+}))
+vi.mock('@/composables/useProgressFavicon', () => ({
+ useProgressFavicon: vi.fn()
+}))
+vi.mock('@/i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, loadLocale: vi.fn().mockResolvedValue(undefined) }
+})
+vi.mock('@/platform/distribution/types', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, isCloud: false, isDesktop: false }
+})
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({ get: vi.fn(() => undefined), set: vi.fn() })
+}))
+vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
+vi.mock('@/platform/updates/common/useFrontendVersionMismatchWarning', () => ({
+ useFrontendVersionMismatchWarning: vi.fn()
+}))
+vi.mock('@/platform/updates/common/versionCompatibilityStore', () => ({
+ useVersionCompatibilityStore: () => ({
+ initialize: vi.fn().mockResolvedValue(undefined)
+ })
+}))
+vi.mock('@/renderer/core/canvas/canvasStore', async () => {
+ const { defineStore } = await import('pinia')
+ return {
+ useCanvasStore: defineStore('canvas-test-stub', () => ({
+ linearMode: ref(false)
+ }))
+ }
+})
+vi.mock('@/services/autoQueueService', () => ({
+ setupAutoQueueHandler: vi.fn()
+}))
+vi.mock('@/platform/keybindings/keybindingService', () => ({
+ useKeybindingService: () => ({
+ registerCoreKeybindings: vi.fn(),
+ keybindHandler: vi.fn()
+ })
+}))
+vi.mock('@/composables/useAppMode', () => ({
+ useAppMode: () => ({ isBuilderMode: ref(false) })
+}))
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({ updateHistory: vi.fn() })
+}))
+vi.mock('@/stores/commandStore', () => ({
+ useCommandStore: () => ({ registerCommands: vi.fn() })
+}))
+vi.mock('@/stores/executionStore', () => ({
+ useExecutionStore: () => ({
+ bindExecutionEvents: vi.fn(),
+ unbindExecutionEvents: vi.fn(),
+ activeJobId: null,
+ clearActiveJobIfStale: vi.fn()
+ })
+}))
+vi.mock('@/stores/authStore', () => ({
+ useAuthStore: () => ({ isAuthenticated: false })
+}))
+vi.mock('@/stores/menuItemStore', () => ({
+ useMenuItemStore: () => ({ registerCoreMenuCommands: vi.fn() })
+}))
+vi.mock('@/stores/modelStore', () => ({ useModelStore: () => ({}) }))
+vi.mock('@/stores/nodeDefStore', () => ({
+ useNodeDefStore: () => ({}),
+ useNodeFrequencyStore: () => ({})
+}))
+vi.mock('@/stores/queueStore', () => ({
+ useQueueStore: () => ({
+ update: vi.fn(),
+ runningTasks: [],
+ pendingTasks: [],
+ tasks: [],
+ maxHistoryItems: 64
+ }),
+ useQueuePendingTaskCountStore: () => ({ update: vi.fn() })
+}))
+vi.mock('@/stores/serverConfigStore', () => ({
+ useServerConfigStore: () => ({})
+}))
+vi.mock('@/stores/workspace/bottomPanelStore', () => ({
+ useBottomPanelStore: () => ({
+ registerCoreBottomPanelTabs: vi.fn().mockResolvedValue(undefined)
+ })
+}))
+vi.mock('@/stores/workspace/colorPaletteStore', () => ({
+ useColorPaletteStore: () => ({
+ completedActivePalette: { light_theme: true, colors: { comfy_base: {} } }
+ })
+}))
+vi.mock('@/stores/workspace/sidebarTabStore', () => ({
+ useSidebarTabStore: () => ({
+ registerCoreSidebarTabs: vi.fn(),
+ activeSidebarTabId: null
+ })
+}))
+vi.mock('@/utils/envUtil', () => ({
+ electronAPI: () => ({
+ changeTheme: vi.fn(),
+ Events: { incrementUserProperty: vi.fn(), trackEvent: vi.fn() }
+ })
+}))
+
+// Module-mock heavy child components so we don't pay their import cost.
+const stubModule = { default: { template: '
' } }
+vi.mock('@/components/graph/GraphCanvas.vue', () => stubModule)
+vi.mock('@/views/LinearView.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderToolbar.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderMenu.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderFooterToolbar.vue', () => stubModule)
+vi.mock(
+ '@/workbench/extensions/manager/components/ManagerProgressToast.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/assets/components/ModelImportProgressDialog.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/assets/components/AssetExportProgressDialog.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/workspace/components/toasts/InviteAcceptedToast.vue',
+ () => stubModule
+)
+vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
+vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
+vi.mock('@/components/MenuHamburger.vue', () => stubModule)
+vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)
+
+describe('GraphView - reconnect wiring', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ it('wires the reconnected event to the toast and queue refresh', async () => {
+ const GraphView = (await import('./GraphView.vue')).default
+ render(GraphView)
+
+ apiMock.dispatchEvent(new Event('reconnected'))
+
+ const { onReconnected } = useReconnectingNotification()
+ const refreshOnReconnect = useReconnectQueueRefresh()
+ await vi.waitFor(() => {
+ expect(onReconnected).toHaveBeenCalledTimes(1)
+ expect(refreshOnReconnect).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue
index f9f42823c7..e9b16fd8b1 100644
--- a/src/views/GraphView.vue
+++ b/src/views/GraphView.vue
@@ -56,11 +56,12 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
-import { i18n, loadLocale } from '@/i18n'
+import { setActiveLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import DesktopCloudNotificationController from '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue'
@@ -189,15 +190,17 @@ watchEffect(() => {
watchEffect(async () => {
const locale = settingStore.get('Comfy.Locale')
- if (locale) {
- // Load the locale dynamically if not already loaded
- try {
- await loadLocale(locale)
- // Type assertion is safe here as loadLocale validates the locale exists
- i18n.global.locale.value = locale as typeof i18n.global.locale.value
- } catch (error) {
- console.error(`Failed to switch to locale "${locale}":`, error)
+ if (!locale) return
+ try {
+ const resolved = await setActiveLocale(locale)
+ // Self-heal: a stored value from an older build (e.g. 'de') would otherwise
+ // leave the language dropdown — derived from SUPPORTED_LOCALE_OPTIONS —
+ // showing nothing selected until the user picks one manually.
+ if (resolved !== locale) {
+ await settingStore.set('Comfy.Locale', resolved)
}
+ } catch (error) {
+ console.error(`Failed to switch to locale "${locale}":`, error)
}
})
@@ -248,11 +251,17 @@ const onExecutionSuccess = async () => {
}
const { onReconnecting, onReconnected } = useReconnectingNotification()
+const refreshOnReconnect = useReconnectQueueRefresh()
+
+const handleReconnected = async () => {
+ onReconnected()
+ await refreshOnReconnect()
+}
useEventListener(api, 'status', onStatus)
useEventListener(api, 'execution_success', onExecutionSuccess)
useEventListener(api, 'reconnecting', onReconnecting)
-useEventListener(api, 'reconnected', onReconnected)
+useEventListener(api, 'reconnected', handleReconnected)
onMounted(() => {
executionStore.bindExecutionEvents()
diff --git a/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts b/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts
index 1571624b33..fc8e00d549 100644
--- a/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts
+++ b/src/workbench/extensions/manager/stores/comfyManagerStore.test.ts
@@ -340,7 +340,7 @@ describe('useComfyManagerStore', () => {
]
describe('isPackEnabled', () => {
- it.each(testCases)(
+ it.for(testCases)(
'$expectState when $desc',
async ({ installed, expectState, packName }) => {
packName ??= 'name'