diff --git a/.agents/checks/playwright-e2e.md b/.agents/checks/playwright-e2e.md new file mode 100644 index 0000000000..5594698f48 --- /dev/null +++ b/.agents/checks/playwright-e2e.md @@ -0,0 +1,74 @@ +--- +name: playwright-e2e +description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse +severity-default: medium +tools: [Read, Grep] +--- + +You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations. + +Reference docs (read if you need full context): + +- `browser_tests/README.md` — setup, patterns, screenshot workflow +- `browser_tests/AGENTS.md` — directory structure, fixture overview +- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns +- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide + +## Checks + +### Flakiness Risks (Major) + +1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`. + +2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly. + +3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing. + +4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead. + +5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable. + +6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic. + +7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler. + +### Fixture & API Misuse (Medium) + +8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers: + - `comfyPage.command.executeCommand()` for menu/command actions + - `comfyPage.workflow.loadWorkflow()` for loading test workflows + - `comfyPage.canvasOps.resetView()` for view reset + - `comfyPage.settings.setSetting()` for settings + - Component page objects in `browser_tests/fixtures/components/` + +9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`. + +10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging. + +### Convention Violations (Minor) + +11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each. + +12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns. + +13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option. + +14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup. + +15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only. + +### Test Design (Nitpick) + +16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior. + +17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph. + +18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell. + +## Rules + +- Only review `.spec.ts` files and supporting code in `browser_tests/` +- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules +- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18) +- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume +- Existing tests that predate conventions are acceptable to modify but not required to fix diff --git a/.claude/settings.json b/.claude/settings.json index ac5df3c1f3..2c7b88bf8d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,9 +1,86 @@ { - "permissions": { - "allow": [ - "Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")", - "Bash(pnpx vitest run \"draftCacheV2.property\")", - "Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")" + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(tsc *)", + "command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(vue-tsc *)", + "command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx tsc *)", + "command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpx tsc *)", + "command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpm exec tsc *)", + "command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx vitest *)", + "command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- `) instead of npx vitest.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpx vitest *)", + "command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- `) instead of pnpx vitest.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx eslint *)", + "command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpx eslint *)", + "command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx prettier *)", + "command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpx prettier *)", + "command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx oxlint *)", + "command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx stylelint *)", + "command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(npx knip *)", + "command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2" + }, + { + "type": "command", + "if": "Bash(pnpx knip *)", + "command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2" + } + ] + } ] } } diff --git a/.claude/skills/adding-deprecation-warnings/SKILL.md b/.claude/skills/adding-deprecation-warnings/SKILL.md new file mode 100644 index 0000000000..c86addf1c8 --- /dev/null +++ b/.claude/skills/adding-deprecation-warnings/SKILL.md @@ -0,0 +1,84 @@ +--- +name: adding-deprecation-warnings +description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.' +--- + +# Adding Deprecation Warnings + +Adds backward-compatible deprecation warnings for renamed or removed +properties using the `defineDeprecatedProperty` helper in +`src/lib/litegraph/src/utils/feedback.ts`. + +## When to Use + +- A property or API has been renamed and custom nodes still use the old name +- A property is being removed but needs a grace period +- Backward compatibility must be preserved while nudging adoption + +## Steps + +### 1. Search the Custom Node Ecosystem + +Before implementing, assess impact by searching for usage of the +deprecated property across ComfyUI custom nodes: + +```text +Use the comfy_codesearch tool to search for the old property name. +Search for both `widget.oldProp` and just `oldProp` to catch all patterns. +``` + +Document the usage patterns found (property access, truthiness checks, +caching to local vars, style mutation, etc.) — these all must continue +working. + +### 2. Apply the Deprecation + +Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`: + +```typescript +import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback' + +/** @deprecated Use {@link obj.newProp} instead. */ +defineDeprecatedProperty( + obj, + 'oldProp', + 'newProp', + 'obj.oldProp is deprecated. Use obj.newProp instead.' +) +``` + +### 3. Checklist + +- [ ] Ecosystem search completed — all usage patterns are compatible +- [ ] `defineDeprecatedProperty` call added after the new property is assigned +- [ ] JSDoc `@deprecated` tag added above the call for IDE support +- [ ] Warning message names both old and new property clearly +- [ ] `pnpm typecheck` passes +- [ ] `pnpm lint` passes + +### 4. PR Comment + +Add a PR comment summarizing the ecosystem search results: which repos +use the deprecated property, what access patterns were found, and +confirmation that all patterns are compatible with the ODP getter/setter. + +## How `defineDeprecatedProperty` Works + +- Creates an `Object.defineProperty` getter/setter on the target object +- Getter returns `this[currentKey]`, setter assigns `this[currentKey]` +- Both log via `warnDeprecated`, which deduplicates (once per unique + message per session via a `Set`) +- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in` + / `JSON.stringify` +- `configurable: true` allows further redefinition if needed + +## Edge Cases + +- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires +- **Caching to local var** (`const el = widget.oldProp`) — works, warns + once then the cached ref is used directly +- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) — + works, getter returns the real object +- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it +- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns + once per session regardless of call count diff --git a/.claude/skills/backport-management/SKILL.md b/.claude/skills/backport-management/SKILL.md index d2ad90c072..138eb98a84 100644 --- a/.claude/skills/backport-management/SKILL.md +++ b/.claude/skills/backport-management/SKILL.md @@ -11,10 +11,11 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br 1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`) 2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`) -3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch -4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`) -5. **Verify** — After each wave, verify branch integrity before proceeding -6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`) +3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow) +4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch +5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`) +6. **Verify** — After each wave, verify branch integrity before proceeding +7. **Log & Report** — Generate session report (`reference/logging.md`) ## System Context @@ -37,16 +38,29 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br **Critical: Match PRs to the correct target branches.** -| Branch prefix | Scope | Example | -| ------------- | ------------------------------ | ----------------------------------------- | -| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI | -| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system | +| Branch prefix | Scope | Example | +| ------------- | ------------------------------ | ------------------------------------------------- | +| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login | +| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system | +| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs | -**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check: +### What Goes Where -- Does the PR title/description mention "app mode", "cloud", or cloud-specific features? -- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components? -- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches) +**Both core + cloud:** + +- **App mode** PRs — app mode is NOT cloud-only +- **Firebase auth** PRs — Firebase auth is on core for API nodes +- **Payment redirect** PRs — payment infrastructure shared +- **Bug fixes** touching shared components + +**Cloud-only (skip for core):** + +- Team workspaces +- Cloud queue virtualization +- Hide API key login +- Cloud-specific UI behind cloud feature flags + +**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed. ## ⚠️ Gotchas (Learn from Past Sessions) @@ -67,6 +81,32 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p 12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below. +### Accept-Theirs Can Produce Broken Hybrids + +When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa. + +**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries. + +**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely. + +### Cherry-Picks Can Reference Missing Dependencies + +When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken. + +**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR. + +**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick. + +**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck. + +### Use `--no-verify` for Worktree Pushes + +Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees. + +### Automation Success Varies Wildly by Branch + +In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs. + ## Conflict Triage **Always categorize before deciding to skip. High conflict count ≠ hard conflicts.** @@ -77,6 +117,8 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p | **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file | | **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant | | **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) | +| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs | +| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after | | **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict | | **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after | @@ -103,7 +145,7 @@ Skip these without discussion: - **Test-only / lint rule changes** — Not user-facing - **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C. - **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40 -- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI. +- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules) ## Wave Verification @@ -122,6 +164,18 @@ git worktree remove /tmp/verify-TARGET --force If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem. +### Fix PRs Are Normal + +Expect to create 1 fix PR per branch after verification. Common issues: + +1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path` +2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit +3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface +4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports +5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function + +Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`. + ### Never Admin-Merge Without CI In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design. @@ -135,6 +189,43 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo - Reserve session-style bulk backporting for catching up after gaps - When a release branch is created, immediately start the continuous process +## Interactive Approval Flow + +After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation. + +### Batch Presentation + +Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format: + +``` + # | PR | Title | Target | Rec | Context +----+--------+------------------------------------------+---------------+------+-------- + 1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning. + 2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch. +``` + +Each row includes: + +- PR number and title +- Target branches +- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning +- 2-3 sentence context: what the PR does, why it matters (or doesn't) + +### Human Response Format + +- `Y` — approve for backport +- `N` — skip +- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks) +- Any freeform question or comment triggers discussion before moving on +- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`) + +### Rules + +- ALL candidates are reviewed, not just MUST items +- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision +- When human asks a question about a PR, answer with context and recommendation, then wait for their decision +- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N + ## Quick Reference ### Label-Driven Automation (default path) @@ -150,13 +241,96 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \ ```bash git worktree add /tmp/backport-$BRANCH origin/$BRANCH cd /tmp/backport-$BRANCH + +# For each PR: +git fetch origin $BRANCH git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH git cherry-pick -m 1 $MERGE_SHA -# Resolve conflicts, push, create PR, merge +# Resolve conflicts (see Conflict Triage) +git push origin backport-$PR-to-$BRANCH --no-verify +gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \ + --title "[backport $BRANCH] $TITLE (#$PR)" \ + --body "Backport of #$PR. [conflict notes]" +gh pr merge $NEW_PR --squash --admin +sleep 25 ``` +### Efficient Batch: Test-Then-Resolve Pattern + +When many PRs need manual cherry-pick (e.g., cloud branches), test all first: + +```bash +cd /tmp/backport-$BRANCH +for pr in "${ORDER[@]}"; do + git checkout -b test-$pr origin/$BRANCH + if git cherry-pick -m 1 $SHA 2>/dev/null; then + echo "CLEAN: $pr" + else + echo "CONFLICT: $pr" + git cherry-pick --abort + fi + git checkout --detach HEAD + git branch -D test-$pr +done +``` + +Then process clean PRs in a batch loop, conflicts individually. + ### PR Title Convention ``` [backport TARGET_BRANCH] Original Title (#ORIGINAL_PR) ``` + +## Final Deliverables (Slack-Compatible) + +After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only. + +### 1. Author Accountability Report + +File: `backport-author-accountability.md` + +Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling. + +``` +Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors + +- author-login + - #1234 fix: short title + - #5678 fix: another title +- other-author + - #9012 fix: some other fix +``` + +Authors sorted alphabetically, 4-space indent for nested items. + +### 2. Slack Status Update + +File: `slack-status-update.md` + +A shareable summary of the session. Structure: + +``` +Backport session complete -- YYYY-MM-DD + +[1-sentence summary: N PRs backported to which branches. All pass typecheck.] + +Branches updated: +- core/X.XX: N PRs + N fix PRs (N auto, N manual) +- cloud/X.XX: N PRs + N fix PRs (N auto, N manual) +- ... + +N total PRs created and merged (N backports + N fix PRs). + +Notable fixes included: +- [category]: [list of fixes] +- ... + +Conflict patterns encountered: +- [pattern and how it was resolved] +- ... + +N authors had PRs backported. See author accountability list for details. +``` + +No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack. diff --git a/.claude/skills/backport-management/reference/analysis.md b/.claude/skills/backport-management/reference/analysis.md index d62cd1c935..dd6a41b394 100644 --- a/.claude/skills/backport-management/reference/analysis.md +++ b/.claude/skills/backport-management/reference/analysis.md @@ -23,10 +23,10 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi **Before categorizing, filter by branch scope:** -| Target branch | Skip if PR is... | -| ------------- | ------------------------------------------------------------------- | -| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) | -| `cloud/*` | Local-only features not present on cloud branch | +| Target branch | Skip if PR is... | +| ------------- | ----------------------------------------------------------------------------------------------------------------- | +| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. | +| `cloud/*` | Local-only features not present on cloud branch | Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators. @@ -61,8 +61,6 @@ done ## Human Review Checkpoint -Present decisions.md before execution. Include: +Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation. -1. All MUST/SHOULD/SKIP categorizations with rationale -2. Questions for human (feature existence, scope, deps) -3. Estimated effort per branch +All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset. diff --git a/.claude/skills/backport-management/reference/execution.md b/.claude/skills/backport-management/reference/execution.md index 44de5f66f0..2bc6f9f78a 100644 --- a/.claude/skills/backport-management/reference/execution.md +++ b/.claude/skills/backport-management/reference/execution.md @@ -73,14 +73,22 @@ for PR in ${CONFLICT_PRS[@]}; do git cherry-pick -m 1 $MERGE_SHA # If conflict — NEVER skip based on file count alone! - # Categorize conflicts first: binary PNGs, modify/delete, content, add/add + # Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites # See SKILL.md Conflict Triage table for resolution per type. + # For component rewrites (4+ markers in a .vue file, library migration): + # DO NOT use accept-theirs regex — it produces broken hybrids. + # Instead, use the complete file from the merge commit: + # git show $MERGE_SHA:path/to/file > path/to/file + + # For simple content conflicts, accept theirs: + # python3 -c "import re; ..." + # Resolve all conflicts, then: git add . GIT_EDITOR=true git cherry-pick --continue - git push origin backport-$PR-to-TARGET + git push origin backport-$PR-to-TARGET --no-verify NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \ --title "[backport TARGET] TITLE (#$PR)" \ --body "Backport of #$PR..." | grep -oP '\d+$') @@ -114,7 +122,30 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes git worktree remove /tmp/verify-TARGET --force ``` -If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves. +If verification fails, **do not skip** — create a fix PR: + +```bash +# Stay in the verify worktree +git checkout -b fix-backport-TARGET origin/TARGET_BRANCH + +# Common fixes: +# 1. Component rewrite hybrids: overwrite with merge commit version +git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue + +# 2. Missing dependency files +git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts + +# 3. Missing type properties: edit the interface +# 4. Unused imports: delete the import lines + +git add -A +git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET" +git push origin fix-backport-TARGET --no-verify +gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..." +gh pr merge $PR --squash --admin +``` + +Do not proceed to the next branch until typecheck passes. ## Conflict Resolution Patterns @@ -142,7 +173,35 @@ git rm $FILE git checkout --theirs $FILE && git add $FILE ``` -### 4. Locale Files +### 4. Component Rewrites (DO NOT accept-theirs) + +When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces +a broken hybrid with mismatched template/script sections. + +```bash +# Use the complete correct file from the merge commit instead: +git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue +git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue +git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue +``` + +**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component +libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side. + +### 5. Missing Dependencies After Cherry-Pick + +Cherry-picks can succeed but leave the branch broken because the PR's code on main +references composables/components introduced by an earlier PR. + +```bash +# Add the missing file from the merge commit: +git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts +git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue +``` + +**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly. + +### 6. Locale Files Usually adding new i18n keys — accept theirs, validate JSON: @@ -176,8 +235,14 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin 8. **Always validate JSON** after resolving locale file conflicts 9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead. 10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors. -11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting. +11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only. 12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design. +13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead. +14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit. +15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure. +16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`. +17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback. +18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially. ## CI Failure Triage diff --git a/.claude/skills/backport-management/reference/logging.md b/.claude/skills/backport-management/reference/logging.md index 4c78ba4b16..77ef0aa1c8 100644 --- a/.claude/skills/backport-management/reference/logging.md +++ b/.claude/skills/backport-management/reference/logging.md @@ -2,26 +2,25 @@ ## During Execution -Maintain `execution-log.md` with per-branch tables: +Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here): ```markdown -| PR# | Title | CI Status | Status | Backport PR | Notes | -| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- | -| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details | +| PR# | Title | Status | Backport PR | Notes | +| ----- | ----- | ------ | ----------- | ------- | +| #XXXX | Title | merged | #YYYY | Details | ``` ## Wave Verification Log -Track verification results per wave: +Track verification results per wave within execution-log.md: ```markdown -## Wave N Verification — TARGET_BRANCH +Wave N Verification -- TARGET_BRANCH - PRs merged: #A, #B, #C -- Typecheck: ✅ Pass / ❌ Fail -- Unit tests: ✅ Pass / ❌ Fail +- Typecheck: pass / fail +- Fix PR: #YYYY (if needed) - Issues found: (if any) -- Human review needed: (list any non-trivial conflict resolutions) ``` ## Session Report Template @@ -63,40 +62,42 @@ Track verification results per wave: - Feature branches that need tracking for future sessions? ``` -## Final Deliverable: Visual Summary +## Final Deliverables -At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output. +After all branches are complete and verified, generate these files in `~/temp/backport-session/`: -```mermaid -graph TD - subgraph branch1["☁️ cloud/X.XX — N PRs"] - C1["#XXXX title"] - C2["#XXXX title"] - end +### 1. execution-log.md (internal) - subgraph branch2must["🔴 core/X.XX MUST — N PRs"] - M1["#XXXX title"] - end +Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack. - subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"] - S1["#XXXX-#XXXX N auto-merged"] - S2["#XXXX-#XXXX N manual picks"] - end +### 2. backport-author-accountability.md (Slack-compatible) - classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff - classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8 - classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0 - classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0 -``` +See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each. -Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable. +### 3. slack-status-update.md (Slack-compatible) + +See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count. + +## Slack Formatting Rules + +Both shareable files (author accountability + status update) must follow these rules: + +- No emojis (no checkmarks, no arrows, no icons) +- No markdown tables (use plain lists with dashes) +- No headers (no # or ##) +- No bold (\*_) or italic (_) +- No inline code backticks +- Use -- instead of em dash +- Use plain dashes (-) for lists with 4-space indent for nesting +- Line breaks between sections for readability + +These files should paste directly into a Slack message and look clean. ## Files to Track -- `candidate_list.md` — all candidates per branch -- `decisions.md` — MUST/SHOULD/SKIP with rationale -- `wave-plan.md` — execution order -- `execution-log.md` — real-time status -- `backport-session-report.md` — final summary +All in `~/temp/backport-session/`: -All in `~/temp/backport-session/`. +- `execution-plan.md` -- approved PRs with merge SHAs (input) +- `execution-log.md` -- real-time status with per-branch tables (internal) +- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible) +- `slack-status-update.md` -- session summary (Slack-compatible) diff --git a/.claude/skills/hardening-flaky-e2e-tests/SKILL.md b/.claude/skills/hardening-flaky-e2e-tests/SKILL.md new file mode 100644 index 0000000000..d45a6f18fc --- /dev/null +++ b/.claude/skills/hardening-flaky-e2e-tests/SKILL.md @@ -0,0 +1,246 @@ +--- +name: hardening-flaky-e2e-tests +description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.' +--- + +# Hardening Flaky E2E Tests + +Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that). + +## Workflow + +### 1. Gather CI Evidence + +```bash +gh run list --workflow=ci-test.yaml --limit=5 +gh run download -n playwright-report +``` + +- Open `report.json` and search for `"status": "flaky"` entries. +- Collect file paths, test titles, and error messages. +- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing. +- Use `error-context.md`, traces, and page snapshots before editing code. +- Pull the newest run after each push instead of assuming the flaky set is unchanged. + +### 2. Classify the Flake + +Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories: + +| # | Pattern | Signature in Code | Fix | +| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- | +| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` | +| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` | +| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` | +| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms | +| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` | +| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first | +| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` | +| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value | +| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` | +| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` | + +### 3. Apply the Transform + +#### Rule: Choose the Smallest Correct Assertion + +- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()` +- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)` +- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()` +- **Never** use `waitForTimeout()` to hide a race. + +```typescript +// ✅ Single value — use expect.poll +await expect + .poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length)) + .toBe(3) + +// ✅ Locator count — use toHaveCount +await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2) + +// ✅ Multiple conditions — use toPass +await expect(async () => { + expect(await node1.getValue()).toBe('foo') + expect(await node2.getValue()).toBe('bar') +}).toPass({ timeout: 5000 }) +``` + +#### Rule: Wait for the Real Readiness Boundary + +Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on. + +Common readiness boundaries: + +| After this action... | Wait for... | +| -------------------------------------- | ------------------------------------------------------------ | +| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` | +| Menu item click | `await contextMenu.waitForHidden()` | +| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) | +| Settings write | Poll the setting value with `expect.poll()` | +| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` | +| Graph mutation (add/remove node, link) | Poll link/node count | +| Clipboard write | Poll pasted value | +| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state | + +#### Rule: Expose Locators for Retrying Assertions + +When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between: + +```typescript +// Helper exposes locator +get domWidgets(): Locator { + return this.page.locator('.dom-widget') +} + +// Caller uses retrying assertion +await expect(comfyPage.domWidgets).toHaveCount(2) +``` + +Replace count methods with locator getters so callers can use retrying assertions directly. + +#### Rule: Fix Check-then-Act Races in Helpers + +```typescript +// ❌ Race: count can change between check and waitFor +const count = await locator.count() +if (count > 0) { + await locator.waitFor({ state: 'hidden' }) +} + +// ✅ Direct: waitFor handles both cases +await locator.waitFor({ state: 'hidden' }) +``` + +#### Rule: Remove force:true from Clicks + +`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue. + +```typescript +// ❌ Hides the race +await closeButton.click({ force: true }) + +// ✅ Surfaces the real issue — fix with proper wait +await closeButton.click() +await dialog.waitForHidden() +``` + +#### Rule: Handle Non-deterministic Element Order + +When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly. + +```typescript +// ❌ Assumes order +const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0] + +// ✅ Find by ID or proximity +const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') +let target = nodes[0] +for (const n of nodes) { + const pos = await n.getPosition() + if (Math.abs(pos.y - expectedY) < minDist) target = n +} +``` + +Or guard the assumption: + +```typescript +const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') +expect(nodes).toHaveLength(1) +const node = nodes[0] +``` + +#### Rule: Use toPass for Timing-sensitive Dismiss Guards + +Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action: + +```typescript +// ✅ Retry click+assert together +await expect(async () => { + await comfyPage.canvas.click({ position: { x: 10, y: 10 } }) + await expect(dialog).toBeHidden({ timeout: 500 }) +}).toPass({ timeout: 5000 }) +``` + +### 4. Keep Changes Narrow + +- Shared helpers should drive setup to a stable boundary. +- Do not encode one-spec timing assumptions into generic helpers. +- If a race only matters to one spec, prefer a local wait in that spec. +- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness. + +### 5. Verify Narrowly + +```bash +# Targeted rerun with repetition +pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10 + +# Single test by line number (avoids grep quoting issues on Windows) +pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42 +``` + +- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases). +- Verify with the smallest command that exercises the flaky path. + +### 6. Watch CI E2E Runs + +After pushing, use `gh` to monitor the E2E workflow: + +```bash +# Find the run for the current branch +gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 + +# Watch it live (blocks until complete, streams logs) +gh run watch + +# One-liner: find and watch the latest E2E run for the current branch +gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch +``` + +On Windows (PowerShell): + +```powershell +# One-liner equivalent +gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId") +``` + +After the run completes: + +```bash +# Download the Playwright report artifact +gh run download -n playwright-report + +# View the run summary in browser +gh run view --web +``` + +Also watch the unit test workflow in parallel if you changed helpers: + +```bash +gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1 +``` + +### 7. Pre-merge Checklist + +Before merging a flaky-test fix, confirm: + +- [ ] The latest CI artifact was inspected directly +- [ ] The root cause is stated as a race or readiness mismatch +- [ ] The fix waits on the real readiness boundary +- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass) +- [ ] The fix stays local unless a shared helper truly owns the race +- [ ] Local verification uses a targeted rerun +- [ ] No behavioral changes to the test — only timing/retry strategy updated + +## Local Noise — Do Not Fix + +These are local distractions, not CI root causes: + +- Missing local input fixture files required by the test path +- Missing local models directory +- Teardown `EPERM` while restoring the local browser-test user data directory +- Local screenshot baseline differences on Windows + +Rules: + +- First confirm whether it blocks the exact flaky path under investigation. +- Do not commit temporary local assets used only for verification. +- Do not commit local screenshot baselines. diff --git a/.claude/skills/ticket-intake/SKILL.md b/.claude/skills/ticket-intake/SKILL.md deleted file mode 100644 index f01c7f763d..0000000000 --- a/.claude/skills/ticket-intake/SKILL.md +++ /dev/null @@ -1,361 +0,0 @@ ---- -name: ticket-intake -description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.' ---- - -# Ticket Intake - -Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API. - -> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below. - -## Supported Sources - -| Source | URL Pattern | Provider File | -| ------ | --------------------------------------------------- | --------------------- | -| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` | -| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` | - -## Quick Start - -When given a ticket URL: - -1. **Detect source type** from URL pattern -2. **Load provider-specific logic** from `providers/` directory -3. Fetch ticket content via appropriate API -4. Extract and normalize properties to common schema -5. **Register ticket in pipeline API** ← MANDATORY -6. **Update source** (Notion status / GitHub comment) ← MANDATORY -7. **Run verification script** to confirm API registration -8. Output summary and handoff to `research-orchestrator` - -## Configuration - -Uses the **production API** by default. No configuration needed for read operations. - -**Defaults (no setup required):** - -- API URL: `https://api-gateway-856475788601.us-central1.run.app` -- Read-only endpoints at `/public/*` require no authentication - -**For write operations** (transitions, creating tickets), set: - -```bash -export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin -``` - -**Optional (for local working artifacts):** - -```bash -PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}" -``` - -## Mandatory API Calls (Execute ALL Three) - -**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.** - -**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation. - -### Call 1: Create Ticket - -```bash -API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}" -API_KEY="${PIPELINE_API_KEY}" - -curl -s -X POST "${API_URL}/v1/tickets" \ - -H "Authorization: Bearer ${API_KEY}" \ - -H "Content-Type: application/json" \ - -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ - -d '{ - "notion_page_id": "NOTION_PAGE_UUID_HERE", - "title": "TICKET_TITLE_HERE", - "source": "notion", - "metadata": { - "description": "DESCRIPTION_HERE", - "priority": "High", - "labels": [], - "acceptanceCriteria": [] - } - }' -``` - -Save the returned `id` — you need it for the next two calls. - -### Call 2: Transition to RESEARCH - -```bash -TICKET_ID="id-from-step-1" - -curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \ - -H "Authorization: Bearer ${API_KEY}" \ - -H "Content-Type: application/json" \ - -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ - -d '{ - "to_state": "RESEARCH", - "reason": "Intake complete, starting research" - }' -``` - -### Call 3: Queue Source Update - -```bash -curl -s -X POST "${API_URL}/v1/sync/queue" \ - -H "Authorization: Bearer ${API_KEY}" \ - -H "Content-Type: application/json" \ - -H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \ - -d '{ - "ticket_id": "TICKET_ID_HERE", - "action": "update_status", - "payload": { "status": "In Progress" }, - "priority": "normal" - }' -``` - -> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`. - -### TypeScript Equivalent (if using pipeline client) - -```typescript -import { PipelineClient } from '@pipeline/client' - -const client = new PipelineClient({ - apiUrl: - process.env.PIPELINE_API_URL || - 'https://api-gateway-856475788601.us-central1.run.app', - agentId: process.env.AGENT_ID! -}) - -const ticket = await client.createTicket({ - notion_page_id: pageId, - title: ticketTitle, - source: 'notion', - metadata: { description, priority, labels, acceptanceCriteria } -}) - -await client.transitionState( - ticket.id, - 'RESEARCH', - 'Intake complete, starting research' -) - -await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' }) -``` - -## Workflow - -### Step 1: Detect Source Type - -Parse the URL to determine source: - -```javascript -if (url.includes('notion.so')) { - source = 'notion' - // Load providers/notion.md -} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) { - source = 'github' - // Load providers/github.md -} else { - // Error: Unsupported URL format -} -``` - -### Step 2: Load Provider and Fetch Data - -Read the appropriate provider file for source-specific instructions: - -- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links -- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments - -Follow the provider's instructions for: - -- Fetching content -- Extracting properties -- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner) - -### Step 3: Normalize to Common Schema - -All providers must extract normalized ticket data following `schema.md`: - -```json -{ - "id": "abc12345", - "url": "https://...", - "source": "notion | github", - "title": "Ticket title", - "description": "Full description", - "status": "Not Started", - "assignee": "username", - "priority": "High", - "area": "UI", - "labels": ["bug", "frontend"], - "acceptanceCriteria": ["Criterion 1", "Criterion 2"], - "fetchedAt": "2024-01-15T10:30:00Z" -} -``` - -### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP) - -**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.** - -This is not optional. This is not documentation. You MUST make these HTTP requests right now. - -1. `createTicket()` → save the returned ticket ID -2. `transitionState(id, 'RESEARCH')` → confirm state changed -3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued - -**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue. - -### Step 5: Run Verification Script - -After making the API calls, run the verification script to confirm everything worked: - -```bash -bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID -``` - -**If the script is not available locally**, verify manually via the public API: - -```bash -curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}' -``` - -Expected output: - -```json -{ - "id": "...", - "state": "RESEARCH", - "title": "...", - "notion_page_id": "..." -} -``` - -**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.** - -### Step 6: Output Summary and Handoff - -Print a clear summary: - -```markdown -## Ticket Intake Complete - -**Source:** Notion | GitHub -**Title:** [Ticket title] -**ID:** abc12345 -**Status:** In Progress (queued) -**Priority:** High -**Area:** UI - -### Description - -[Brief description or first 200 chars] - -### Acceptance Criteria - -- [ ] Criterion 1 -- [ ] Criterion 2 - -### Links - -- **Ticket:** [Original URL] -- **Slack:** [Slack thread content fetched via slackdump] (Notion only) - -### Pipeline - -- **API Ticket ID:** abc12345 -- **State:** RESEARCH -- **Verified:** ✅ (via verify-intake.sh or public API) -``` - -**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any): - -> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}" - -**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately. - -## Error Handling - -### Unsupported URL - -``` -❌ Unsupported ticket URL format. - -Supported formats: -- Notion: https://notion.so/... or https://www.notion.so/... -- GitHub: https://github.com/{owner}/{repo}/issues/{number} - -Received: [provided URL] -``` - -### Provider-Specific Errors - -See individual provider files for source-specific error handling: - -- `providers/notion.md` - Authentication, page not found -- `providers/github.md` - Auth, rate limits, issue not found - -### Missing Properties - -Continue with available data and note what's missing: - -``` -⚠️ Some properties unavailable: -- Priority: not found (using default: Medium) -- Area: not found - -Proceeding with available data... -``` - -### API Call Failures - -``` -❌ Pipeline API call failed: {method} {endpoint} - Status: {status} - Error: {message} - -Retrying once... - -❌ Retry also failed. INTAKE IS INCOMPLETE. - The ticket was NOT registered in the pipeline. - Downstream skills will not work until this is fixed. -``` - -## Notes - -- This skill focuses ONLY on intake — it does not do research -- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed -- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them -- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"` -- Pipeline state is tracked via the API, not local files -- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/` -- The `source` field in the ticket determines which research strategies to use - -## API Client Reference - -### Available Methods - -| Method | Description | -| ----------------------------------------------------------- | ------------------------------------------------------------------- | -| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API | -| `getTicket(id)` | Retrieve a ticket by ID | -| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID | -| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters | -| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) | -| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created | -| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) | -| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection | - -### Error Handling - -```typescript -import { PipelineClient, PipelineAPIError } from '@pipeline/client'; - -try { - await client.createTicket({ ... }); -} catch (error) { - if (error instanceof PipelineAPIError) { - console.error(`API Error ${error.status}: ${error.message}`); - } - throw error; -} -``` diff --git a/.claude/skills/ticket-intake/providers/github.md b/.claude/skills/ticket-intake/providers/github.md deleted file mode 100644 index 7ebaf1082f..0000000000 --- a/.claude/skills/ticket-intake/providers/github.md +++ /dev/null @@ -1,194 +0,0 @@ -# GitHub Provider - Ticket Intake - -Provider-specific logic for ingesting tickets from GitHub Issues. - -## URL Pattern - -``` -https://github.com/{owner}/{repo}/issues/{number} -https://www.github.com/{owner}/{repo}/issues/{number} -``` - -Extract: `owner`, `repo`, `issue_number` from URL. - -## Prerequisites - -- `gh` CLI authenticated (`gh auth status`) -- Access to the repository - -## Fetch Issue Content - -Use `gh` CLI to fetch issue details: - -```bash -# Get issue details in JSON -gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs - -# Get comments separately if needed -gh issue view {number} --repo {owner}/{repo} --comments -``` - -## Extract Ticket Data - -Map GitHub issue fields to normalized ticket data (stored via API): - -| GitHub Field | ticket.json Field | Notes | -| ------------ | ----------------- | -------------------------- | -| title | title | Direct mapping | -| body | description | Issue body/description | -| state | status | Map: open → "Not Started" | -| labels | labels | Array of label names | -| assignees | assignee | First assignee login | -| author | author | Issue author login | -| milestone | milestone | Milestone title if present | -| comments | comments | Array of comment objects | -| linkedPRs | linkedPRs | PRs linked to this issue | - -### Priority Mapping - -Infer priority from labels: - -- `priority:critical`, `P0` → "Critical" -- `priority:high`, `P1` → "High" -- `priority:medium`, `P2` → "Medium" -- `priority:low`, `P3` → "Low" -- No priority label → "Medium" (default) - -### Area Mapping - -Infer area from labels: - -- `area:ui`, `frontend`, `component:*` → "UI" -- `area:api`, `backend` → "API" -- `area:docs`, `documentation` → "Docs" -- `bug`, `fix` → "Bug" -- `enhancement`, `feature` → "Feature" - -## Update Source - -**For GitHub issues, update is optional but recommended.** - -Add a comment to indicate work has started: - -```bash -gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue." -``` - -Optionally assign to self: - -```bash -gh issue edit {number} --repo {owner}/{repo} --add-assignee @me -``` - -Log any updates via the Pipeline API: - -```typescript -await client.updateTicket(ticketId, { - metadata: { - ...ticket.metadata, - githubWrites: [ - ...(ticket.metadata?.githubWrites || []), - { - action: 'comment', - issueNumber: 123, - at: new Date().toISOString(), - skill: 'ticket-intake', - success: true - } - ] - } -}) -``` - -## GitHub-Specific Ticket Fields - -Store via API using `client.createTicket()`: - -```json -{ - "source": "github", - "githubOwner": "Comfy-Org", - "githubRepo": "ComfyUI_frontend", - "githubIssueNumber": 123, - "githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123", - "labels": ["bug", "area:ui", "priority:high"], - "linkedPRs": [456, 789], - "dosuComment": "..." // Extracted Dosu bot analysis if present -} -``` - -## Dosu Bot Detection - -Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu: - -```bash -gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu" -``` - -Look for comments from: - -- `dosu[bot]` -- `dosu-bot` - -Extract Dosu analysis which typically includes: - -- Root cause analysis -- Suggested files to modify -- Related issues/PRs -- Potential solutions - -Store in ticket data via API: - -```json -{ - "dosuComment": { - "found": true, - "analysis": "...", - "suggestedFiles": ["src/file1.ts", "src/file2.ts"], - "relatedIssues": [100, 101] - } -} -``` - -## Extract Linked Issues/PRs - -Parse issue body and comments for references: - -- `#123` → Issue or PR reference -- `fixes #123`, `closes #123` → Linked issue -- `https://github.com/.../issues/123` → Full URL reference - -Store in ticket data via API for research phase: - -```json -{ - "referencedIssues": [100, 101, 102], - "referencedPRs": [200, 201] -} -``` - -## Error Handling - -### Authentication Error - -``` -⚠️ GitHub CLI not authenticated. -Run: gh auth login -``` - -### Issue Not Found - -``` -❌ GitHub issue not found or inaccessible. -- Check the URL is correct -- Ensure you have access to this repository -- Run: gh auth status -``` - -### Rate Limiting - -``` -⚠️ GitHub API rate limited. -Wait a few minutes and try again. -Check status: gh api rate_limit -``` diff --git a/.claude/skills/ticket-intake/providers/notion.md b/.claude/skills/ticket-intake/providers/notion.md deleted file mode 100644 index 42153f9dfe..0000000000 --- a/.claude/skills/ticket-intake/providers/notion.md +++ /dev/null @@ -1,202 +0,0 @@ -# Notion Provider - Ticket Intake - -Provider-specific logic for ingesting tickets from Notion. - -## URL Pattern - -``` -https://www.notion.so/workspace/Page-Title-abc123def456... -https://notion.so/Page-Title-abc123def456... -https://www.notion.so/abc123def456... -``` - -Page ID is the 32-character hex string (with or without hyphens). - -## Prerequisites - -- Notion MCP connected and authenticated -- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp` -- Authenticate via `/mcp` command if prompted - -## Fetch Ticket Content - -Use `Notion:notion-fetch` with the page URL or ID: - -``` -Fetch the full page content including all properties -``` - -## Extract Ticket Data - -Extract these properties (names may vary): - -| Property | Expected Name | Type | -| ------------- | ------------------------- | ------------ | -| Title | Name / Title | Title | -| Status | Status | Select | -| Assignee | Assignee / Assigned To | Person | -| Description | - | Page content | -| Slack Link | Slack Link / Slack Thread | URL | -| GitHub PR | GitHub PR / PR Link | URL | -| Priority | Priority | Select | -| Area | Area / Category | Select | -| Related Tasks | Related Tasks | Relation | - -**If properties are missing**: Note what's unavailable and continue with available data. - -## Update Source (REQUIRED) - -**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.** - -**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):** - -- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written -- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done -- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status - -Use `Notion:notion-update-page` to update the ticket: - -1. **Status**: Set to "In Progress" (only valid from "Not Started") -2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`) - -```json -{ - "page_id": "{page_id_from_ticket}", - "command": "update_properties", - "properties": { - "Status": "In Progress", - "Assignee": "175d872b-594c-81d4-ba5a-0002911c5966" - } -} -``` - -**After the update succeeds**, log the write via the Pipeline API: - -```typescript -await client.updateTicket(ticketId, { - metadata: { - ...ticket.metadata, - notionWrites: [ - ...(ticket.metadata?.notionWrites || []), - { - field: 'Status', - value: 'In Progress', - previousValue: 'Not Started', - at: new Date().toISOString(), - skill: 'ticket-intake', - success: true - } - ] - } -}) -``` - -If update fails, log with `success: false` and continue. - -## Notion-Specific Ticket Fields - -Store via API using `client.createTicket()`: - -```json -{ - "source": "notion", - "notionPageId": "abc123def456...", - "slackLink": "https://slack.com/...", - "relatedTasks": ["page-id-1", "page-id-2"] -} -``` - -## Slack Thread Handling - -If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically. - -### Slack URL Conversion - -Notion stores Slack links in `slackMessage://` format: - -``` -slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS -``` - -Convert to browser-clickable format: - -``` -https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT -``` - -**Example:** - -- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829` -- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829` - -(Remove the dot from the last timestamp and prefix with `p`) - -### Fetching Thread Content - -Load the `slackdump` skill and use the **export-thread** workflow: - -```bash -# Export thread by URL -slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS" - -# Or by colon notation (channel_id:thread_ts) -slackdump dump CHANNEL_ID:THREAD_TS -``` - -Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata. - -> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`. - -## Database Reference: Comfy Tasks - -The "Comfy Tasks" database has these properties (verify via `notion-search`): - -- **Status values**: Not Started, In Progress, In Review, Done -- **Team assignment**: "Frontend Team" for unassigned tickets -- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully - -### Pipeline Owner Details - -When assigning tickets, use these identifiers: - -| Platform | Identifier | -| --------------- | -------------------------------------- | -| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` | -| Notion Name | Christian Byrne | -| Notion Email | cbyrne@comfy.org | -| Slack User ID | U087MJCDHHC | -| GitHub Username | christian-byrne | - -**To update Assignee**, use the Notion User ID (not name): - -``` -properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"} -``` - -### Finding Active Tickets - -To list your active tickets: - -``` -Use Notion:notion-search for "Comfy Tasks" -Filter by Assignee = current user OR Team = "Frontend Team" -``` - -## Error Handling - -### Authentication Error - -``` -⚠️ Notion authentication required. -Run: claude mcp add --transport http notion https://mcp.notion.com/mcp -Then authenticate via /mcp command. -``` - -### Page Not Found - -``` -❌ Notion page not found or inaccessible. -- Check the URL is correct -- Ensure you have access to this page -- Try re-authenticating via /mcp -``` diff --git a/.claude/skills/ticket-intake/schema.md b/.claude/skills/ticket-intake/schema.md deleted file mode 100644 index b82a798df1..0000000000 --- a/.claude/skills/ticket-intake/schema.md +++ /dev/null @@ -1,81 +0,0 @@ -# Ticket Schema - -Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files. - -## Ticket Data Schema - -```json -{ - // Required fields (all sources) - "id": "string", // Unique identifier (short form) - "url": "string", // Original URL - "source": "notion | github", // Source type - "title": "string", // Ticket title - "description": "string", // Full description/body - "fetchedAt": "ISO8601", // When ticket was fetched - - // Common optional fields - "status": "string", // Current status - "assignee": "string", // Assigned user - "priority": "string", // Priority level - "area": "string", // Category/area - "labels": ["string"], // Tags/labels - "acceptanceCriteria": ["string"] // List of AC items - - // Source-specific fields (see providers) - // Notion: notionPageId, slackLink, relatedTasks, notionWrites - // GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues -} -``` - -## Ticket State Schema (via API) - -State is managed via the Pipeline API using `client.transitionState()`: - -```json -{ - "ticketId": "string", - "state": "intake | research | planning | implementation | pr_created | done | failed", - "stateChangedAt": "ISO8601", - - // Timestamps tracked by API - "createdAt": "ISO8601", - "updatedAt": "ISO8601" -} -``` - -## Priority Normalization - -All sources should normalize to these values: - -| Normalized | Description | -| ---------- | ------------------------- | -| Critical | Production down, security | -| High | Blocking work, urgent | -| Medium | Normal priority (default) | -| Low | Nice to have, backlog | - -## Status Normalization - -Pipeline tracks these statuses internally: - -| Status | Description | -| -------------- | ---------------------------- | -| research | Gathering context | -| planning | Creating implementation plan | -| implementation | Writing code | -| review | Code review in progress | -| qa | Quality assurance | -| done | PR merged or completed | - -## ID Generation - -IDs are generated by the API when creating tickets. For reference: - -- **Notion**: First 8 characters of page ID -- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized) - -Examples: - -- Notion: `abc12345` -- GitHub: `gh-comfy-org-frontend-123` diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..4a7ea3036a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/actions/find-workflow-run/action.yaml b/.github/actions/find-workflow-run/action.yaml new file mode 100644 index 0000000000..bb18a56f98 --- /dev/null +++ b/.github/actions/find-workflow-run/action.yaml @@ -0,0 +1,65 @@ +name: Find Workflow Run +description: Finds a workflow run for a given commit SHA and outputs its status and run ID. + +inputs: + workflow-id: + description: The workflow filename (e.g., 'ci-size-data.yaml') + required: true + head-sha: + description: The commit SHA to find runs for + required: true + not-found-status: + description: Status to output when no run exists + required: false + default: pending + token: + description: GitHub token for API access + required: true + +outputs: + status: + description: One of 'ready', 'pending', 'failed', or the not-found-status value + value: ${{ steps.find.outputs.status }} + run-id: + description: The workflow run ID (only set when status is 'ready') + value: ${{ steps.find.outputs.run-id }} + +runs: + using: composite + steps: + - name: Find workflow run + id: find + uses: actions/github-script@v8 + env: + WORKFLOW_ID: ${{ inputs.workflow-id }} + HEAD_SHA: ${{ inputs.head-sha }} + NOT_FOUND_STATUS: ${{ inputs.not-found-status }} + with: + github-token: ${{ inputs.token }} + script: | + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: process.env.WORKFLOW_ID, + head_sha: process.env.HEAD_SHA, + per_page: 1, + }); + + const run = runs.workflow_runs[0]; + if (!run) { + core.setOutput('status', process.env.NOT_FOUND_STATUS); + return; + } + + if (run.status !== 'completed') { + core.setOutput('status', 'pending'); + return; + } + + if (run.conclusion !== 'success') { + core.setOutput('status', 'failed'); + return; + } + + core.setOutput('status', 'ready'); + core.setOutput('run-id', String(run.id)); diff --git a/.github/actions/lint-format-verify/action.yml b/.github/actions/lint-format-verify/action.yml new file mode 100644 index 0000000000..1142097123 --- /dev/null +++ b/.github/actions/lint-format-verify/action.yml @@ -0,0 +1,31 @@ +name: 'Lint and format verify' +description: > + Runs the lint/format/knip verification suite plus a conditional + browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and + ci-lint-format-queue.yaml (merge queue) so both paths run the exact + same checks. The caller is responsible for checkout and frontend setup + before invoking this action. + +runs: + using: composite + steps: + - name: Detect browser_tests changes + id: changed-paths + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + browser_tests: + - 'browser_tests/**' + + - name: Verify lint and format + shell: bash + run: | + pnpm lint + pnpm stylelint + pnpm format:check + pnpm knip + + - name: Typecheck browser tests + if: steps.changed-paths.outputs.browser_tests == 'true' + shell: bash + run: pnpm typecheck:browser diff --git a/.github/actions/setup-frontend/action.yaml b/.github/actions/setup-frontend/action.yaml index 3e7e829ce7..44108182fd 100644 --- a/.github/actions/setup-frontend/action.yaml +++ b/.github/actions/setup-frontend/action.yaml @@ -13,8 +13,6 @@ runs: # Install pnpm, Node.js, build frontend - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/api-update-electron-api-types.yaml b/.github/workflows/api-update-electron-api-types.yaml index b02d504674..befffc6f1d 100644 --- a/.github/workflows/api-update-electron-api-types.yaml +++ b/.github/workflows/api-update-electron-api-types.yaml @@ -17,8 +17,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/api-update-manager-api-types.yaml b/.github/workflows/api-update-manager-api-types.yaml index 26a8ba47f8..99a148fff7 100644 --- a/.github/workflows/api-update-manager-api-types.yaml +++ b/.github/workflows/api-update-manager-api-types.yaml @@ -22,8 +22,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/api-update-registry-api-types.yaml b/.github/workflows/api-update-registry-api-types.yaml deleted file mode 100644 index 1f84baf47e..0000000000 --- a/.github/workflows/api-update-registry-api-types.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo -name: 'Api: Update Registry API Types' - -on: - # Manual trigger - workflow_dispatch: - - # Triggered from comfy-api repo - repository_dispatch: - types: [comfy-api-updated] - -jobs: - update-registry-types: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Checkout comfy-api repository - uses: actions/checkout@v6 - with: - repository: Comfy-Org/comfy-api - path: comfy-api - token: ${{ secrets.COMFY_API_PAT }} - clean: true - - - name: Get API commit information - id: api-info - run: | - cd comfy-api - API_COMMIT=$(git rev-parse --short HEAD) - echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT - cd .. - - - name: Generate API types - run: | - echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..." - mkdir -p ./packages/registry-types/src - pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts - - - name: Validate generated types - run: | - if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then - echo "Error: Types file was not generated." - exit 1 - fi - - # Check if file is not empty - if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then - echo "Error: Generated types file is empty." - exit 1 - fi - - - name: Lint generated types - run: | - echo "Linting generated Comfy Registry API types..." - pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts - - - name: Check for changes - id: check-changes - run: | - if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then - echo "No changes to Comfy Registry API types detected." - echo "changed=false" >> $GITHUB_OUTPUT - exit 0 - else - echo "Changes detected in Comfy Registry API types." - echo "changed=true" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.check-changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 - with: - token: ${{ secrets.PR_GH_TOKEN }} - commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}' - title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}' - body: | - ## Automated API Type Update - - This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification. - - - API commit: ${{ steps.api-info.outputs.commit }} - - Generated on: ${{ github.event.repository.updated_at }} - - These types are automatically generated using openapi-typescript. - branch: update-registry-types-${{ steps.api-info.outputs.commit }} - base: main - labels: CNR - delete-branch: true - add-paths: | - packages/registry-types/src/comfyRegistryTypes.ts diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index 14f98434ee..1821efd95d 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -20,8 +20,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Use Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 diff --git a/.github/workflows/ci-lint-format-queue.yaml b/.github/workflows/ci-lint-format-queue.yaml new file mode 100644 index 0000000000..de064ca885 --- /dev/null +++ b/.github/workflows/ci-lint-format-queue.yaml @@ -0,0 +1,29 @@ +# Description: Lint and format verification for GitHub merge queue runs. +# Paired with ci-lint-format.yaml — workflow name and job name must match +# so branch protection resolves a single required check in both the +# pull_request and merge_group contexts. This file runs verify-only steps +# with a read-only token; auto-fix and PR comments live in the PR workflow. +name: 'CI: Lint Format' + +on: + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint-and-format: + runs-on: ubuntu-latest + steps: + - name: Checkout merge group ref + uses: actions/checkout@v6 + + - name: Setup frontend + uses: ./.github/actions/setup-frontend + + - name: Verify lint and format + uses: ./.github/actions/lint-format-verify diff --git a/.github/workflows/ci-lint-format.yaml b/.github/workflows/ci-lint-format.yaml index 157bd576d2..6205dbed8c 100644 --- a/.github/workflows/ci-lint-format.yaml +++ b/.github/workflows/ci-lint-format.yaml @@ -1,4 +1,7 @@ -# Description: Linting and code formatting validation for pull requests +# Description: Linting and code formatting validation for pull requests. +# Paired with ci-lint-format-queue.yaml - workflow name and job name must +# match so branch protection resolves a single required check in both the +# pull_request and merge_group contexts. name: 'CI: Lint Format' on: @@ -26,14 +29,6 @@ jobs: - name: Setup frontend uses: ./.github/actions/setup-frontend - - name: Detect browser_tests changes - id: changed-paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - browser_tests: - - 'browser_tests/**' - - name: Run ESLint with auto-fix run: pnpm lint:fix @@ -77,16 +72,8 @@ jobs: echo "See CONTRIBUTING.md for more details." exit 1 - - name: Final validation - run: | - pnpm lint - pnpm stylelint - pnpm format:check - pnpm knip - - - name: Typecheck browser tests - if: steps.changed-paths.outputs.browser_tests == 'true' - run: pnpm typecheck:browser + - name: Verify lint and format + uses: ./.github/actions/lint-format-verify - name: Comment on PR about auto-fix if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/ci-oss-assets-validation.yaml b/.github/workflows/ci-oss-assets-validation.yaml index 5a41779294..a145ca04f1 100644 --- a/.github/workflows/ci-oss-assets-validation.yaml +++ b/.github/workflows/ci-oss-assets-validation.yaml @@ -21,8 +21,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Use Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -76,8 +74,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Use Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -99,7 +95,7 @@ jobs: if npx license-checker-rseidelsohn@4 \ --production \ --summary \ - --excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \ + --excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \ --clarificationsFile .github/license-clarifications.json \ --onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then echo '' diff --git a/.github/workflows/ci-tests-e2e-coverage.yaml b/.github/workflows/ci-tests-e2e-coverage.yaml new file mode 100644 index 0000000000..8fdfe04279 --- /dev/null +++ b/.github/workflows/ci-tests-e2e-coverage.yaml @@ -0,0 +1,100 @@ +name: 'CI: E2E Coverage' + +on: + workflow_run: + workflows: ['CI: Tests E2E'] + types: + - completed + +concurrency: + group: e2e-coverage-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + merge: + if: > + github.repository == 'Comfy-Org/ComfyUI_frontend' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup frontend + uses: ./.github/actions/setup-frontend + + - name: Download all shard coverage data + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + run_id: ${{ github.event.workflow_run.id }} + name: e2e-coverage-shard-.* + name_is_regexp: true + path: temp/coverage-shards + if_no_artifact_found: warn + + - name: Install lcov + run: sudo apt-get install -y -qq lcov + + - name: Merge shard coverage into single LCOV + run: | + mkdir -p coverage/playwright + LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f) + if [ -z "$LCOV_FILES" ]; then + echo "No coverage.lcov files found" + touch coverage/playwright/coverage.lcov + exit 0 + fi + ADD_ARGS="" + for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done + lcov $ADD_ARGS -o coverage/playwright/coverage.lcov + wc -l coverage/playwright/coverage.lcov + + - name: Validate merged coverage + run: | + SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ') + if [ "$SHARD_COUNT" -eq 0 ]; then + echo "::error::No shard coverage.lcov files found under temp/coverage-shards" + exit 1 + fi + + MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0) + MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov) + MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov) + echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY" + echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY" + echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY" + for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do + SHARD=$(basename "$(dirname "$f")") + SHARD_SF=$(grep -c '^SF:' "$f" || echo 0) + SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f") + echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY" + if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then + echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss" + fi + done + + - name: Upload merged coverage data + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e-coverage + path: coverage/playwright/ + retention-days: 30 + if-no-files-found: warn + + - name: Upload E2E coverage to Codecov + if: always() + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + with: + files: coverage/playwright/coverage.lcov + flags: e2e + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index 6994315fe4..59217f742c 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -8,6 +8,7 @@ on: pull_request: branches-ignore: [wip/*, draft/*, temp/*] paths-ignore: ['**/*.md'] + merge_group: workflow_dispatch: concurrency: @@ -33,6 +34,20 @@ jobs: path: dist/ retention-days: 1 + # Build cloud distribution for @cloud tagged tests + # NX_SKIP_NX_CACHE=true is required because `nx build` was already run + # for the OSS distribution above. Without skipping cache, Nx returns + # the cached OSS build since env vars aren't part of the cache key. + - name: Build cloud frontend + run: NX_SKIP_NX_CACHE=true pnpm build:cloud + + - name: Upload cloud frontend + uses: actions/upload-artifact@v6 + with: + name: frontend-dist-cloud + path: dist/ + retention-days: 1 + # Sharded chromium tests playwright-tests-chromium-sharded: needs: setup @@ -72,6 +87,7 @@ jobs: run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob env: PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report + COLLECT_COVERAGE: 'true' - name: Upload blob report uses: actions/upload-artifact@v6 @@ -81,6 +97,15 @@ jobs: path: blob-report/ retention-days: 1 + - name: Upload shard coverage data + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e-coverage-shard-${{ matrix.shardIndex }} + path: coverage/playwright/ + retention-days: 1 + if-no-files-found: warn + playwright-tests: # Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes timeout-minutes: 15 @@ -97,14 +122,14 @@ jobs: strategy: fail-fast: false matrix: - browser: [chromium-2x, chromium-0.5x, mobile-chrome] + browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud] steps: - name: Checkout repository uses: actions/checkout@v6 - name: Download built frontend uses: actions/download-artifact@v7 with: - name: frontend-dist + name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }} path: dist/ - name: Start ComfyUI server diff --git a/.github/workflows/ci-tests-unit.yaml b/.github/workflows/ci-tests-unit.yaml index 914030c7d8..3fc3095bd6 100644 --- a/.github/workflows/ci-tests-unit.yaml +++ b/.github/workflows/ci-tests-unit.yaml @@ -1,4 +1,4 @@ -# Description: Unit and component testing with Vitest +# Description: Unit and component testing with Vitest + coverage reporting name: 'CI: Tests Unit' on: @@ -8,6 +8,7 @@ on: pull_request: branches-ignore: [wip/*, draft/*, temp/*] paths-ignore: ['**/*.md'] + merge_group: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -23,5 +24,23 @@ jobs: - name: Setup frontend uses: ./.github/actions/setup-frontend - - name: Run Vitest tests - run: pnpm test:unit + - name: Run Vitest tests with coverage + run: pnpm test:coverage + + - name: Upload unit coverage artifact + if: always() && github.event_name == 'push' + uses: actions/upload-artifact@v6 + with: + name: unit-coverage + path: coverage/lcov.info + retention-days: 30 + if-no-files-found: warn + + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + with: + files: coverage/lcov.info + flags: unit + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/ci-website-build.yaml b/.github/workflows/ci-website-build.yaml new file mode 100644 index 0000000000..832854c2eb --- /dev/null +++ b/.github/workflows/ci-website-build.yaml @@ -0,0 +1,33 @@ +# Description: Build and validate the marketing website (apps/website) +name: 'CI: Website Build' + +on: + push: + branches: [main, master, website/*] + paths: + - 'apps/website/**' + - 'packages/design-system/**' + - 'pnpm-lock.yaml' + pull_request: + branches-ignore: [wip/*, draft/*, temp/*] + paths: + - 'apps/website/**' + - 'packages/design-system/**' + - 'pnpm-lock.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup frontend + uses: ./.github/actions/setup-frontend + + - name: Build website + run: pnpm --filter @comfyorg/website build diff --git a/.github/workflows/coverage-slack-notify.yaml b/.github/workflows/coverage-slack-notify.yaml new file mode 100644 index 0000000000..7c9896bbc9 --- /dev/null +++ b/.github/workflows/coverage-slack-notify.yaml @@ -0,0 +1,149 @@ +name: 'Coverage: Slack Notification' + +on: + workflow_run: + workflows: ['CI: Tests Unit'] + branches: [main] + types: + - completed + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + notify: + if: > + github.repository == 'Comfy-Org/ComfyUI_frontend' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup frontend + uses: ./.github/actions/setup-frontend + + - name: Download current unit coverage + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + run_id: ${{ github.event.workflow_run.id }} + name: unit-coverage + path: coverage + + - name: Download previous unit coverage baseline + continue-on-error: true + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + branch: main + workflow: coverage-slack-notify.yaml + name: unit-coverage-baseline + path: temp/coverage-baseline + if_no_artifact_found: warn + + - name: Download latest E2E coverage + continue-on-error: true + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + branch: main + workflow: ci-tests-e2e-coverage.yaml + name: e2e-coverage + path: temp/e2e-coverage + if_no_artifact_found: warn + + - name: Download previous E2E coverage baseline + continue-on-error: true + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + branch: main + workflow: coverage-slack-notify.yaml + name: e2e-coverage-baseline + path: temp/e2e-coverage-baseline + if_no_artifact_found: warn + + - name: Resolve merged PR metadata + id: pr-meta + uses: actions/github-script@v8 + with: + script: | + const sha = context.payload.workflow_run.head_sha; + const { data: commit } = await github.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + }); + const message = commit.commit.message ?? ''; + const firstLine = message.split('\n')[0]; + const match = firstLine.match(/\(#(\d+)\)\s*$/); + if (!match) { + core.setOutput('skip', 'true'); + core.info('No PR number found in commit message — skipping.'); + return; + } + const prNumber = match[1]; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(prNumber), + }); + core.setOutput('skip', 'false'); + core.setOutput('number', prNumber); + core.setOutput('url', pr.html_url); + core.setOutput('author', pr.user.login); + + - name: Generate Slack notification + if: steps.pr-meta.outputs.skip != 'true' + id: slack-payload + env: + PR_URL: ${{ steps.pr-meta.outputs.url }} + PR_NUMBER: ${{ steps.pr-meta.outputs.number }} + PR_AUTHOR: ${{ steps.pr-meta.outputs.author }} + run: | + PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \ + --pr-url="$PR_URL" \ + --pr-number="$PR_NUMBER" \ + --author="$PR_AUTHOR") + if [ -n "$PAYLOAD" ]; then + echo "has_payload=true" >> "$GITHUB_OUTPUT" + DELIM="SLACK_PAYLOAD_$(date +%s)" + echo "payload<<$DELIM" >> "$GITHUB_OUTPUT" + printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT" + echo "$DELIM" >> "$GITHUB_OUTPUT" + else + echo "has_payload=false" >> "$GITHUB_OUTPUT" + fi + + - name: Post to Slack + if: steps.slack-payload.outputs.has_payload == 'true' + continue-on-error: true + env: + SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: | + # Channel: #p-deprecated-frontend-automated-testing + BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}') + curl -sf -X POST \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + -o /dev/null \ + https://slack.com/api/chat.postMessage + + - name: Save unit coverage baseline + if: always() && hashFiles('coverage/lcov.info') != '' + uses: actions/upload-artifact@v6 + with: + name: unit-coverage-baseline + path: coverage/lcov.info + retention-days: 90 + if-no-files-found: warn + + - name: Save E2E coverage baseline + if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != '' + uses: actions/upload-artifact@v6 + with: + name: e2e-coverage-baseline + path: temp/e2e-coverage/coverage.lcov + retention-days: 90 + if-no-files-found: warn diff --git a/.github/workflows/pr-claude-review.yaml b/.github/workflows/pr-claude-review.yaml index c9cfd88b6a..df819b9226 100644 --- a/.github/workflows/pr-claude-review.yaml +++ b/.github/workflows/pr-claude-review.yaml @@ -30,8 +30,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr-report.yaml b/.github/workflows/pr-report.yaml index 1fecfdc567..839c61d7b8 100644 --- a/.github/workflows/pr-report.yaml +++ b/.github/workflows/pr-report.yaml @@ -2,7 +2,7 @@ name: 'PR: Unified Report' on: workflow_run: - workflows: ['CI: Size Data', 'CI: Performance Report'] + workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage'] types: - completed @@ -67,73 +67,23 @@ jobs: core.setOutput('base', livePr.base.ref); core.setOutput('head-sha', livePr.head.sha); - - name: Find size workflow run for this commit + - name: Find size workflow run if: steps.pr-meta.outputs.skip != 'true' id: find-size - uses: actions/github-script@v8 + uses: ./.github/actions/find-workflow-run with: - script: | - const headSha = '${{ steps.pr-meta.outputs.head-sha }}'; - const { data: runs } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'ci-size-data.yaml', - head_sha: headSha, - per_page: 1, - }); + workflow-id: ci-size-data.yaml + head-sha: ${{ steps.pr-meta.outputs.head-sha }} + token: ${{ secrets.GITHUB_TOKEN }} - const run = runs.workflow_runs[0]; - if (!run) { - core.setOutput('status', 'pending'); - return; - } - - if (run.status !== 'completed') { - core.setOutput('status', 'pending'); - return; - } - - if (run.conclusion !== 'success') { - core.setOutput('status', 'failed'); - return; - } - - core.setOutput('status', 'ready'); - core.setOutput('run-id', String(run.id)); - - - name: Find perf workflow run for this commit + - name: Find perf workflow run if: steps.pr-meta.outputs.skip != 'true' id: find-perf - uses: actions/github-script@v8 + uses: ./.github/actions/find-workflow-run with: - script: | - const headSha = '${{ steps.pr-meta.outputs.head-sha }}'; - const { data: runs } = await github.rest.actions.listWorkflowRuns({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'ci-perf-report.yaml', - head_sha: headSha, - per_page: 1, - }); - - const run = runs.workflow_runs[0]; - if (!run) { - core.setOutput('status', 'pending'); - return; - } - - if (run.status !== 'completed') { - core.setOutput('status', 'pending'); - return; - } - - if (run.conclusion !== 'success') { - core.setOutput('status', 'failed'); - return; - } - - core.setOutput('status', 'ready'); - core.setOutput('run-id', String(run.id)); + workflow-id: ci-perf-report.yaml + head-sha: ${{ steps.pr-meta.outputs.head-sha }} + token: ${{ secrets.GITHUB_TOKEN }} - name: Download size data (current) if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready' @@ -154,6 +104,25 @@ jobs: path: temp/size-prev if_no_artifact_found: warn + - name: Find coverage workflow run + if: steps.pr-meta.outputs.skip != 'true' + id: find-coverage + uses: ./.github/actions/find-workflow-run + with: + workflow-id: ci-tests-e2e-coverage.yaml + head-sha: ${{ steps.pr-meta.outputs.head-sha }} + not-found-status: skip + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download coverage data + if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready' + uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 + with: + name: e2e-coverage + run_id: ${{ steps.find-coverage.outputs.run-id }} + path: temp/coverage + if_no_artifact_found: warn + - name: Download perf metrics (current) if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready' uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12 @@ -189,9 +158,10 @@ jobs: - name: Generate unified report if: steps.pr-meta.outputs.skip != 'true' run: > - node scripts/unified-report.js + pnpm exec tsx scripts/unified-report.ts --size-status=${{ steps.find-size.outputs.status }} --perf-status=${{ steps.find-perf.outputs.status }} + --coverage-status=${{ steps.find-coverage.outputs.status }} > pr-report.md - name: Remove legacy separate comments diff --git a/.github/workflows/publish-desktop-ui.yaml b/.github/workflows/publish-desktop-ui.yaml index 80954351b7..cfa8cb21f7 100644 --- a/.github/workflows/publish-desktop-ui.yaml +++ b/.github/workflows/publish-desktop-ui.yaml @@ -85,8 +85,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release-biweekly-comfyui.yaml b/.github/workflows/release-biweekly-comfyui.yaml index 7e8ec9140a..8c3f181fdb 100644 --- a/.github/workflows/release-biweekly-comfyui.yaml +++ b/.github/workflows/release-biweekly-comfyui.yaml @@ -1,14 +1,23 @@ -# Automated bi-weekly workflow to bump ComfyUI frontend RC releases -name: 'Release: Bi-weekly ComfyUI' +# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR. +# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases. +name: 'Release: ComfyUI' on: - # Schedule for Monday at 12:00 PM PST (20:00 UTC) + # Bi-weekly schedule: Monday at 20:00 UTC schedule: - cron: '0 20 * * 1' - # Allow manual triggering (bypasses bi-weekly check) + # Manual trigger for both on-demand minor and patch/hotfix releases workflow_dispatch: inputs: + release_type: + description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version' + required: true + default: 'minor' + type: choice + options: + - minor + - patch comfyui_fork: description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)' required: false @@ -41,10 +50,11 @@ jobs: - name: Summary run: | - echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY + echo "## Release Check" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY + echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY resolve-version: needs: check-release-week @@ -77,7 +87,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 with: - version: 10 + package_json_file: frontend/package.json - name: Setup Node.js uses: actions/setup-node@v6 @@ -91,6 +101,8 @@ jobs: - name: Resolve release information id: resolve working-directory: frontend + env: + RELEASE_TYPE: ${{ inputs.release_type || 'minor' }} run: | set -euo pipefail @@ -203,8 +215,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/release-draft-create.yaml b/.github/workflows/release-draft-create.yaml index 0ce163c3ba..e6a442e7c3 100644 --- a/.github/workflows/release-draft-create.yaml +++ b/.github/workflows/release-draft-create.yaml @@ -20,10 +20,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' diff --git a/.github/workflows/release-npm-types.yaml b/.github/workflows/release-npm-types.yaml index 2895ef94c0..5c43f353c4 100644 --- a/.github/workflows/release-npm-types.yaml +++ b/.github/workflows/release-npm-types.yaml @@ -76,8 +76,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release-pypi-dev.yaml b/.github/workflows/release-pypi-dev.yaml index a7bb820610..315e7566b1 100644 --- a/.github/workflows/release-pypi-dev.yaml +++ b/.github/workflows/release-pypi-dev.yaml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' diff --git a/.github/workflows/release-version-bump.yaml b/.github/workflows/release-version-bump.yaml index d9bbc74f1e..0b7fafb3c3 100644 --- a/.github/workflows/release-version-bump.yaml +++ b/.github/workflows/release-version-bump.yaml @@ -142,10 +142,22 @@ jobs: fi echo "✅ Branch '$BRANCH' exists" + - name: Ensure packageManager field exists + run: | + if ! grep -q '"packageManager"' package.json; then + # Old branches (e.g. core/1.42) predate the packageManager field. + # Inject it so pnpm/action-setup can resolve the version. + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json','utf8')); + pkg.packageManager = 'pnpm@10.33.0'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + echo "Injected packageManager into package.json for legacy branch" + fi + - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/version-bump-desktop-ui.yaml b/.github/workflows/version-bump-desktop-ui.yaml index f47fe9e776..fc20daa8b6 100644 --- a/.github/workflows/version-bump-desktop-ui.yaml +++ b/.github/workflows/version-bump-desktop-ui.yaml @@ -52,8 +52,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/weekly-docs-check.yaml b/.github/workflows/weekly-docs-check.yaml index 418923daab..8e1b4e72ad 100644 --- a/.github/workflows/weekly-docs-check.yaml +++ b/.github/workflows/weekly-docs-check.yaml @@ -30,8 +30,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - version: 10 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.oxlintrc.json b/.oxlintrc.json index aa8a4c1ccd..19700f80c2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -64,6 +64,7 @@ ] } ], + "no-unsafe-optional-chaining": "error", "no-self-assign": "allow", "no-unused-expressions": "off", "no-unused-private-class-members": "off", @@ -83,6 +84,7 @@ "typescript/no-unsafe-declaration-merging": "off", "typescript/no-unused-vars": "off", "unicorn/no-empty-file": "off", + "vitest/require-mock-type-parameters": "off", "unicorn/no-new-array": "off", "unicorn/no-single-promise-in-promise-methods": "off", "unicorn/no-useless-fallback-in-spread": "off", @@ -104,8 +106,7 @@ "allowInterfaces": "always" } ], - "vue/no-import-compiler-macros": "error", - "vue/no-dupe-keys": "error" + "vue/no-import-compiler-macros": "error" }, "overrides": [ { @@ -116,13 +117,60 @@ }, { "files": ["browser_tests/**/*.ts"], + "jsPlugins": ["eslint-plugin-playwright"], "rules": { "typescript/no-explicit-any": "error", "no-async-promise-executor": "error", "no-control-regex": "error", "no-useless-rename": "error", "no-unused-private-class-members": "error", - "unicorn/no-empty-file": "error" + "unicorn/no-empty-file": "error", + "playwright/consistent-spacing-between-blocks": "error", + "playwright/expect-expect": [ + "error", + { + "assertFunctionNames": [ + "recordMeasurement", + "logMeasurement", + "builderSaveAs" + ], + "assertFunctionPatterns": [ + "^expect", + "^assert", + "^verify", + "^searchAndExpect", + "waitForOpen", + "waitForClosed", + "waitForRequest" + ] + } + ], + "playwright/max-nested-describe": "error", + "playwright/no-duplicate-hooks": "error", + "playwright/no-element-handle": "error", + "playwright/no-eval": "error", + "playwright/no-focused-test": "error", + "playwright/no-force-option": "error", + "playwright/no-networkidle": "error", + "playwright/no-page-pause": "error", + "playwright/no-skipped-test": "error", + "playwright/no-unsafe-references": "error", + "playwright/no-unused-locators": "error", + "playwright/no-useless-await": "error", + "playwright/no-useless-not": "error", + "playwright/no-wait-for-navigation": "error", + "playwright/no-wait-for-selector": "error", + "playwright/no-wait-for-timeout": "error", + "playwright/prefer-hooks-on-top": "error", + "playwright/prefer-locator": "error", + "playwright/prefer-to-have-count": "error", + "playwright/prefer-to-have-length": "error", + "playwright/prefer-web-first-assertions": "error", + "playwright/prefer-native-locators": "error", + "playwright/require-to-pass-timeout": "error", + "playwright/valid-expect": "error", + "playwright/valid-expect-in-promise": "error", + "playwright/valid-title": "error" } } ] diff --git a/AGENTS.md b/AGENTS.md index d692cf22fd..9463e5357d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,12 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g 24. Do not use function expressions if it's possible to use function declarations instead 25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them +## Design Standards + +Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time. + +See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references. + ## Testing Guidelines See @docs/testing/\*.md for detailed patterns. @@ -216,6 +222,7 @@ See @docs/testing/\*.md for detailed patterns. 1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices) 2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions) 3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests +4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table ## External Resources @@ -225,6 +232,7 @@ See @docs/testing/\*.md for detailed patterns. - shadcn/vue: - Reka UI: - PrimeVue: +- Comfy Design Standards: - ComfyUI: - Electron: - Wiki: @@ -310,6 +318,9 @@ When referencing Comfy-Org repos: - Find existing `!important` classes that are interfering with the styling and propose corrections of those instead. - NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists - Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc. +- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons + - Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results + - Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally ## Agent-only rules diff --git a/CODEOWNERS b/CODEOWNERS index 2fb3dc08e4..030e550a94 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,12 +41,49 @@ /src/components/templates/ @Myestery @christian-byrne @comfyui-wiki # Mask Editor -/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp -/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp +/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 +/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 +/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 +/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 +/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 +/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 + +# Image Crop +/src/extensions/core/imageCrop.ts @jtydhr88 +/src/components/imagecrop/ @jtydhr88 +/src/composables/useImageCrop.ts @jtydhr88 +/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 + +# Image Compare +/src/extensions/core/imageCompare.ts @jtydhr88 +/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 +/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 +/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 +/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 +/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 + +# Painter +/src/extensions/core/painter.ts @jtydhr88 +/src/components/painter/ @jtydhr88 +/src/composables/painter/ @jtydhr88 +/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 +/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 + +# GLSL +/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne # 3D /src/extensions/core/load3d.ts @jtydhr88 +/src/extensions/core/load3dLazy.ts @jtydhr88 +/src/extensions/core/load3d/ @jtydhr88 /src/components/load3d/ @jtydhr88 +/src/composables/useLoad3d.ts @jtydhr88 +/src/composables/useLoad3d.test.ts @jtydhr88 +/src/composables/useLoad3dDrag.ts @jtydhr88 +/src/composables/useLoad3dDrag.test.ts @jtydhr88 +/src/composables/useLoad3dViewer.ts @jtydhr88 +/src/composables/useLoad3dViewer.test.ts @jtydhr88 +/src/services/load3dService.ts @jtydhr88 # Manager /src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef7b920948..2bfa098f65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,37 @@ python main.py --port 8188 --cpu - Run `pnpm dev:electron` to start the dev server with electron API mocked - Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server) +#### Testing with Cloud & Staging Environments + +Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend: + +**Option 1: Frontend — `pnpm dev:cloud`** + +The simplest approach. This proxies all API requests to the test cloud environment: + +```bash +pnpm dev:cloud +``` + +This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment: + +```bash +# .env +DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ +``` + +Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs. + +**Option 2: Backend — `--comfy-api-base`** + +Alternatively, launch the ComfyUI backend pointed at the staging API: + +```bash +python main.py --comfy-api-base https://stagingapi.comfy.org --verbose +``` + +Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging. + #### Access dev server on touch devices Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`. diff --git a/apps/desktop-ui/package.json b/apps/desktop-ui/package.json index d47be69fec..689b9cb72f 100644 --- a/apps/desktop-ui/package.json +++ b/apps/desktop-ui/package.json @@ -5,6 +5,7 @@ "scripts": { "lint": "nx run @comfyorg/desktop-ui:lint", "typecheck": "nx run @comfyorg/desktop-ui:typecheck", + "test:unit": "vitest run --config vitest.config.mts", "storybook": "storybook dev -p 6007", "build-storybook": "storybook build -o dist/storybook" }, diff --git a/apps/desktop-ui/src/components/common/StartupDisplay.test.ts b/apps/desktop-ui/src/components/common/StartupDisplay.test.ts new file mode 100644 index 0000000000..7de8561ed4 --- /dev/null +++ b/apps/desktop-ui/src/components/common/StartupDisplay.test.ts @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/vue' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import StartupDisplay from '@/components/common/StartupDisplay.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { logoAlt: 'ComfyUI' } } } +}) + +const ProgressBarStub = { + props: ['mode', 'value', 'showValue'], + template: + '
' +} + +function renderDisplay( + props: { + progressPercentage?: number + title?: string + statusText?: string + hideProgress?: boolean + fullScreen?: boolean + } = {} +) { + return render(StartupDisplay, { + props, + global: { + plugins: [[PrimeVue, { unstyled: true }], i18n], + stubs: { ProgressBar: ProgressBarStub } + } + }) +} + +describe('StartupDisplay', () => { + describe('progressMode', () => { + it('renders indeterminate mode when progressPercentage is undefined', () => { + renderDisplay() + expect(screen.getByTestId('progress-bar').dataset.mode).toBe( + 'indeterminate' + ) + }) + + it('renders determinate mode when progressPercentage is provided', () => { + renderDisplay({ progressPercentage: 50 }) + expect(screen.getByTestId('progress-bar').dataset.mode).toBe( + 'determinate' + ) + }) + + it('passes progressPercentage as value to the progress bar', () => { + renderDisplay({ progressPercentage: 75 }) + expect(screen.getByTestId('progress-bar').dataset.value).toBe('75') + }) + }) + + describe('hideProgress', () => { + it('hides the progress bar when hideProgress is true', () => { + renderDisplay({ hideProgress: true }) + expect(screen.queryByTestId('progress-bar')).toBeNull() + }) + + it('shows the progress bar by default', () => { + renderDisplay() + expect(screen.getByTestId('progress-bar')).toBeDefined() + }) + }) + + describe('title', () => { + it('renders the title text when provided', () => { + renderDisplay({ title: 'Loading...' }) + expect(screen.getByText('Loading...')).toBeDefined() + }) + + it('does not render h1 when title is not provided', () => { + renderDisplay() + expect(screen.queryByRole('heading', { level: 1 })).toBeNull() + }) + }) + + describe('statusText', () => { + it('renders statusText with data-testid attribute', () => { + renderDisplay({ statusText: 'Starting server' }) + expect(screen.getByTestId('startup-status-text').textContent).toContain( + 'Starting server' + ) + }) + + it('does not render statusText element when not provided', () => { + renderDisplay() + expect(screen.queryByTestId('startup-status-text')).toBeNull() + }) + }) +}) diff --git a/apps/desktop-ui/src/components/common/UrlInput.test.ts b/apps/desktop-ui/src/components/common/UrlInput.test.ts new file mode 100644 index 0000000000..410d8f6d41 --- /dev/null +++ b/apps/desktop-ui/src/components/common/UrlInput.test.ts @@ -0,0 +1,208 @@ +import { render, screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({ + checkUrlReachable: vi.fn() +})) + +import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil' +import UrlInput from '@/components/common/UrlInput.vue' +import { ValidationState } from '@/utils/validationUtil' + +const InputTextStub = { + props: ['modelValue', 'invalid'], + emits: ['update:modelValue', 'blur'], + template: `` +} + +const InputIconStub = { + template: '' +} + +const IconFieldStub = { + template: '
' +} + +function renderUrlInput( + modelValue = '', + validateUrlFn?: (url: string) => Promise +) { + return render(UrlInput, { + props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) }, + global: { + plugins: [[PrimeVue, { unstyled: true }]], + stubs: { + InputText: InputTextStub, + InputIcon: InputIconStub, + IconField: IconFieldStub + } + } + }) +} + +describe('UrlInput', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('initial validation on mount', () => { + it('stays IDLE when modelValue is empty on mount', async () => { + renderUrlInput('') + await waitFor(() => { + expect(screen.getByTestId('url-input').dataset.invalid).toBe('false') + }) + }) + + it('sets VALID state when modelValue is a reachable URL on mount', async () => { + vi.mocked(checkUrlReachable).mockResolvedValue(true) + renderUrlInput('https://example.com') + await waitFor(() => { + expect(screen.getByTestId('url-input').dataset.invalid).toBe('false') + }) + }) + + it('sets INVALID state when URL is not reachable on mount', async () => { + vi.mocked(checkUrlReachable).mockResolvedValue(false) + renderUrlInput('https://unreachable.example') + await waitFor(() => { + expect(screen.getByTestId('url-input').dataset.invalid).toBe('true') + }) + }) + }) + + describe('input handling', () => { + it('resets validation state to IDLE on user input', async () => { + vi.mocked(checkUrlReachable).mockResolvedValue(false) + renderUrlInput('https://bad.example') + + await waitFor(() => { + expect(screen.getByTestId('url-input').dataset.invalid).toBe('true') + }) + + const user = userEvent.setup() + await user.type(screen.getByTestId('url-input'), 'x') + expect(screen.getByTestId('url-input').dataset.invalid).toBe('false') + }) + + it('strips whitespace from typed input', async () => { + const onUpdate = vi.fn() + render(UrlInput, { + props: { + modelValue: '', + 'onUpdate:modelValue': onUpdate + }, + global: { + plugins: [[PrimeVue, { unstyled: true }]], + stubs: { + InputText: InputTextStub, + InputIcon: InputIconStub, + IconField: IconFieldStub + } + } + }) + + const user = userEvent.setup() + const input = screen.getByTestId('url-input') + await user.type(input, 'htt ps') + expect((input as HTMLInputElement).value).not.toContain(' ') + }) + }) + + describe('blur handling', () => { + it('emits update:modelValue on blur', async () => { + const onUpdate = vi.fn() + render(UrlInput, { + props: { + modelValue: 'https://example.com', + 'onUpdate:modelValue': onUpdate + }, + global: { + plugins: [[PrimeVue, { unstyled: true }]], + stubs: { + InputText: InputTextStub, + InputIcon: InputIconStub, + IconField: IconFieldStub + } + } + }) + + const user = userEvent.setup() + await user.click(screen.getByTestId('url-input')) + await user.tab() + + expect(onUpdate).toHaveBeenCalled() + }) + + it('normalizes URL on blur', async () => { + const onUpdate = vi.fn() + render(UrlInput, { + props: { + modelValue: 'https://example.com', + 'onUpdate:modelValue': onUpdate + }, + global: { + plugins: [[PrimeVue, { unstyled: true }]], + stubs: { + InputText: InputTextStub, + InputIcon: InputIconStub, + IconField: IconFieldStub + } + } + }) + + const user = userEvent.setup() + await user.click(screen.getByTestId('url-input')) + await user.tab() + + const emittedUrl = onUpdate.mock.calls[0]?.[0] + expect(emittedUrl).toBe('https://example.com/') + }) + }) + + describe('custom validateUrlFn', () => { + it('uses custom validateUrlFn when provided', async () => { + const customValidator = vi.fn().mockResolvedValue(true) + renderUrlInput('https://custom.example', customValidator) + + await waitFor(() => { + expect(customValidator).toHaveBeenCalledWith('https://custom.example') + }) + + expect(checkUrlReachable).not.toHaveBeenCalled() + }) + }) + + describe('state-change emission', () => { + it('emits state-change when validation state changes', async () => { + const onStateChange = vi.fn() + vi.mocked(checkUrlReachable).mockResolvedValue(true) + + render(UrlInput, { + props: { + modelValue: 'https://example.com', + 'onState-change': onStateChange + }, + global: { + plugins: [[PrimeVue, { unstyled: true }]], + stubs: { + InputText: InputTextStub, + InputIcon: InputIconStub, + IconField: IconFieldStub + } + } + }) + + await waitFor(() => { + expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID) + }) + }) + }) +}) diff --git a/apps/desktop-ui/src/components/install/GpuPicker.test.ts b/apps/desktop-ui/src/components/install/GpuPicker.test.ts new file mode 100644 index 0000000000..c14c26fb1a --- /dev/null +++ b/apps/desktop-ui/src/components/install/GpuPicker.test.ts @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/vue' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: vi.fn(() => ({ + getPlatform: vi.fn().mockReturnValue('win32') + })) +})) + +vi.mock('@/i18n', () => ({ + t: (key: string) => key, + te: () => false, + st: (_key: string, fallback: string) => fallback +})) + +import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types' +import GpuPicker from '@/components/install/GpuPicker.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + missingWarn: false, + fallbackWarn: false, + messages: { en: {} } +}) + +const HardwareOptionStub = { + props: ['imagePath', 'placeholderText', 'subtitle', 'selected'], + emits: ['click'], + template: + '' +} + +function renderPicker(device: TorchDeviceType | null = null) { + return render(GpuPicker, { + props: { device }, + global: { + plugins: [[PrimeVue, { unstyled: true }], i18n], + stubs: { + HardwareOption: HardwareOptionStub, + Tag: { + props: ['value'], + template: '{{ value }}' + } + } + } + }) +} + +describe('GpuPicker', () => { + describe('recommended badge', () => { + it('shows recommended badge for nvidia', () => { + renderPicker('nvidia') + expect(screen.getByTestId('recommended-tag')).toBeVisible() + }) + + it('shows recommended badge for amd', () => { + renderPicker('amd') + expect(screen.getByTestId('recommended-tag')).toBeVisible() + }) + + it('does not show recommended badge for cpu', () => { + renderPicker('cpu') + expect(screen.getByTestId('recommended-tag')).not.toBeVisible() + }) + + it('does not show recommended badge for unsupported', () => { + renderPicker('unsupported') + expect(screen.getByTestId('recommended-tag')).not.toBeVisible() + }) + + it('does not show recommended badge when no device is selected', () => { + renderPicker(null) + expect(screen.getByTestId('recommended-tag')).not.toBeVisible() + }) + }) + + describe('selection state', () => { + it('marks nvidia as selected when device is nvidia', () => { + renderPicker('nvidia') + expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true') + }) + + it('marks cpu as selected when device is cpu', () => { + renderPicker('cpu') + expect(screen.getByTestId('CPU').dataset.selected).toBe('true') + }) + + it('marks unsupported as selected when device is unsupported', () => { + renderPicker('unsupported') + expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true') + }) + + it('no option is selected when device is null', () => { + renderPicker(null) + expect(screen.getByTestId('CPU').dataset.selected).toBe('false') + expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false') + }) + }) + + describe('gpu options on non-darwin platform', () => { + it('shows NVIDIA, AMD, CPU, and Manual Install options', () => { + renderPicker(null) + expect(screen.getByTestId('NVIDIA')).toBeDefined() + expect(screen.getByTestId('AMD')).toBeDefined() + expect(screen.getByTestId('CPU')).toBeDefined() + expect(screen.getByTestId('Manual Install')).toBeDefined() + }) + }) +}) diff --git a/apps/desktop-ui/src/components/install/MigrationPicker.test.ts b/apps/desktop-ui/src/components/install/MigrationPicker.test.ts new file mode 100644 index 0000000000..91a617b9c2 --- /dev/null +++ b/apps/desktop-ui/src/components/install/MigrationPicker.test.ts @@ -0,0 +1,223 @@ +import { render, screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createI18n } from 'vue-i18n' + +const mockValidateComfyUISource = vi.fn() +const mockShowDirectoryPicker = vi.fn() + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: vi.fn(() => ({ + validateComfyUISource: mockValidateComfyUISource, + showDirectoryPicker: mockShowDirectoryPicker + })) +})) + +import MigrationPicker from '@/components/install/MigrationPicker.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + install: { + migrationSourcePathDescription: 'Source path description', + migrationOptional: 'Migration is optional', + selectItemsToMigrate: 'Select items to migrate', + pathValidationFailed: 'Validation failed', + failedToSelectDirectory: 'Failed to select directory', + locationPicker: { + migrationPathPlaceholder: 'Enter path' + } + } + } + } +}) + +const InputTextStub = { + props: ['modelValue', 'invalid'], + emits: ['update:modelValue'], + template: `` +} + +const CheckboxStub = { + props: ['modelValue', 'inputId', 'binary'], + emits: ['update:modelValue', 'click'], + template: `` +} + +function renderPicker(sourcePath = '', migrationItemIds: string[] = []) { + return render(MigrationPicker, { + props: { sourcePath, migrationItemIds }, + global: { + plugins: [[PrimeVue, { unstyled: true }], i18n], + stubs: { + InputText: InputTextStub, + Checkbox: CheckboxStub, + Button: { template: ' - diff --git a/src/components/builder/EmptyWorkflowDialogContent.vue b/src/components/builder/EmptyWorkflowDialogContent.vue index e790478b30..fadda9fde3 100644 --- a/src/components/builder/EmptyWorkflowDialogContent.vue +++ b/src/components/builder/EmptyWorkflowDialogContent.vue @@ -1,5 +1,8 @@ diff --git a/src/components/builder/VueNodeSwitchPopup.vue b/src/components/builder/VueNodeSwitchPopup.vue index 0e2ff58c48..ccf177e53b 100644 --- a/src/components/builder/VueNodeSwitchPopup.vue +++ b/src/components/builder/VueNodeSwitchPopup.vue @@ -1,6 +1,7 @@ diff --git a/src/components/chip/SquareChip.stories.ts b/src/components/chip/SquareChip.stories.ts deleted file mode 100644 index 6ae12b1e93..0000000000 --- a/src/components/chip/SquareChip.stories.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import SquareChip from './SquareChip.vue' - -const meta: Meta = { - title: 'Components/SquareChip', - component: SquareChip, - tags: ['autodocs'], - argTypes: { - label: { - control: 'text', - defaultValue: 'Tag' - } - } -} - -export default meta -type Story = StoryObj - -export const TagList: Story = { - render: () => ({ - components: { SquareChip }, - template: ` -
- - - - - - - - -
- ` - }) -} diff --git a/src/components/chip/SquareChip.vue b/src/components/chip/SquareChip.vue deleted file mode 100644 index e9f4979226..0000000000 --- a/src/components/chip/SquareChip.vue +++ /dev/null @@ -1,31 +0,0 @@ - - diff --git a/src/components/chip/Tag.stories.ts b/src/components/chip/Tag.stories.ts new file mode 100644 index 0000000000..0cf9d9e731 --- /dev/null +++ b/src/components/chip/Tag.stories.ts @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Tag from './Tag.vue' + +const meta: Meta = { + title: 'Components/Tag', + component: Tag, + tags: ['autodocs'], + argTypes: { + label: { control: 'text' }, + shape: { + control: 'select', + options: ['square', 'rounded', 'overlay'] + }, + state: { + control: 'select', + options: ['default', 'unselected', 'selected'] + }, + removable: { control: 'boolean' } + }, + args: { + label: 'Tag', + shape: 'square', + state: 'default', + removable: false + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const Rounded: Story = { + args: { + label: 'Tag', + shape: 'rounded' + } +} + +export const Unselected: Story = { + args: { + label: 'Tag', + state: 'unselected' + } +} + +export const Removable: Story = { + args: { + label: 'Tag', + removable: true + } +} + +export const AllStates: Story = { + render: () => ({ + components: { Tag }, + template: ` +
+
+

Square

+
+ + + +
+
+
+

Rounded

+
+ + + +
+
+
+

Overlay (on images)

+
+ + +
+
+
+ ` + }) +} + +export const TagList: Story = { + render: () => ({ + components: { Tag }, + template: ` +
+ + + + + + + + +
+ ` + }) +} diff --git a/src/components/chip/Tag.test.ts b/src/components/chip/Tag.test.ts new file mode 100644 index 0000000000..425c0931da --- /dev/null +++ b/src/components/chip/Tag.test.ts @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import Tag from './Tag.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { remove: 'Remove' } } } +}) + +function renderTag( + props: { + label: string + shape?: 'square' | 'rounded' + removable?: boolean + onRemove?: (...args: unknown[]) => void + }, + options?: { slots?: Record } +) { + return render(Tag, { + props, + global: { plugins: [i18n] }, + ...options + }) +} + +describe('Tag', () => { + it('renders label text', () => { + renderTag({ label: 'JavaScript' }) + expect(screen.getByText('JavaScript')).toBeInTheDocument() + }) + + it('does not show remove button by default', () => { + renderTag({ label: 'Test' }) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('shows remove button when removable', () => { + renderTag({ label: 'Test', removable: true }) + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument() + }) + + it('emits remove event when remove button is clicked', async () => { + const user = userEvent.setup() + const onRemove = vi.fn() + renderTag({ label: 'Test', removable: true, onRemove }) + + await user.click(screen.getByRole('button', { name: 'Remove' })) + expect(onRemove).toHaveBeenCalledOnce() + }) + + it('renders icon slot content', () => { + renderTag( + { label: 'LoRA' }, + { + slots: { + icon: '' + } + } + ) + expect(screen.getByTestId('tag-icon')).toBeInTheDocument() + }) +}) diff --git a/src/components/chip/Tag.vue b/src/components/chip/Tag.vue new file mode 100644 index 0000000000..65c73a087e --- /dev/null +++ b/src/components/chip/Tag.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/chip/tag.variants.ts b/src/components/chip/tag.variants.ts new file mode 100644 index 0000000000..6f1a4b05c4 --- /dev/null +++ b/src/components/chip/tag.variants.ts @@ -0,0 +1,29 @@ +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const tagVariants = cva({ + base: 'inline-flex h-6 shrink-0 items-center justify-center gap-1 text-xs', + variants: { + shape: { + square: 'rounded-sm bg-modal-card-tag-background', + rounded: 'rounded-full bg-secondary-background', + overlay: 'rounded-sm bg-zinc-500/40 text-white/90' + }, + state: { + default: 'text-modal-card-tag-foreground', + unselected: 'text-muted-foreground opacity-70', + selected: 'text-modal-card-tag-foreground' + }, + removable: { + true: 'py-1 pr-1 pl-2', + false: 'px-2 py-1' + } + }, + defaultVariants: { + shape: 'square', + state: 'default', + removable: false + } +}) + +export type TagVariants = VariantProps diff --git a/src/components/common/ApiNodesList.vue b/src/components/common/ApiNodesList.vue new file mode 100644 index 0000000000..b16ea2ceff --- /dev/null +++ b/src/components/common/ApiNodesList.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/common/Badge.test.ts b/src/components/common/Badge.test.ts index 56636400eb..86507cce53 100644 --- a/src/components/common/Badge.test.ts +++ b/src/components/common/Badge.test.ts @@ -1,44 +1,44 @@ -import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/vue' + import Badge from './Badge.vue' import { badgeVariants } from './badge.variants' describe('Badge', () => { it('renders label text', () => { - const wrapper = mount(Badge, { props: { label: 'NEW' } }) - expect(wrapper.text()).toBe('NEW') + render(Badge, { props: { label: 'NEW' } }) + expect(screen.getByText('NEW')).toBeInTheDocument() }) it('renders numeric label', () => { - const wrapper = mount(Badge, { props: { label: 5 } }) - expect(wrapper.text()).toBe('5') + render(Badge, { props: { label: 5 } }) + expect(screen.getByText('5')).toBeInTheDocument() }) it('defaults to dot variant when no label is provided', () => { - const wrapper = mount(Badge) - expect(wrapper.classes()).toContain('size-2') + const { container } = render(Badge) + // eslint-disable-next-line testing-library/no-node-access -- dot badge has no text/role to query + expect(container.firstElementChild).toHaveClass('size-2') }) it('defaults to label variant when label is provided', () => { - const wrapper = mount(Badge, { props: { label: 'NEW' } }) - expect(wrapper.classes()).toContain('font-semibold') - expect(wrapper.classes()).toContain('uppercase') + render(Badge, { props: { label: 'NEW' } }) + const el = screen.getByText('NEW') + expect(el).toHaveClass('font-semibold') + expect(el).toHaveClass('uppercase') }) it('applies circle variant', () => { - const wrapper = mount(Badge, { - props: { label: '3', variant: 'circle' } - }) - expect(wrapper.classes()).toContain('size-3.5') + render(Badge, { props: { label: '3', variant: 'circle' } }) + expect(screen.getByText('3')).toHaveClass('size-3.5') }) it('merges custom class via cn()', () => { - const wrapper = mount(Badge, { - props: { label: 'Test', class: 'ml-2' } - }) - expect(wrapper.classes()).toContain('ml-2') - expect(wrapper.classes()).toContain('rounded-full') + render(Badge, { props: { label: 'Test', class: 'ml-2' } }) + const el = screen.getByText('Test') + expect(el).toHaveClass('ml-2') + expect(el).toHaveClass('rounded-full') }) describe('twMerge preserves color alongside text-3xs font size', () => { @@ -58,12 +58,10 @@ describe('Badge', () => { ) it('cn() does not clobber text-white when merging with text-3xs', () => { - const wrapper = mount(Badge, { - props: { label: 'Test', severity: 'danger' } - }) - const classList = wrapper.classes() - expect(classList).toContain('text-white') - expect(classList).toContain('text-3xs') + render(Badge, { props: { label: 'Test', severity: 'danger' } }) + const el = screen.getByText('Test') + expect(el).toHaveClass('text-white') + expect(el).toHaveClass('text-3xs') }) }) }) diff --git a/src/components/common/LoadingOverlay.vue b/src/components/common/LoadingOverlay.vue index ccc34908e1..9253fee0b8 100644 --- a/src/components/common/LoadingOverlay.vue +++ b/src/components/common/LoadingOverlay.vue @@ -3,6 +3,7 @@
diff --git a/src/components/common/MarqueeLine.test.ts b/src/components/common/MarqueeLine.test.ts index 210f388c1a..a6cf3e2c94 100644 --- a/src/components/common/MarqueeLine.test.ts +++ b/src/components/common/MarqueeLine.test.ts @@ -1,22 +1,22 @@ -import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/vue' + import MarqueeLine from './MarqueeLine.vue' describe(MarqueeLine, () => { it('renders slot content', () => { - const wrapper = mount(MarqueeLine, { + render(MarqueeLine, { slots: { default: 'Hello World' } }) - expect(wrapper.text()).toBe('Hello World') + expect(screen.getByText('Hello World')).toBeInTheDocument() }) it('renders content inside a span within the container', () => { - const wrapper = mount(MarqueeLine, { + render(MarqueeLine, { slots: { default: 'Test Text' } }) - const span = wrapper.find('span') - expect(span.exists()).toBe(true) - expect(span.text()).toBe('Test Text') + const el = screen.getByText('Test Text') + expect(el.tagName).toBe('SPAN') }) }) diff --git a/src/components/common/NotificationPopup.test.ts b/src/components/common/NotificationPopup.test.ts index e494410808..fe152d496d 100644 --- a/src/components/common/NotificationPopup.test.ts +++ b/src/components/common/NotificationPopup.test.ts @@ -1,6 +1,6 @@ -import { mount } from '@vue/test-utils' -import type { ComponentProps } from 'vue-component-type-helpers' -import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import NotificationPopup from './NotificationPopup.vue' @@ -13,13 +13,11 @@ const i18n = createI18n({ } }) -function mountPopup( - props: ComponentProps = { - title: 'Test' - }, +function renderPopup( + props: { title: string; [key: string]: unknown } = { title: 'Test' }, slots: Record = {} ) { - return mount(NotificationPopup, { + return render(NotificationPopup, { global: { plugins: [i18n] }, props, slots @@ -28,51 +26,58 @@ function mountPopup( describe('NotificationPopup', () => { it('renders title', () => { - const wrapper = mountPopup({ title: 'Hello World' }) - expect(wrapper.text()).toContain('Hello World') + renderPopup({ title: 'Hello World' }) + expect(screen.getByRole('status')).toHaveTextContent('Hello World') }) it('has role="status" for accessibility', () => { - const wrapper = mountPopup() - expect(wrapper.find('[role="status"]').exists()).toBe(true) + renderPopup() + expect(screen.getByRole('status')).toBeInTheDocument() }) it('renders subtitle when provided', () => { - const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' }) - expect(wrapper.text()).toContain('v1.2.3') + renderPopup({ title: 'T', subtitle: 'v1.2.3' }) + expect(screen.getByRole('status')).toHaveTextContent('v1.2.3') }) it('renders icon when provided', () => { - const wrapper = mountPopup({ + const { container } = renderPopup({ title: 'T', icon: 'icon-[lucide--rocket]' }) - expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const icon = container.querySelector('i.icon-\\[lucide--rocket\\]') + expect(icon).toBeInTheDocument() }) it('emits close when close button clicked', async () => { - const wrapper = mountPopup({ title: 'T', showClose: true }) - await wrapper.find('[aria-label="Close"]').trigger('click') - expect(wrapper.emitted('close')).toHaveLength(1) + const user = userEvent.setup() + const closeSpy = vi.fn() + renderPopup({ title: 'T', showClose: true, onClose: closeSpy }) + await user.click(screen.getByRole('button', { name: 'Close' })) + expect(closeSpy).toHaveBeenCalledOnce() }) it('renders default slot content', () => { - const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' }) - expect(wrapper.text()).toContain('Body text here') + renderPopup({ title: 'T' }, { default: 'Body text here' }) + expect(screen.getByRole('status')).toHaveTextContent('Body text here') }) it('renders footer slots', () => { - const wrapper = mountPopup( + renderPopup( { title: 'T' }, { 'footer-start': 'Left side', 'footer-end': 'Right side' } ) - expect(wrapper.text()).toContain('Left side') - expect(wrapper.text()).toContain('Right side') + const status = screen.getByRole('status') + expect(status).toHaveTextContent('Left side') + expect(status).toHaveTextContent('Right side') }) it('positions bottom-right when specified', () => { - const wrapper = mountPopup({ title: 'T', position: 'bottom-right' }) - const root = wrapper.find('[role="status"]') - expect(root.attributes('data-position')).toBe('bottom-right') + renderPopup({ title: 'T', position: 'bottom-right' }) + expect(screen.getByRole('status')).toHaveAttribute( + 'data-position', + 'bottom-right' + ) }) }) diff --git a/src/components/common/TextTicker.test.ts b/src/components/common/TextTicker.test.ts index 0d94b84b40..cb7141656c 100644 --- a/src/components/common/TextTicker.test.ts +++ b/src/components/common/TextTicker.test.ts @@ -1,4 +1,5 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import { nextTick } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,7 +14,8 @@ function mockScrollWidth(el: HTMLElement, scrollWidth: number) { describe(TextTicker, () => { let rafCallbacks: ((time: number) => void)[] - let wrapper: ReturnType + let user: ReturnType + let cleanup: (() => void) | undefined beforeEach(() => { vi.useFakeTimers() @@ -23,32 +25,35 @@ describe(TextTicker, () => { return rafCallbacks.length }) vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) }) afterEach(() => { - wrapper?.unmount() + cleanup?.() vi.useRealTimers() vi.restoreAllMocks() }) it('renders slot content', () => { - wrapper = mount(TextTicker, { + const { unmount } = render(TextTicker, { slots: { default: 'Hello World' } }) - expect(wrapper.text()).toBe('Hello World') + cleanup = unmount + expect(screen.getByText('Hello World')).toBeInTheDocument() }) it('scrolls on hover after delay', async () => { - wrapper = mount(TextTicker, { + const { unmount } = render(TextTicker, { slots: { default: 'Very long text that overflows' }, props: { speed: 100 } }) + cleanup = unmount - const el = wrapper.element as HTMLElement + const el = screen.getByText('Very long text that overflows') mockScrollWidth(el, 300) await nextTick() - await wrapper.trigger('mouseenter') + await user.hover(el) await nextTick() expect(rafCallbacks.length).toBe(0) @@ -62,19 +67,21 @@ describe(TextTicker, () => { }) it('cancels delayed scroll on mouse leave before delay elapses', async () => { - wrapper = mount(TextTicker, { + const { unmount } = render(TextTicker, { slots: { default: 'Very long text that overflows' }, props: { speed: 100 } }) + cleanup = unmount - mockScrollWidth(wrapper.element as HTMLElement, 300) + const el = screen.getByText('Very long text that overflows') + mockScrollWidth(el, 300) await nextTick() - await wrapper.trigger('mouseenter') + await user.hover(el) await nextTick() vi.advanceTimersByTime(200) - await wrapper.trigger('mouseleave') + await user.unhover(el) await nextTick() vi.advanceTimersByTime(350) @@ -83,16 +90,17 @@ describe(TextTicker, () => { }) it('resets scroll position on mouse leave', async () => { - wrapper = mount(TextTicker, { + const { unmount } = render(TextTicker, { slots: { default: 'Very long text that overflows' }, props: { speed: 100 } }) + cleanup = unmount - const el = wrapper.element as HTMLElement + const el = screen.getByText('Very long text that overflows') mockScrollWidth(el, 300) await nextTick() - await wrapper.trigger('mouseenter') + await user.hover(el) await nextTick() vi.advanceTimersByTime(350) await nextTick() @@ -100,19 +108,22 @@ describe(TextTicker, () => { rafCallbacks[0](performance.now() + 500) expect(el.scrollLeft).toBeGreaterThan(0) - await wrapper.trigger('mouseleave') + await user.unhover(el) await nextTick() expect(el.scrollLeft).toBe(0) }) it('does not scroll when content fits', async () => { - wrapper = mount(TextTicker, { + const { unmount } = render(TextTicker, { slots: { default: 'Short' } }) + cleanup = unmount + + const el = screen.getByText('Short') await nextTick() - await wrapper.trigger('mouseenter') + await user.hover(el) await nextTick() vi.advanceTimersByTime(350) await nextTick() diff --git a/src/components/common/TextTickerMultiLine.test.ts b/src/components/common/TextTickerMultiLine.test.ts index 9680472965..cfdcc29ff3 100644 --- a/src/components/common/TextTickerMultiLine.test.ts +++ b/src/components/common/TextTickerMultiLine.test.ts @@ -1,8 +1,7 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' import { nextTick } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' -import MarqueeLine from './MarqueeLine.vue' import TextTickerMultiLine from './TextTickerMultiLine.vue' type Callback = () => void @@ -41,23 +40,38 @@ function mockElementSize( } describe(TextTickerMultiLine, () => { - let wrapper: ReturnType + let unmountFn: () => void afterEach(() => { - wrapper?.unmount() + unmountFn?.() resizeCallbacks.length = 0 mutationCallbacks.length = 0 }) - function mountComponent(text: string) { - wrapper = mount(TextTickerMultiLine, { + function renderComponent(text: string) { + const result = render(TextTickerMultiLine, { slots: { default: text } }) - return wrapper + unmountFn = result.unmount + return { + ...result, + container: result.container as HTMLElement + } } - function getMeasureEl(): HTMLElement { - return wrapper.find('[aria-hidden="true"]').element as HTMLElement + function getMeasureEl(container: HTMLElement): HTMLElement { + // eslint-disable-next-line testing-library/no-node-access + return container.querySelector('[aria-hidden="true"]') as HTMLElement + } + + function getVisibleLines(container: HTMLElement): HTMLElement[] { + /* eslint-disable testing-library/no-node-access */ + return Array.from( + container.querySelectorAll( + 'div.overflow-hidden:not([aria-hidden])' + ) + ) + /* eslint-enable testing-library/no-node-access */ } async function triggerSplitLines() { @@ -66,40 +80,42 @@ describe(TextTickerMultiLine, () => { } it('renders slot content', () => { - mountComponent('Load Checkpoint') - expect(wrapper.text()).toContain('Load Checkpoint') + renderComponent('Load Checkpoint') + expect( + screen.getAllByText('Load Checkpoint').length + ).toBeGreaterThanOrEqual(1) }) - it('renders a single MarqueeLine when text fits', async () => { - mountComponent('Short') - mockElementSize(getMeasureEl(), 200, 100) + it('renders a single line when text fits', async () => { + const { container } = renderComponent('Short') + mockElementSize(getMeasureEl(container), 200, 100) await triggerSplitLines() - expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1) + expect(getVisibleLines(container)).toHaveLength(1) }) - it('renders two MarqueeLines when text overflows', async () => { - mountComponent('Load Checkpoint Loader Simple') - mockElementSize(getMeasureEl(), 100, 300) + it('renders two lines when text overflows', async () => { + const { container } = renderComponent('Load Checkpoint Loader Simple') + mockElementSize(getMeasureEl(container), 100, 300) await triggerSplitLines() - expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2) + expect(getVisibleLines(container)).toHaveLength(2) }) it('splits text at word boundary when overflowing', async () => { - mountComponent('Load Checkpoint Loader') - mockElementSize(getMeasureEl(), 100, 200) + const { container } = renderComponent('Load Checkpoint Loader') + mockElementSize(getMeasureEl(container), 100, 200) await triggerSplitLines() - const lines = wrapper.findAllComponents(MarqueeLine) - expect(lines[0].text()).toBe('Load') - expect(lines[1].text()).toBe('Checkpoint Loader') + const lines = getVisibleLines(container) + expect(lines[0].textContent).toBe('Load') + expect(lines[1].textContent).toBe('Checkpoint Loader') }) it('has hidden measurement element with aria-hidden', () => { - mountComponent('Test') - const measureEl = wrapper.find('[aria-hidden="true"]') - expect(measureEl.exists()).toBe(true) - expect(measureEl.classes()).toContain('invisible') + const { container } = renderComponent('Test') + const measureEl = getMeasureEl(container) + expect(measureEl).toBeInTheDocument() + expect(measureEl).toHaveClass('invisible') }) }) diff --git a/src/components/common/TreeExplorerV2.vue b/src/components/common/TreeExplorerV2.vue index 1257a994f3..9d30b85a28 100644 --- a/src/components/common/TreeExplorerV2.vue +++ b/src/components/common/TreeExplorerV2.vue @@ -37,13 +37,13 @@ - + + + {{ $t('g.delete') }} + @@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +import { useSubgraphStore } from '@/stores/subgraphStore' import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes' @@ -98,7 +107,6 @@ const emit = defineEmits<{ node: RenderedTreeExplorerNode, event: MouseEvent ] - addToFavorites: [node: RenderedTreeExplorerNode] }>() const contextMenuNode = ref | null>( @@ -107,6 +115,7 @@ const contextMenuNode = ref | null>( provide(InjectKeyContextMenuNode, contextMenuNode) const nodeBookmarkStore = useNodeBookmarkStore() +const subgraphStore = useSubgraphStore() const isCurrentNodeBookmarked = computed(() => { const node = contextMenuNode.value @@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => { return nodeBookmarkStore.isBookmarked(node.data) }) -function handleAddToFavorites() { - if (contextMenuNode.value) { - emit('addToFavorites', contextMenuNode.value) +const isCurrentNodeUserBlueprint = computed(() => + subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name) +) + +function handleToggleBookmark() { + const node = contextMenuNode.value + if (node?.data) { + nodeBookmarkStore.toggleBookmark(node.data) + } +} + +function handleDeleteBlueprint() { + const name = contextMenuNode.value?.data?.name + if (name) { + void subgraphStore.deleteBlueprint(name) } } diff --git a/src/components/common/TreeExplorerV2Node.test.ts b/src/components/common/TreeExplorerV2Node.test.ts index e8433a4510..1156156383 100644 --- a/src/components/common/TreeExplorerV2Node.test.ts +++ b/src/components/common/TreeExplorerV2Node.test.ts @@ -1,6 +1,7 @@ -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import type { FlattenedItem } from 'reka-ui' -import { ref } from 'vue' +import { nextTick, ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' @@ -13,7 +14,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue' const i18n = createI18n({ legacy: false, locale: 'en', - messages: { en: {} } + messages: { en: { g: { delete: 'Delete' } } } }) vi.mock('@/platform/settings/settingStore', () => ({ @@ -29,6 +30,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({ }) })) +const mockDeleteBlueprint = vi.fn() +const mockIsUserBlueprint = vi.fn().mockReturnValue(false) + +vi.mock('@/stores/subgraphStore', () => ({ + useSubgraphStore: () => ({ + isUserBlueprint: mockIsUserBlueprint, + deleteBlueprint: mockDeleteBlueprint, + typePrefix: 'SubgraphBlueprint.' + }) +})) + vi.mock('@/components/node/NodePreviewCard.vue', () => ({ default: { template: '
' } })) @@ -81,7 +93,7 @@ describe('TreeExplorerV2Node', () => { } } - function mountComponent( + function renderComponent( props: Record = {}, options: { provide?: Record @@ -89,68 +101,76 @@ describe('TreeExplorerV2Node', () => { } = {} ) { const treeItemStub = options.treeItemStub ?? createTreeItemStub() - return { - wrapper: mount(TreeExplorerV2Node, { - global: { - plugins: [i18n], - stubs: { - TreeItem: treeItemStub.stub, - Teleport: { template: '
' } - }, - provide: { - ...options.provide - } + const onNodeClick = vi.fn() + const { container } = render(TreeExplorerV2Node, { + global: { + plugins: [i18n], + stubs: { + TreeItem: treeItemStub.stub, + Teleport: { template: '
' } }, - props: { - item: createMockItem('node'), - ...props + provide: { + ...options.provide } - }), - treeItemStub - } + }, + props: { + item: createMockItem('node'), + onNodeClick, + ...props + } + }) + return { container, treeItemStub, onNodeClick } + } + + function getTreeNode(container: Element) { + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + return container.querySelector('div.group\\/tree-node')! as HTMLElement } describe('handleClick', () => { it('emits nodeClick event when clicked', async () => { - const { wrapper } = mountComponent({ + const user = userEvent.setup() + const { container, onNodeClick } = renderComponent({ item: createMockItem('node') }) - const nodeDiv = wrapper.find('div.group\\/tree-node') - await nodeDiv.trigger('click') + const nodeDiv = getTreeNode(container) + await user.click(nodeDiv) - expect(wrapper.emitted('nodeClick')).toBeTruthy() - expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({ + expect(onNodeClick).toHaveBeenCalled() + expect(onNodeClick.mock.calls[0][0]).toMatchObject({ type: 'node', label: 'Test Label' }) }) it('calls handleToggle for folder items', async () => { + const user = userEvent.setup() const treeItemStub = createTreeItemStub() - const { wrapper } = mountComponent( + const { container, onNodeClick } = renderComponent( { item: createMockItem('folder') }, { treeItemStub } ) - const folderDiv = wrapper.find('div.group\\/tree-node') - await folderDiv.trigger('click') + const folderDiv = getTreeNode(container) + await user.click(folderDiv) - expect(wrapper.emitted('nodeClick')).toBeTruthy() + expect(onNodeClick).toHaveBeenCalled() expect(treeItemStub.handleToggle).toHaveBeenCalled() }) it('does not call handleToggle for node items', async () => { + const user = userEvent.setup() const treeItemStub = createTreeItemStub() - const { wrapper } = mountComponent( + const { container, onNodeClick } = renderComponent( { item: createMockItem('node') }, { treeItemStub } ) - const nodeDiv = wrapper.find('div.group\\/tree-node') - await nodeDiv.trigger('click') + const nodeDiv = getTreeNode(container) + await user.click(nodeDiv) - expect(wrapper.emitted('nodeClick')).toBeTruthy() + expect(onNodeClick).toHaveBeenCalled() expect(treeItemStub.handleToggle).not.toHaveBeenCalled() }) }) @@ -160,7 +180,7 @@ describe('TreeExplorerV2Node', () => { const contextMenuNode = ref(null) const nodeItem = createMockItem('node') - const { wrapper } = mountComponent( + const { container } = renderComponent( { item: nodeItem }, { provide: { @@ -169,16 +189,20 @@ describe('TreeExplorerV2Node', () => { } ) - const nodeDiv = wrapper.find('div.group\\/tree-node') - await nodeDiv.trigger('contextmenu') + const nodeDiv = getTreeNode(container) + await fireEvent.contextMenu(nodeDiv) expect(contextMenuNode.value).toEqual(nodeItem.value) }) - it('does not set contextMenuNode for folder items', async () => { - const contextMenuNode = ref(null) + it('clears contextMenuNode when right-clicking a folder', async () => { + const contextMenuNode = ref({ + key: 'stale', + type: 'node', + label: 'Stale' + } as RenderedTreeExplorerNode) - const { wrapper } = mountComponent( + const { container } = renderComponent( { item: createMockItem('folder') }, { provide: { @@ -187,49 +211,115 @@ describe('TreeExplorerV2Node', () => { } ) - const folderDiv = wrapper.find('div.group\\/tree-node') - await folderDiv.trigger('contextmenu') + const folderDiv = getTreeNode(container) + await fireEvent.contextMenu(folderDiv) expect(contextMenuNode.value).toBeNull() }) }) + describe('blueprint actions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows delete button for user blueprints', () => { + mockIsUserBlueprint.mockReturnValue(true) + renderComponent({ + item: createMockItem('node', { + data: { name: 'SubgraphBlueprint.test' } + }) + }) + + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument() + }) + + it('hides delete button for non-blueprint nodes', () => { + mockIsUserBlueprint.mockReturnValue(false) + renderComponent({ + item: createMockItem('node', { + data: { name: 'KSampler' } + }) + }) + + expect( + screen.queryByRole('button', { name: 'Delete' }) + ).not.toBeInTheDocument() + }) + + it('always shows bookmark button', () => { + mockIsUserBlueprint.mockReturnValue(true) + renderComponent({ + item: createMockItem('node', { + data: { name: 'SubgraphBlueprint.test' } + }) + }) + + expect( + screen.getByRole('button', { name: 'icon.bookmark' }) + ).toBeInTheDocument() + }) + + it('calls deleteBlueprint when delete button is clicked', async () => { + const user = userEvent.setup() + mockIsUserBlueprint.mockReturnValue(true) + const nodeName = 'SubgraphBlueprint.test' + renderComponent({ + item: createMockItem('node', { + data: { name: nodeName } + }) + }) + + const deleteButton = screen.getByRole('button', { name: 'Delete' }) + await user.click(deleteButton) + + expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName) + }) + }) + describe('rendering', () => { it('renders node icon for node type', () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('node') }) - expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector('i.icon-\\[comfy--node\\]')).toBeTruthy() }) it('renders folder icon for folder type', () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('folder', { icon: 'icon-[lucide--folder]' }) }) - expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true) + /* eslint-disable testing-library/no-container, testing-library/no-node-access */ + expect( + container.querySelector('i.icon-\\[lucide--folder\\]') + ).toBeTruthy() + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ }) it('renders label text', () => { - const { wrapper } = mountComponent({ + renderComponent({ item: createMockItem('node', { label: 'My Node' }) }) - expect(wrapper.text()).toContain('My Node') + expect(screen.getByText('My Node')).toBeInTheDocument() }) it('renders chevron for folder with children', () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: { ...createMockItem('folder'), hasChildren: true } }) - expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe( - true - ) + /* eslint-disable testing-library/no-container, testing-library/no-node-access */ + expect( + container.querySelector('i.icon-\\[lucide--chevron-down\\]') + ).toBeTruthy() + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ }) }) @@ -239,75 +329,75 @@ describe('TreeExplorerV2Node', () => { }) it('sets draggable attribute on node items', () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('node') }) - const nodeDiv = wrapper.find('div.group\\/tree-node') - expect(nodeDiv.attributes('draggable')).toBe('true') + const nodeDiv = getTreeNode(container) + expect(nodeDiv.getAttribute('draggable')).toBe('true') }) it('does not set draggable on folder items', () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('folder') }) - const folderDiv = wrapper.find('div.group\\/tree-node') - expect(folderDiv.attributes('draggable')).toBeUndefined() + const folderDiv = getTreeNode(container) + expect(folderDiv.getAttribute('draggable')).toBeNull() }) it('calls startDrag with native mode on dragstart', async () => { const mockData = { name: 'TestNode' } - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('node', { data: mockData }) }) - const nodeDiv = wrapper.find('div.group\\/tree-node') - await nodeDiv.trigger('dragstart') + const nodeDiv = getTreeNode(container) + await fireEvent.dragStart(nodeDiv) expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native') }) it('does not call startDrag for folder items on dragstart', async () => { - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('folder') }) - const folderDiv = wrapper.find('div.group\\/tree-node') - await folderDiv.trigger('dragstart') + const folderDiv = getTreeNode(container) + await fireEvent.dragStart(folderDiv) expect(mockStartDrag).not.toHaveBeenCalled() }) it('calls handleNativeDrop on dragend with drop coordinates', async () => { const mockData = { name: 'TestNode' } - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('node', { data: mockData }) }) - const nodeDiv = wrapper.find('div.group\\/tree-node') + const nodeDiv = getTreeNode(container) - await nodeDiv.trigger('dragstart') + await fireEvent.dragStart(nodeDiv) const dragEndEvent = new DragEvent('dragend', { bubbles: true }) Object.defineProperty(dragEndEvent, 'clientX', { value: 100 }) Object.defineProperty(dragEndEvent, 'clientY', { value: 200 }) - await nodeDiv.element.dispatchEvent(dragEndEvent) - await wrapper.vm.$nextTick() + nodeDiv.dispatchEvent(dragEndEvent) + await nextTick() expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200) }) it('calls handleNativeDrop regardless of dropEffect', async () => { const mockData = { name: 'TestNode' } - const { wrapper } = mountComponent({ + const { container } = renderComponent({ item: createMockItem('node', { data: mockData }) }) - const nodeDiv = wrapper.find('div.group\\/tree-node') + const nodeDiv = getTreeNode(container) - await nodeDiv.trigger('dragstart') + await fireEvent.dragStart(nodeDiv) mockHandleNativeDrop.mockClear() const dragEndEvent = new DragEvent('dragend', { bubbles: true }) @@ -317,8 +407,8 @@ describe('TreeExplorerV2Node', () => { value: { dropEffect: 'none' } }) - await nodeDiv.element.dispatchEvent(dragEndEvent) - await wrapper.vm.$nextTick() + nodeDiv.dispatchEvent(dragEndEvent) + await nextTick() expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400) }) diff --git a/src/components/common/TreeExplorerV2Node.vue b/src/components/common/TreeExplorerV2Node.vue index 1158d65ffe..ba2e776857 100644 --- a/src/components/common/TreeExplorerV2Node.vue +++ b/src/components/common/TreeExplorerV2Node.vue @@ -25,25 +25,37 @@ {{ item.value.label }} - +
+ + + +
@@ -53,6 +65,7 @@ :class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')" :style="rowStyle" @click.stop="handleClick($event, handleToggle, handleSelect)" + @contextmenu="clearContextMenuNode" > item.value.data) @@ -128,12 +144,29 @@ const isBookmarked = computed(() => { return nodeBookmarkStore.isBookmarked(nodeDef.value) }) +const isUserBlueprint = computed(() => + subgraphStore.isUserBlueprint(nodeDef.value?.name) +) + function toggleBookmark() { if (nodeDef.value) { nodeBookmarkStore.toggleBookmark(nodeDef.value) } } +function deleteBlueprint() { + if (nodeDef.value) { + void subgraphStore.deleteBlueprint(nodeDef.value.name) + } +} +const editBlueprint = async () => { + if (!nodeDef.value) + throw new Error( + 'Failed to edit subgraph blueprint lacking backing node data' + ) + await useSubgraphStore().editBlueprint(nodeDef.value.name) +} + const { previewRef, showPreview, @@ -166,6 +199,12 @@ function handleContextMenu() { } } +function clearContextMenuNode() { + if (contextMenuNode) { + contextMenuNode.value = null + } +} + function handleMouseEnter(e: MouseEvent) { if (item.value.type !== 'node') return baseHandleMouseEnter(e) diff --git a/src/components/common/VirtualGrid.test.ts b/src/components/common/VirtualGrid.test.ts index 2834e92199..36d355141b 100644 --- a/src/components/common/VirtualGrid.test.ts +++ b/src/components/common/VirtualGrid.test.ts @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Ref } from 'vue' import { nextTick, ref } from 'vue' @@ -46,7 +46,7 @@ describe('VirtualGrid', () => { mockedHeight.value = 200 mockedScrollY.value = 0 - const wrapper = mount(VirtualGrid, { + render(VirtualGrid, { props: { items, gridStyle: defaultGridStyle, @@ -60,16 +60,14 @@ describe('VirtualGrid', () => {
{{ item.name }}
` }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() - const renderedItems = wrapper.findAll('.test-item') + const renderedItems = screen.getAllByText(/^Item \d+$/) expect(renderedItems.length).toBeGreaterThan(0) expect(renderedItems.length).toBeLessThan(items.length) - - wrapper.unmount() }) it('provides correct index in slot props', async () => { @@ -79,7 +77,7 @@ describe('VirtualGrid', () => { mockedHeight.value = 200 mockedScrollY.value = 0 - const wrapper = mount(VirtualGrid, { + render(VirtualGrid, { props: { items, gridStyle: defaultGridStyle, @@ -94,7 +92,7 @@ describe('VirtualGrid', () => { return null } }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() @@ -104,8 +102,6 @@ describe('VirtualGrid', () => { for (let i = 1; i < receivedIndices.length; i++) { expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1) } - - wrapper.unmount() }) it('respects maxColumns prop', async () => { @@ -114,28 +110,29 @@ describe('VirtualGrid', () => { mockedHeight.value = 200 mockedScrollY.value = 0 - const wrapper = mount(VirtualGrid, { + const { container } = render(VirtualGrid, { props: { items, gridStyle: defaultGridStyle, maxColumns: 2 }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() - const gridElement = wrapper.find('[style*="display: grid"]') - expect(gridElement.exists()).toBe(true) - - const gridEl = gridElement.element as HTMLElement - expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') - - wrapper.unmount() + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const gridElement = container.querySelector( + '[style*="display: grid"]' + ) as HTMLElement + expect(gridElement).not.toBeNull() + expect(gridElement.style.gridTemplateColumns).toBe( + 'repeat(2, minmax(0, 1fr))' + ) }) it('renders empty when no items provided', async () => { - const wrapper = mount(VirtualGrid, { + render(VirtualGrid, { props: { items: [], gridStyle: defaultGridStyle @@ -149,10 +146,8 @@ describe('VirtualGrid', () => { await nextTick() - const renderedItems = wrapper.findAll('.test-item') + const renderedItems = screen.queryAllByText(/^Item \d+$/) expect(renderedItems.length).toBe(0) - - wrapper.unmount() }) it('emits approach-end for single-column list when scrolled near bottom', async () => { @@ -161,7 +156,9 @@ describe('VirtualGrid', () => { mockedHeight.value = 600 mockedScrollY.value = 0 - const wrapper = mount(VirtualGrid, { + const onApproachEnd = vi.fn() + + render(VirtualGrid, { props: { items, gridStyle: { @@ -171,19 +168,20 @@ describe('VirtualGrid', () => { defaultItemHeight: 48, defaultItemWidth: 200, maxColumns: 1, - bufferRows: 1 + bufferRows: 1, + onApproachEnd }, slots: { item: `` }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() - expect(wrapper.emitted('approach-end')).toBeUndefined() + expect(onApproachEnd).not.toHaveBeenCalled() // Scroll near the end: 50 items * 48px = 2400px total // viewRows = ceil(600/48) = 13, buffer = 1 @@ -195,9 +193,7 @@ describe('VirtualGrid', () => { mockedScrollY.value = 1680 await nextTick() - expect(wrapper.emitted('approach-end')).toBeDefined() - - wrapper.unmount() + expect(onApproachEnd).toHaveBeenCalled() }) it('does not emit approach-end without maxColumns in single-column layout', async () => { @@ -208,7 +204,9 @@ describe('VirtualGrid', () => { mockedHeight.value = 600 mockedScrollY.value = 0 - const wrapper = mount(VirtualGrid, { + const onApproachEnd = vi.fn() + + render(VirtualGrid, { props: { items, gridStyle: { @@ -218,14 +216,15 @@ describe('VirtualGrid', () => { defaultItemHeight: 48, defaultItemWidth: 200, // No maxColumns — cols will be floor(400/200) = 2 - bufferRows: 1 + bufferRows: 1, + onApproachEnd }, slots: { item: `` }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() @@ -237,9 +236,7 @@ describe('VirtualGrid', () => { // With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50) // remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false // The approach-end never fires at the correct scroll position - expect(wrapper.emitted('approach-end')).toBeUndefined() - - wrapper.unmount() + expect(onApproachEnd).not.toHaveBeenCalled() }) it('forces cols to maxColumns when maxColumns is finite', async () => { @@ -248,7 +245,7 @@ describe('VirtualGrid', () => { mockedScrollY.value = 0 const items = createItems(20) - const wrapper = mount(VirtualGrid, { + render(VirtualGrid, { props: { items, gridStyle: defaultGridStyle, @@ -262,15 +259,13 @@ describe('VirtualGrid', () => {
{{ item.name }}
` }, - attachTo: document.body + container: document.body.appendChild(document.createElement('div')) }) await nextTick() - const renderedItems = wrapper.findAll('.test-item') + const renderedItems = screen.getAllByText(/^Item \d+$/) expect(renderedItems.length).toBeGreaterThan(0) expect(renderedItems.length % 4).toBe(0) - - wrapper.unmount() }) }) diff --git a/src/components/common/WorkflowActionsList.test.ts b/src/components/common/WorkflowActionsList.test.ts index e5483c65a0..620a7e85ee 100644 --- a/src/components/common/WorkflowActionsList.test.ts +++ b/src/components/common/WorkflowActionsList.test.ts @@ -1,4 +1,5 @@ -import { shallowMount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue' @@ -7,10 +8,23 @@ import type { WorkflowMenuItem } from '@/types/workflowMenuItem' -function createWrapper(items: WorkflowMenuItem[]) { - return shallowMount(WorkflowActionsList, { - props: { items }, - global: { renderStubDefaultSlot: true } +const MenuItemStub = { + template: + '
', + emits: ['select'] +} + +const SeparatorStub = { + template: '
' +} + +function renderList(items: WorkflowMenuItem[]) { + return render(WorkflowActionsList, { + props: { + items, + itemComponent: MenuItemStub, + separatorComponent: SeparatorStub + } }) } @@ -20,10 +34,9 @@ describe('WorkflowActionsList', () => { { id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() } ] - const wrapper = createWrapper(items) + renderList(items) - expect(wrapper.text()).toContain('Save') - expect(wrapper.find('.pi-save').exists()).toBe(true) + expect(screen.getByText('Save')).toBeInTheDocument() }) it('renders separator items', () => { @@ -33,24 +46,23 @@ describe('WorkflowActionsList', () => { { id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() } ] - const wrapper = createWrapper(items) - const html = wrapper.html() + renderList(items) - expect(html).toContain('dropdown-menu-separator-stub') - expect(wrapper.text()).toContain('Before') - expect(wrapper.text()).toContain('After') + screen.getByTestId('menu-separator') + screen.getByText('Before') + screen.getByText('After') }) it('dispatches command on select', async () => { + const user = userEvent.setup() const command = vi.fn() const items: WorkflowMenuItem[] = [ { id: 'action', label: 'Action', icon: 'pi pi-play', command } ] - const wrapper = createWrapper(items) - const item = wrapper.findComponent({ name: 'DropdownMenuItem' }) - await item.vm.$emit('select') + renderList(items) + await user.click(screen.getByTestId('menu-item')) expect(command).toHaveBeenCalledOnce() }) @@ -65,9 +77,9 @@ describe('WorkflowActionsList', () => { } ] - const wrapper = createWrapper(items) + renderList(items) - expect(wrapper.text()).toContain('NEW') + screen.getByText('NEW') }) it('does not render items with visible set to false', () => { @@ -82,10 +94,10 @@ describe('WorkflowActionsList', () => { { id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() } ] - const wrapper = createWrapper(items) + renderList(items) - expect(wrapper.text()).not.toContain('Hidden Item') - expect(wrapper.text()).toContain('Shown Item') + expect(screen.queryByText('Hidden Item')).toBeNull() + screen.getByText('Shown Item') }) it('does not render badge when absent', () => { @@ -93,8 +105,8 @@ describe('WorkflowActionsList', () => { { id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() } ] - const wrapper = createWrapper(items) + renderList(items) - expect(wrapper.text()).not.toContain('NEW') + expect(screen.queryByText('NEW')).toBeNull() }) }) diff --git a/src/components/curve/CurveEditor.test.ts b/src/components/curve/CurveEditor.test.ts index e04eae8329..a97987e0da 100644 --- a/src/components/curve/CurveEditor.test.ts +++ b/src/components/curve/CurveEditor.test.ts @@ -1,86 +1,91 @@ -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' import { describe, expect, it } from 'vitest' import type { CurvePoint } from './types' import CurveEditor from './CurveEditor.vue' -function mountEditor(points: CurvePoint[], extraProps = {}) { - return mount(CurveEditor, { +function renderEditor(points: CurvePoint[], extraProps = {}) { + const { container } = render(CurveEditor, { props: { modelValue: points, ...extraProps } }) + return { container } } -function getCurvePath(wrapper: ReturnType) { - return wrapper.find('[data-testid="curve-path"]') +function getCurvePath() { + return screen.getByTestId('curve-path') } describe('CurveEditor', () => { it('renders SVG with curve path', () => { - const wrapper = mountEditor([ + const { container } = renderEditor([ [0, 0], [1, 1] ]) - expect(wrapper.find('svg').exists()).toBe(true) - const curvePath = getCurvePath(wrapper) - expect(curvePath.exists()).toBe(true) - expect(curvePath.attributes('d')).toBeTruthy() + /* eslint-disable testing-library/no-container, testing-library/no-node-access */ + expect(container.querySelector('svg')).toBeInTheDocument() + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ + const curvePath = getCurvePath() + expect(curvePath).toBeInTheDocument() + expect(curvePath.getAttribute('d')).toBeTruthy() }) it('renders a circle for each control point', () => { - const wrapper = mountEditor([ + const { container } = renderEditor([ [0, 0], [0.5, 0.7], [1, 1] ]) - expect(wrapper.findAll('circle')).toHaveLength(3) + /* eslint-disable testing-library/no-container, testing-library/no-node-access */ + expect(container.querySelectorAll('circle')).toHaveLength(3) + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ }) it('renders histogram path when provided', () => { const histogram = new Uint32Array(256) for (let i = 0; i < 256; i++) histogram[i] = i + 1 - const wrapper = mountEditor( + renderEditor( [ [0, 0], [1, 1] ], { histogram } ) - const histogramPath = wrapper.find('[data-testid="histogram-path"]') - expect(histogramPath.exists()).toBe(true) - expect(histogramPath.attributes('d')).toContain('M0,1') + const histogramPath = screen.getByTestId('histogram-path') + expect(histogramPath).toBeInTheDocument() + expect(histogramPath.getAttribute('d')).toContain('M0,1') }) it('does not render histogram path when not provided', () => { - const wrapper = mountEditor([ + renderEditor([ [0, 0], [1, 1] ]) - expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false) + expect(screen.queryByTestId('histogram-path')).not.toBeInTheDocument() }) it('returns empty path with fewer than 2 points', () => { - const wrapper = mountEditor([[0.5, 0.5]]) - expect(getCurvePath(wrapper).attributes('d')).toBe('') + renderEditor([[0.5, 0.5]]) + expect(getCurvePath().getAttribute('d')).toBe('') }) it('generates path starting with M and containing L segments', () => { - const wrapper = mountEditor([ + renderEditor([ [0, 0], [0.5, 0.8], [1, 1] ]) - const d = getCurvePath(wrapper).attributes('d')! + const d = getCurvePath().getAttribute('d')! expect(d).toMatch(/^M/) expect(d).toContain('L') }) it('curve path only spans the x-range of control points', () => { - const wrapper = mountEditor([ + renderEditor([ [0.2, 0.3], [0.8, 0.9] ]) - const d = getCurvePath(wrapper).attributes('d')! + const d = getCurvePath().getAttribute('d')! const xValues = d .split(/[ML]/) .filter(Boolean) @@ -95,19 +100,22 @@ describe('CurveEditor', () => { [0.5, 0.5], [1, 1] ] - const wrapper = mountEditor(points) - expect(wrapper.findAll('circle')).toHaveLength(3) + const { container } = renderEditor(points) - await wrapper.findAll('circle')[1].trigger('pointerdown', { + /* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */ + expect(container.querySelectorAll('circle')).toHaveLength(3) + + await fireEvent.pointerDown(container.querySelectorAll('circle')[1], { button: 2, pointerId: 1 }) - expect(wrapper.findAll('circle')).toHaveLength(2) + expect(container.querySelectorAll('circle')).toHaveLength(2) - await wrapper.findAll('circle')[0].trigger('pointerdown', { + await fireEvent.pointerDown(container.querySelectorAll('circle')[0], { button: 2, pointerId: 1 }) - expect(wrapper.findAll('circle')).toHaveLength(2) + expect(container.querySelectorAll('circle')).toHaveLength(2) + /* eslint-enable testing-library/no-container, testing-library/no-node-access */ }) }) diff --git a/src/components/curve/CurveEditor.vue b/src/components/curve/CurveEditor.vue index ea8d216c91..b623da9a3e 100644 --- a/src/components/curve/CurveEditor.vue +++ b/src/components/curve/CurveEditor.vue @@ -89,7 +89,7 @@ import { cn } from '@/utils/tailwindUtil' import type { CurveInterpolation, CurvePoint } from './types' -import { histogramToPath } from './curveUtils' +import { histogramToPath } from '@/utils/histogramUtil' const { curveColor = 'white', diff --git a/src/components/curve/curveUtils.test.ts b/src/components/curve/curveUtils.test.ts index e959651f81..a20852d1f1 100644 --- a/src/components/curve/curveUtils.test.ts +++ b/src/components/curve/curveUtils.test.ts @@ -5,8 +5,7 @@ import type { CurvePoint } from './types' import { createLinearInterpolator, createMonotoneInterpolator, - curvesToLUT, - histogramToPath + curvesToLUT } from './curveUtils' describe('createMonotoneInterpolator', () => { @@ -164,37 +163,3 @@ describe('curvesToLUT', () => { } }) }) - -describe('histogramToPath', () => { - it('returns empty string for empty histogram', () => { - expect(histogramToPath(new Uint32Array(0))).toBe('') - }) - - it('returns empty string when all bins are zero', () => { - expect(histogramToPath(new Uint32Array(256))).toBe('') - }) - - it('returns a closed SVG path for valid histogram', () => { - const histogram = new Uint32Array(256) - for (let i = 0; i < 256; i++) histogram[i] = i + 1 - const path = histogramToPath(histogram) - expect(path).toMatch(/^M0,1/) - expect(path).toMatch(/L1,1 Z$/) - }) - - it('normalizes using 99.5th percentile to suppress outliers', () => { - const histogram = new Uint32Array(256) - for (let i = 0; i < 256; i++) histogram[i] = 100 - histogram[255] = 100000 - const path = histogramToPath(histogram) - // Most bins should map to y=0 (1 - 100/100 = 0) since - // the 99.5th percentile is 100, not the outlier 100000 - const yValues = path - .split(/[ML]/) - .filter(Boolean) - .map((s) => parseFloat(s.split(',')[1])) - .filter((y) => !isNaN(y)) - const nearZero = yValues.filter((y) => Math.abs(y) < 0.01) - expect(nearZero.length).toBeGreaterThan(200) - }) -}) diff --git a/src/components/curve/curveUtils.ts b/src/components/curve/curveUtils.ts index 4254878dd2..6bbd507311 100644 --- a/src/components/curve/curveUtils.ts +++ b/src/components/curve/curveUtils.ts @@ -149,34 +149,6 @@ export function createMonotoneInterpolator( } } -/** - * Convert a histogram (arbitrary number of bins) into an SVG path string. - * Applies square-root scaling and normalizes using the 99.5th percentile - * to avoid outlier spikes. - */ -export function histogramToPath(histogram: Uint32Array): string { - const len = histogram.length - if (len === 0) return '' - - const sqrtValues = new Float32Array(len) - for (let i = 0; i < len; i++) sqrtValues[i] = Math.sqrt(histogram[i]) - - const sorted = Array.from(sqrtValues).sort((a, b) => a - b) - const max = sorted[Math.floor((len - 1) * 0.995)] - if (max === 0) return '' - - const invMax = 1 / max - const lastIdx = len - 1 - const parts: string[] = ['M0,1'] - for (let i = 0; i < len; i++) { - const x = lastIdx === 0 ? 0.5 : i / lastIdx - const y = 1 - Math.min(1, sqrtValues[i] * invMax) - parts.push(`L${x},${y}`) - } - parts.push('L1,1 Z') - return parts.join(' ') -} - export function curvesToLUT( points: CurvePoint[], interpolation: CurveInterpolation = 'monotone_cubic' @@ -192,3 +164,15 @@ export function curvesToLUT( return lut } + +export function curveDataToFloatLUT( + curve: CurveData, + size: number = 256 +): Float32Array { + const lut = new Float32Array(size) + const interpolate = createInterpolator(curve.points, curve.interpolation) + for (let i = 0; i < size; i++) { + lut[i] = interpolate(i / (size - 1)) + } + return lut +} diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 6495d4ece4..3c743d483f 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -265,10 +265,11 @@ @@ -402,7 +403,7 @@ import { useI18n } from 'vue-i18n' import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' -import SquareChip from '@/components/chip/SquareChip.vue' +import Tag from '@/components/chip/Tag.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' import MultiSelect from '@/components/input/MultiSelect.vue' import SingleSelect from '@/components/input/SingleSelect.vue' diff --git a/src/components/dialog/content/ApiNodesSignInContent.vue b/src/components/dialog/content/ApiNodesSignInContent.vue index 41ad903c7b..20ccb38fb6 100644 --- a/src/components/dialog/content/ApiNodesSignInContent.vue +++ b/src/components/dialog/content/ApiNodesSignInContent.vue @@ -1,5 +1,8 @@ @@ -129,7 +129,7 @@ import MoreButton from '@/components/button/MoreButton.vue' import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' -import SquareChip from '@/components/chip/SquareChip.vue' +import Tag from '@/components/chip/Tag.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' import MultiSelect from '@/components/input/MultiSelect.vue' import SingleSelect from '@/components/input/SingleSelect.vue' diff --git a/src/components/widget/layout/BaseModalLayout.stories.ts b/src/components/widget/layout/BaseModalLayout.stories.ts index a8f4dc57f4..fb70e5a133 100644 --- a/src/components/widget/layout/BaseModalLayout.stories.ts +++ b/src/components/widget/layout/BaseModalLayout.stories.ts @@ -6,7 +6,7 @@ import MoreButton from '@/components/button/MoreButton.vue' import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' -import SquareChip from '@/components/chip/SquareChip.vue' +import Tag from '@/components/chip/Tag.vue' import MultiSelect from '@/components/input/MultiSelect.vue' import SearchInput from '@/components/ui/search-input/SearchInput.vue' import SingleSelect from '@/components/input/SingleSelect.vue' @@ -76,7 +76,7 @@ const createStoryTemplate = (args: StoryArgs) => ({ CardContainer, CardTop, CardBottom, - SquareChip + Tag }, setup() { const t = (k: string) => k @@ -276,13 +276,13 @@ const createStoryTemplate = (args: StoryArgs) => ({ @@ -392,13 +392,13 @@ const createStoryTemplate = (args: StoryArgs) => ({ diff --git a/src/composables/element/useDomClipping.test.ts b/src/composables/element/useDomClipping.test.ts index 0900d0d24b..c7d7cedf69 100644 --- a/src/composables/element/useDomClipping.test.ts +++ b/src/composables/element/useDomClipping.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { useDomClipping } from './useDomClipping' @@ -8,7 +9,7 @@ function createMockElement(rect: { width: number height: number }): HTMLElement { - return { + return fromPartial({ getBoundingClientRect: vi.fn( () => ({ @@ -20,7 +21,7 @@ function createMockElement(rect: { toJSON: () => ({}) }) as DOMRect ) - } as unknown as HTMLElement + }) } function createMockCanvas(rect: { @@ -29,7 +30,7 @@ function createMockCanvas(rect: { width: number height: number }): HTMLCanvasElement { - return { + return fromPartial({ getBoundingClientRect: vi.fn( () => ({ @@ -41,7 +42,7 @@ function createMockCanvas(rect: { toJSON: () => ({}) }) as DOMRect ) - } as unknown as HTMLCanvasElement + }) } describe('useDomClipping', () => { diff --git a/src/composables/graph/useCanvasRefresh.ts b/src/composables/graph/useCanvasRefresh.ts index ddb3b4670c..2bc2ff942d 100644 --- a/src/composables/graph/useCanvasRefresh.ts +++ b/src/composables/graph/useCanvasRefresh.ts @@ -13,7 +13,7 @@ export function useCanvasRefresh() { canvasStore.canvas?.setDirty(true, true) canvasStore.canvas?.graph?.afterChange() canvasStore.canvas?.emitAfterChange() - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } return { diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts index ea82714a84..1c2329274d 100644 --- a/src/composables/graph/useErrorClearingHooks.test.ts +++ b/src/composables/graph/useErrorClearingHooks.test.ts @@ -1,5 +1,6 @@ -import { setActivePinia } from 'pinia' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks' @@ -8,7 +9,15 @@ import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' -import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' +import { + LGraphEventMode, + NodeSlotType +} from '@/lib/litegraph/src/types/globalEnums' +import * as missingMediaScan from '@/platform/missingMedia/missingMediaScan' +import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' +import * as missingModelScan from '@/platform/missingModel/missingModelScan' +import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' +import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore' import { app } from '@/scripts/app' import { useExecutionErrorStore } from '@/stores/executionErrorStore' @@ -194,7 +203,7 @@ describe('Widget change error clearing via onWidgetChanged', () => { const store = useExecutionErrorStore() vi.spyOn(app, 'rootGraph', 'get').mockReturnValue( - undefined as unknown as LGraph + fromAny(undefined) ) store.lastNodeErrors = { [String(node.id)]: { @@ -356,6 +365,474 @@ describe('installErrorClearingHooks lifecycle', () => { }) }) +describe('onNodeRemoved clears missing asset errors by execution ID', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false) + }) + + it('removes root-level node missing model error using its local id', () => { + const graph = new LGraph() + const node = new LGraphNode('CheckpointLoaderSimple') + graph.add(node) + + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + installErrorClearingHooks(graph) + + const modelStore = useMissingModelStore() + modelStore.setMissingModels([ + fromAny< + Parameters[0][number], + unknown + >({ + nodeId: String(node.id), + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: false, + name: 'model.safetensors', + isMissing: true + }) + ]) + + graph.remove(node) + + expect(modelStore.missingModelCandidates).toBeNull() + }) + + it('removes subgraph interior node missing model error using parentId:nodeId', () => { + // Regression: node.graph is nulled before onNodeRemoved fires, so + // getExecutionIdByNode returned null and removal fell back to the + // local node id. Errors stored under "parentId:nodeId" were never + // removed for subgraph interior nodes. + const subgraph = createTestSubgraph() + const interiorNode = new LGraphNode('CheckpointLoaderSimple') + subgraph.add(interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 }) + const rootGraph = subgraphNode.graph as LGraph + rootGraph.add(subgraphNode) + + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph) + // Hooks are installed on whichever graph is currently active in + // the canvas; when the user is inside the subgraph, that is the + // graph whose onNodeRemoved fires for interior deletions. + installErrorClearingHooks(subgraph) + + const interiorExecId = `${subgraphNode.id}:${interiorNode.id}` + const modelStore = useMissingModelStore() + modelStore.setMissingModels([ + fromAny< + Parameters[0][number], + unknown + >({ + nodeId: interiorExecId, + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: false, + name: 'model.safetensors', + isMissing: true + }) + ]) + + subgraph.remove(interiorNode) + + expect(modelStore.missingModelCandidates).toBeNull() + }) + + it('removes subgraph interior node missing media and missing node errors', () => { + const subgraph = createTestSubgraph() + const interiorNode = new LGraphNode('LoadImage') + subgraph.add(interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 }) + const rootGraph = subgraphNode.graph as LGraph + rootGraph.add(subgraphNode) + + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph) + installErrorClearingHooks(subgraph) + + const interiorExecId = `${subgraphNode.id}:${interiorNode.id}` + + const mediaStore = useMissingMediaStore() + mediaStore.setMissingMedia([ + fromAny< + Parameters[0][number], + unknown + >({ + nodeId: interiorExecId, + nodeType: 'LoadImage', + widgetName: 'image', + mediaType: 'image', + name: 'cat.png', + isMissing: true + }) + ]) + + const nodesStore = useMissingNodesErrorStore() + nodesStore.surfaceMissingNodes([ + { + type: 'LoadImage', + nodeId: interiorExecId, + cnrId: undefined, + isReplaceable: false, + replacement: undefined + } + ]) + + subgraph.remove(interiorNode) + + expect(mediaStore.missingMediaCandidates).toBeNull() + expect(nodesStore.missingNodesError).toBeNull() + }) +}) + +describe('realtime scan verifies pending cloud candidates', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false) + }) + + it('un-bypass path surfaces pending model candidates after verification', async () => { + const graph = new LGraph() + const node = new LGraphNode('CheckpointLoaderSimple') + graph.add(node) + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + + // Cloud mode returns candidates with isMissing: undefined until + // verifyAssetSupportedCandidates resolves them against the assets store. + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([ + { + nodeId: String(node.id), + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: true, + name: 'cloud_model.safetensors', + isMissing: undefined + } + ]) + const verifySpy = vi + .spyOn(missingModelScan, 'verifyAssetSupportedCandidates') + .mockImplementation(async (candidates) => { + for (const c of candidates) c.isMissing = true + }) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([]) + + installErrorClearingHooks(graph) + + // Simulate un-bypass (BYPASS → NEVER_BY_USER is not active; use 0 = active) + node.mode = LGraphEventMode.ALWAYS + graph.onTrigger?.({ + type: 'node:property:changed', + nodeId: node.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + + await vi.waitFor(() => { + expect(verifySpy).toHaveBeenCalledOnce() + }) + await vi.waitFor(() => { + const store = useMissingModelStore() + expect(store.missingModelCandidates).toHaveLength(1) + expect(store.missingModelCandidates![0].name).toBe( + 'cloud_model.safetensors' + ) + }) + }) + + it('un-bypass path surfaces pending media candidates after verification', async () => { + const graph = new LGraph() + const node = new LGraphNode('LoadImage') + graph.add(node) + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([]) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([ + { + nodeId: String(node.id), + nodeType: 'LoadImage', + widgetName: 'image', + mediaType: 'image', + name: 'cloud_image.png', + isMissing: undefined + } + ]) + const verifySpy = vi + .spyOn(missingMediaScan, 'verifyCloudMediaCandidates') + .mockImplementation(async (candidates) => { + for (const c of candidates) c.isMissing = true + }) + + installErrorClearingHooks(graph) + + node.mode = LGraphEventMode.ALWAYS + graph.onTrigger?.({ + type: 'node:property:changed', + nodeId: node.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + + await vi.waitFor(() => { + expect(verifySpy).toHaveBeenCalledOnce() + }) + await vi.waitFor(() => { + const store = useMissingMediaStore() + expect(store.missingMediaCandidates).toHaveLength(1) + expect(store.missingMediaCandidates![0].name).toBe('cloud_image.png') + }) + }) + + it('does not add candidates that remain confirmed-present after verification', async () => { + const graph = new LGraph() + const node = new LGraphNode('CheckpointLoaderSimple') + graph.add(node) + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([ + { + nodeId: String(node.id), + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: true, + name: 'present.safetensors', + isMissing: undefined + } + ]) + vi.spyOn( + missingModelScan, + 'verifyAssetSupportedCandidates' + ).mockImplementation(async (candidates) => { + for (const c of candidates) c.isMissing = false + }) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([]) + + installErrorClearingHooks(graph) + + node.mode = LGraphEventMode.ALWAYS + graph.onTrigger?.({ + type: 'node:property:changed', + nodeId: node.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + + await new Promise((r) => setTimeout(r, 0)) + expect(useMissingModelStore().missingModelCandidates).toBeNull() + }) +}) + +describe('realtime verification staleness guards', () => { + beforeEach(() => { + vi.restoreAllMocks() + setActivePinia(createTestingPinia({ stubActions: false })) + vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false) + }) + + it('skips adding verified model when node was bypassed before verification resolved', async () => { + const graph = new LGraph() + const node = new LGraphNode('CheckpointLoaderSimple') + graph.add(node) + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([ + { + nodeId: String(node.id), + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: true, + name: 'stale_model.safetensors', + isMissing: undefined + } + ]) + let resolveVerify: (() => void) | undefined + const verifyPromise = new Promise((r) => (resolveVerify = r)) + const verifySpy = vi + .spyOn(missingModelScan, 'verifyAssetSupportedCandidates') + .mockImplementation(async (candidates) => { + await verifyPromise + for (const c of candidates) c.isMissing = true + }) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([]) + + installErrorClearingHooks(graph) + + // Un-bypass: kicks off verification (still pending) + node.mode = LGraphEventMode.ALWAYS + graph.onTrigger?.({ + type: 'node:property:changed', + nodeId: node.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce()) + + // Bypass again before verification resolves + node.mode = LGraphEventMode.BYPASS + + // Verification now resolves with isMissing: true, but staleness + // check must drop the add because node is currently bypassed. + resolveVerify!() + await new Promise((r) => setTimeout(r, 0)) + + expect(useMissingModelStore().missingModelCandidates).toBeNull() + }) + + it('skips adding verified media when node is deleted before verification resolved', async () => { + const graph = new LGraph() + const node = new LGraphNode('LoadImage') + graph.add(node) + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph) + + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([]) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([ + { + nodeId: String(node.id), + nodeType: 'LoadImage', + widgetName: 'image', + mediaType: 'image', + name: 'deleted_image.png', + isMissing: undefined + } + ]) + let resolveVerify: (() => void) | undefined + const verifyPromise = new Promise((r) => (resolveVerify = r)) + const verifySpy = vi + .spyOn(missingMediaScan, 'verifyCloudMediaCandidates') + .mockImplementation(async (candidates) => { + await verifyPromise + for (const c of candidates) c.isMissing = true + }) + + installErrorClearingHooks(graph) + + node.mode = LGraphEventMode.ALWAYS + graph.onTrigger?.({ + type: 'node:property:changed', + nodeId: node.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce()) + + // Delete the node before verification completes + graph.remove(node) + + resolveVerify!() + await new Promise((r) => setTimeout(r, 0)) + + expect(useMissingMediaStore().missingMediaCandidates).toBeNull() + }) + + it('skips adding verified model when rootGraph switched before verification resolved', async () => { + // Workflow A has a pending candidate on node id=1. A is replaced + // by workflow B (fresh LGraph, potentially has a node with the + // same id). Late verification from A must not leak into B. + const graphA = new LGraph() + const nodeA = new LGraphNode('CheckpointLoaderSimple') + graphA.add(nodeA) + const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA) + + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([ + { + nodeId: String(nodeA.id), + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: true, + name: 'stale_from_A.safetensors', + isMissing: undefined + } + ]) + let resolveVerify: (() => void) | undefined + const verifyPromise = new Promise((r) => (resolveVerify = r)) + const verifySpy = vi + .spyOn(missingModelScan, 'verifyAssetSupportedCandidates') + .mockImplementation(async (candidates) => { + await verifyPromise + for (const c of candidates) c.isMissing = true + }) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([]) + + installErrorClearingHooks(graphA) + + nodeA.mode = LGraphEventMode.ALWAYS + graphA.onTrigger?.({ + type: 'node:property:changed', + nodeId: nodeA.id, + property: 'mode', + oldValue: LGraphEventMode.BYPASS, + newValue: LGraphEventMode.ALWAYS + }) + await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce()) + + // Workflow swap: app.rootGraph now points at graphB. + const graphB = new LGraph() + const nodeB = new LGraphNode('CheckpointLoaderSimple') + graphB.add(nodeB) + rootSpy.mockReturnValue(graphB) + + resolveVerify!() + await new Promise((r) => setTimeout(r, 0)) + + // A's verification finished but rootGraph is now B — the late + // result must not be added to the store. + expect(useMissingModelStore().missingModelCandidates).toBeNull() + }) +}) + +describe('scan skips interior of bypassed subgraph containers', () => { + beforeEach(() => { + vi.restoreAllMocks() + setActivePinia(createTestingPinia({ stubActions: false })) + vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false) + }) + + it('does not surface interior missing model when entering a bypassed subgraph', async () => { + // Repro: root has a bypassed subgraph container, interior node is + // itself active. useGraphNodeManager replays `onNodeAdded` for each + // interior node on subgraph entry, which previously reached + // scanSingleNodeErrors without an ancestor check and resurfaced the + // error that the initial pipeline post-filter had correctly dropped. + const subgraph = createTestSubgraph() + const interiorNode = new LGraphNode('CheckpointLoaderSimple') + subgraph.add(interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 }) + subgraphNode.mode = LGraphEventMode.BYPASS + const rootGraph = subgraphNode.graph as LGraph + rootGraph.add(subgraphNode) + + vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph) + // Any scanner output would surface the error if the ancestor guard + // didn't short-circuit first — return a concrete missing candidate. + vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([ + { + nodeId: `${subgraphNode.id}:${interiorNode.id}`, + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + isAssetSupported: false, + name: 'fake.safetensors', + isMissing: true + } + ]) + vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([]) + + installErrorClearingHooks(subgraph) + + // Simulate useGraphNodeManager replaying onNodeAdded for existing + // interior nodes after Vue node manager init on subgraph entry. + subgraph.onNodeAdded?.(interiorNode) + await new Promise((r) => setTimeout(r, 0)) + + expect(useMissingModelStore().missingModelCandidates).toBeNull() + }) +}) + describe('clearWidgetRelatedErrors parameter routing', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/composables/graph/useErrorClearingHooks.ts b/src/composables/graph/useErrorClearingHooks.ts index 9f9be0b642..88b1fbc04d 100644 --- a/src/composables/graph/useErrorClearingHooks.ts +++ b/src/composables/graph/useErrorClearingHooks.ts @@ -8,12 +8,42 @@ import { useChainCallback } from '@/composables/functional/useChainCallback' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' -import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' +import { + LGraphEventMode, + NodeSlotType +} from '@/lib/litegraph/src/types/globalEnums' +import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers' +import { ChangeTracker } from '@/scripts/changeTracker' +import { isCloud } from '@/platform/distribution/types' +import { assetService } from '@/platform/assets/services/assetService' +import type { MissingMediaCandidate } from '@/platform/missingMedia/types' +import type { MissingModelCandidate } from '@/platform/missingModel/types' +import { + scanNodeModelCandidates, + verifyAssetSupportedCandidates +} from '@/platform/missingModel/missingModelScan' +import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' +import { + scanNodeMediaCandidates, + verifyCloudMediaCandidates +} from '@/platform/missingMedia/missingMediaScan' +import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' +import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore' +import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore' +import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil' import { app } from '@/scripts/app' import { useExecutionErrorStore } from '@/stores/executionErrorStore' -import { getExecutionIdByNode } from '@/utils/graphTraversalUtil' +import { useModelToNodeStore } from '@/stores/modelToNodeStore' +import { + collectAllNodes, + getExecutionIdByNode, + getExecutionIdForNodeInGraph, + getNodeByExecutionId, + isAncestorPathActive +} from '@/utils/graphTraversalUtil' function resolvePromotedExecId( rootGraph: LGraph, @@ -121,6 +151,210 @@ function restoreNodeHooksRecursive(node: LGraphNode): void { } } +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 { + if (!app.rootGraph) return + + if (node.isSubgraphNode?.() && node.subgraph) { + for (const innerNode of collectAllNodes(node.subgraph)) { + if (isNodeInactive(innerNode.mode)) continue + scanSingleNodeErrors(innerNode) + } + return + } + + scanSingleNodeErrors(node) +} + +function scanSingleNodeErrors(node: LGraphNode): void { + if (!app.rootGraph) return + // 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 + // nodes) reaches this point without the ancestor check. A null + // 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 + + const modelCandidates = scanNodeModelCandidates( + app.rootGraph, + node, + isCloud + ? (nodeType, widgetName) => + assetService.shouldUseAssetBrowser(nodeType, widgetName) + : () => false, + (nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType) + ) + const confirmedModels = modelCandidates.filter((c) => c.isMissing === true) + if (confirmedModels.length) { + useMissingModelStore().addMissingModels(confirmedModels) + } + // Cloud scans return isMissing: undefined for asset-browser-supported + // widgets until async verification resolves. Without this, realtime + // add/un-bypass paths would silently drop those candidates. + const pendingModels = modelCandidates.filter((c) => c.isMissing === undefined) + if (pendingModels.length) { + void verifyAndAddPendingModels(pendingModels) + } + + 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. + 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 + } + ]) + } + } +} + +/** + * True when the candidate's node still exists in the current root graph + * and is active. Filters out late verification results for nodes that + * have been bypassed, deleted, or belong to a workflow that is no + * longer current — any of which would reintroduce stale errors. + */ +function isCandidateStillActive(nodeId: unknown): boolean { + if (!app.rootGraph || nodeId == null) return false + const execId = String(nodeId) + const node = getNodeByExecutionId(app.rootGraph, execId) + if (!node) return false + if (isNodeInactive(node.mode)) return false + // Also reject if any enclosing subgraph was bypassed between scan + // kick-off and verification resolving — mirrors the pipeline-level + // ancestor post-filter so realtime and initial-load paths stay + // symmetric. + return isAncestorPathActive(app.rootGraph, execId) +} + +async function verifyAndAddPendingModels( + pending: MissingModelCandidate[] +): Promise { + // Capture rootGraph at scan time so a late verification for workflow + // A cannot leak into workflow B after a switch — execution IDs (esp. + // root-level like "1") collide across workflows. + const rootGraphAtScan = app.rootGraph + try { + await verifyAssetSupportedCandidates(pending) + if (app.rootGraph !== rootGraphAtScan) return + const verified = pending.filter( + (c) => c.isMissing === true && isCandidateStillActive(c.nodeId) + ) + if (verified.length) useMissingModelStore().addMissingModels(verified) + } catch (error: unknown) { + console.warn('[useErrorClearingHooks] model verification failed:', error) + } +} + +async function verifyAndAddPendingMedia( + pending: MissingMediaCandidate[] +): Promise { + const rootGraphAtScan = app.rootGraph + try { + await verifyCloudMediaCandidates(pending) + if (app.rootGraph !== rootGraphAtScan) return + const verified = pending.filter( + (c) => c.isMissing === true && isCandidateStillActive(c.nodeId) + ) + if (verified.length) useMissingMediaStore().addMissingMedia(verified) + } catch (error: unknown) { + console.warn('[useErrorClearingHooks] media verification failed:', error) + } +} + +function scanAddedNode(node: LGraphNode): void { + if (!app.rootGraph || ChangeTracker.isLoadingGraph) return + if (isNodeInactive(node.mode)) return + scanAndAddNodeErrors(node) +} + +function handleNodeModeChange( + localGraph: LGraph, + nodeId: number, + oldMode: number, + newMode: number +): void { + if (!app.rootGraph) return + + const wasInactive = isNodeInactive(oldMode) + const isNowInactive = isNodeInactive(newMode) + + if (wasInactive === isNowInactive) return + + // Find the node by local ID in the graph that fired the event, + // then compute its execution ID relative to the root graph. + const node = localGraph.getNodeById(nodeId) + if (!node) return + + const execId = getExecutionIdByNode(app.rootGraph, node) + if (!execId) return + + if (isNowInactive) { + removeNodeErrors(node, execId) + } else { + scanAndAddNodeErrors(node) + if ( + useMissingModelStore().hasMissingModels || + useMissingMediaStore().hasMissingMedia || + useMissingNodesErrorStore().hasMissingNodes + ) { + useExecutionErrorStore().showErrorOverlay() + } + } +} + +/** Remove all missing asset errors for a node and, if it's a subgraph + * container, for all interior nodes (prefix match on execution ID). */ +function removeNodeErrors(node: LGraphNode, execId: string): void { + const modelStore = useMissingModelStore() + const mediaStore = useMissingMediaStore() + const nodesStore = useMissingNodesErrorStore() + + modelStore.removeMissingModelsByNodeId(execId) + mediaStore.removeMissingMediaByNodeId(execId) + nodesStore.removeMissingNodesByNodeId(execId) + + // For subgraph containers, also remove errors from interior nodes. + // The trailing colon in the prefix is load-bearing: it prevents sibling + // IDs sharing a numeric prefix (e.g. "705" vs "70") from being matched. + if (node.isSubgraphNode?.() && node.subgraph) { + const prefix = `${execId}:` + modelStore.removeMissingModelsByPrefix(prefix) + mediaStore.removeMissingMediaByPrefix(prefix) + nodesStore.removeMissingNodesByPrefix(prefix) + } +} + export function installErrorClearingHooks(graph: LGraph): () => void { for (const node of graph._nodes ?? []) { installNodeHooksRecursive(node) @@ -129,20 +363,54 @@ export function installErrorClearingHooks(graph: LGraph): () => void { const originalOnNodeAdded = graph.onNodeAdded graph.onNodeAdded = function (node: LGraphNode) { installNodeHooksRecursive(node) + + // 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. + if (!ChangeTracker.isLoadingGraph) { + queueMicrotask(() => scanAddedNode(node)) + } + originalOnNodeAdded?.call(this, node) } const originalOnNodeRemoved = graph.onNodeRemoved graph.onNodeRemoved = function (node: LGraphNode) { + // node.graph is already null by the time onNodeRemoved fires, so + // derive the execution ID from the graph the hook is installed on + // plus node.id. For subgraph interior nodes this yields the full + // "parentId:...:nodeId" path that matches how missing asset errors + // are keyed; without this, removal falls back to the local ID and + // misses subgraph entries. + const execId = app.rootGraph + ? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id) + : String(node.id) + removeNodeErrors(node, execId) restoreNodeHooksRecursive(node) originalOnNodeRemoved?.call(this, node) } + const originalOnTrigger = graph.onTrigger + graph.onTrigger = (event: LGraphTriggerEvent) => { + if (event.type === 'node:property:changed' && event.property === 'mode') { + handleNodeModeChange( + graph, + event.nodeId as number, + event.oldValue as number, + event.newValue as number + ) + } + originalOnTrigger?.(event) + } + return () => { for (const node of graph._nodes ?? []) { restoreNodeHooksRecursive(node) } graph.onNodeAdded = originalOnNodeAdded || undefined graph.onNodeRemoved = originalOnNodeRemoved || undefined + graph.onTrigger = originalOnTrigger || undefined } } diff --git a/src/composables/graph/useGraphHierarchy.test.ts b/src/composables/graph/useGraphHierarchy.test.ts index c7b5a8f819..30cb8968e2 100644 --- a/src/composables/graph/useGraphHierarchy.test.ts +++ b/src/composables/graph/useGraphHierarchy.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle' @@ -8,7 +9,6 @@ import { createMockLGraphNode, createMockLGraphGroup } from '@/utils/__tests__/litegraphTestUtils' - import { useGraphHierarchy } from './useGraphHierarchy' vi.mock('@/renderer/core/canvas/canvasStore') @@ -36,7 +36,10 @@ describe('useGraphHierarchy', () => { mockNode = createMockNode() mockGroups = [] - mockCanvasStore = { + mockCanvasStore = fromAny< + Partial>, + unknown + >({ canvas: { graph: { groups: mockGroups @@ -51,7 +54,7 @@ describe('useGraphHierarchy', () => { $dispose: vi.fn(), _customProperties: new Set(), _p: {} - } as unknown as Partial> + }) vi.mocked(useCanvasStore).mockReturnValue( mockCanvasStore as ReturnType diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 268e07ec57..cb00bbfbd7 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -1,5 +1,6 @@ -import { setActivePinia } from 'pinia' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, nextTick, watch } from 'vue' @@ -11,10 +12,10 @@ import { createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' -import { app } from '@/scripts/app' -import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useSettingStore } from '@/platform/settings/settingStore' +import { app } from '@/scripts/app' +import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -277,18 +278,20 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { const secondPromotedView = promotedViews[1] if (!secondPromotedView) throw new Error('Expected second promoted view') - ;( - secondPromotedView as unknown as { + fromAny< + { sourceNodeId: string sourceWidgetName: string - } - ).sourceNodeId = '9999' - ;( - secondPromotedView as unknown as { + }, + unknown + >(secondPromotedView).sourceNodeId = '9999' + fromAny< + { sourceNodeId: string sourceWidgetName: string - } - ).sourceWidgetName = 'stale_widget' + }, + unknown + >(secondPromotedView).sourceWidgetName = 'stale_widget' const { vueNodeData } = useGraphNodeManager(graph) const nodeData = vueNodeData.get(String(subgraphNode.id)) diff --git a/src/composables/graph/useGroupMenuOptions.ts b/src/composables/graph/useGroupMenuOptions.ts index ac390a62cd..60cf4ac101 100644 --- a/src/composables/graph/useGroupMenuOptions.ts +++ b/src/composables/graph/useGroupMenuOptions.ts @@ -36,7 +36,7 @@ export function useGroupMenuOptions() { groupContext.resizeTo(groupContext.children, padding) groupContext.graph?.change() canvasStore.canvas?.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } }) @@ -119,7 +119,7 @@ export function useGroupMenuOptions() { }) canvasStore.canvas?.setDirty(true, true) groupContext.graph?.change() - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() bump() } }) diff --git a/src/composables/graph/useImageMenuOptions.test.ts b/src/composables/graph/useImageMenuOptions.test.ts index 510a7b927e..bac50a9749 100644 --- a/src/composables/graph/useImageMenuOptions.test.ts +++ b/src/composables/graph/useImageMenuOptions.test.ts @@ -1,8 +1,8 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { afterEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' - import { useImageMenuOptions } from './useImageMenuOptions' vi.mock('vue-i18n', async (importOriginal) => { @@ -112,9 +112,11 @@ describe('useImageMenuOptions', () => { getType: vi.fn().mockResolvedValue(mockBlob) } - mockClipboard({ - read: vi.fn().mockResolvedValue([mockClipboardItem]) - } as unknown as Clipboard) + mockClipboard( + fromPartial({ + read: vi.fn().mockResolvedValue([mockClipboardItem]) + }) + ) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) @@ -131,7 +133,7 @@ describe('useImageMenuOptions', () => { it('handles missing clipboard API gracefully', async () => { const node = createImageNode() - mockClipboard({ read: undefined } as unknown as Clipboard) + mockClipboard(fromPartial({ read: undefined })) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) @@ -148,9 +150,11 @@ describe('useImageMenuOptions', () => { getType: vi.fn() } - mockClipboard({ - read: vi.fn().mockResolvedValue([mockClipboardItem]) - } as unknown as Clipboard) + mockClipboard( + fromPartial({ + read: vi.fn().mockResolvedValue([mockClipboardItem]) + }) + ) const { getImageMenuOptions } = useImageMenuOptions() const options = getImageMenuOptions(node) diff --git a/src/composables/graph/useNodeErrorFlagSync.ts b/src/composables/graph/useNodeErrorFlagSync.ts index bb32b82363..361b892161 100644 --- a/src/composables/graph/useNodeErrorFlagSync.ts +++ b/src/composables/graph/useNodeErrorFlagSync.ts @@ -3,6 +3,7 @@ import { computed, watch } from 'vue' import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore' +import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import { useSettingStore } from '@/platform/settings/settingStore' import { app } from '@/scripts/app' import type { NodeError } from '@/schemas/apiSchema' @@ -32,7 +33,8 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void { function reconcileNodeErrorFlags( rootGraph: LGraph, nodeErrors: Record | null, - missingModelExecIds: Set + missingModelExecIds: Set, + missingMediaExecIds: Set = new Set() ): void { // Collect nodes and slot info that should be flagged // Includes both error-owning nodes and their ancestor containers @@ -64,6 +66,11 @@ function reconcileNodeErrorFlags( if (node) flaggedNodes.add(node) } + for (const execId of missingMediaExecIds) { + const node = getNodeByExecutionId(rootGraph, execId) + if (node) flaggedNodes.add(node) + } + forEachNode(rootGraph, (node) => { setNodeHasErrors(node, flaggedNodes.has(node)) @@ -78,7 +85,8 @@ function reconcileNodeErrorFlags( export function useNodeErrorFlagSync( lastNodeErrors: Ref | null>, - missingModelStore: ReturnType + missingModelStore: ReturnType, + missingMediaStore: ReturnType ): () => void { const settingStore = useSettingStore() const showErrorsTab = computed(() => @@ -89,12 +97,13 @@ export function useNodeErrorFlagSync( [ lastNodeErrors, () => missingModelStore.missingModelNodeIds, + () => missingMediaStore.missingMediaNodeIds, showErrorsTab ], () => { if (!app.isGraphReady) return - // Legacy (LGraphNode) only: suppress missing-model error flags when - // the Errors tab is hidden, since legacy nodes lack the per-widget + // Legacy (LGraphNode) only: suppress missing-model/media error flags + // when the Errors tab is hidden, since legacy nodes lack the per-widget // red highlight that Vue nodes use to indicate *why* a node has errors. // Vue nodes compute hasAnyError independently and are unaffected. reconcileNodeErrorFlags( @@ -102,6 +111,9 @@ export function useNodeErrorFlagSync( lastNodeErrors.value, showErrorsTab.value ? missingModelStore.missingModelAncestorExecutionIds + : new Set(), + showErrorsTab.value + ? missingMediaStore.missingMediaAncestorExecutionIds : new Set() ) }, diff --git a/src/composables/graph/useSelectedNodeActions.ts b/src/composables/graph/useSelectedNodeActions.ts index 7ea48b5454..68fabe304a 100644 --- a/src/composables/graph/useSelectedNodeActions.ts +++ b/src/composables/graph/useSelectedNodeActions.ts @@ -23,7 +23,7 @@ export function useSelectedNodeActions() { }) app.canvas.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const toggleNodeCollapse = () => { @@ -33,7 +33,7 @@ export function useSelectedNodeActions() { }) app.canvas.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const toggleNodePin = () => { @@ -43,7 +43,7 @@ export function useSelectedNodeActions() { }) app.canvas.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const toggleNodeBypass = () => { diff --git a/src/composables/graph/useSelectionOperations.ts b/src/composables/graph/useSelectionOperations.ts index 857e8871d6..d53a170989 100644 --- a/src/composables/graph/useSelectionOperations.ts +++ b/src/composables/graph/useSelectionOperations.ts @@ -47,7 +47,7 @@ export function useSelectionOperations() { canvas.pasteFromClipboard({ connectInputs: false }) // Trigger change tracking - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const duplicateSelection = () => { @@ -73,7 +73,7 @@ export function useSelectionOperations() { canvas.pasteFromClipboard({ connectInputs: false }) // Trigger change tracking - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const deleteSelection = () => { @@ -92,7 +92,7 @@ export function useSelectionOperations() { canvas.setDirty(true, true) // Trigger change tracking - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const renameSelection = async () => { @@ -122,7 +122,7 @@ export function useSelectionOperations() { const titledItem = item as { title: string } titledItem.title = newTitle app.canvas.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } } return @@ -145,7 +145,7 @@ export function useSelectionOperations() { } }) app.canvas.setDirty(true, true) - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } return } diff --git a/src/composables/graph/useSubgraphOperations.ts b/src/composables/graph/useSubgraphOperations.ts index 3df39d3fac..9a50752c26 100644 --- a/src/composables/graph/useSubgraphOperations.ts +++ b/src/composables/graph/useSubgraphOperations.ts @@ -31,7 +31,7 @@ export function useSubgraphOperations() { canvas.select(node) canvasStore.updateSelectedItems() // Trigger change tracking - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const doUnpack = ( @@ -46,7 +46,7 @@ export function useSubgraphOperations() { nodeOutputStore.revokeSubgraphPreviews(subgraphNode) graph.unpackSubgraph(subgraphNode, { skipMissingNodes }) } - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } const unpackSubgraph = () => { diff --git a/src/composables/maskeditor/useCanvasTools.ts b/src/composables/maskeditor/useCanvasTools.ts index 59a45247f0..1f1c9e0e31 100644 --- a/src/composables/maskeditor/useCanvasTools.ts +++ b/src/composables/maskeditor/useCanvasTools.ts @@ -2,6 +2,7 @@ import { ref, watch } from 'vue' import { useMaskEditorStore } from '@/stores/maskEditorStore' import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types' import type { Point } from '@/extensions/core/maskeditor/types' +import { rgbToHsl } from '@/utils/colorUtil' const getPixelAlpha = ( data: Uint8ClampedArray, @@ -47,39 +48,8 @@ const rgbToHSL = ( g: number, b: number ): { h: number; s: number; l: number } => { - r /= 255 - g /= 255 - b /= 255 - - const max = Math.max(r, g, b) - const min = Math.min(r, g, b) - let h = 0 - let s = 0 - const l = (max + min) / 2 - - if (max !== min) { - const d = max - min - s = l > 0.5 ? d / (2 - max - min) : d / (max + min) - - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0) - break - case g: - h = (b - r) / d + 2 - break - case b: - h = (r - g) / d + 4 - break - } - h /= 6 - } - - return { - h: h * 360, - s: s * 100, - l: l * 100 - } + const hsl = rgbToHsl({ r, g, b }) + return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 } } const rgbToLab = (rgb: { diff --git a/src/composables/maskeditor/useMaskEditorSaver.test.ts b/src/composables/maskeditor/useMaskEditorSaver.test.ts index c074c6566a..7031bf0189 100644 --- a/src/composables/maskeditor/useMaskEditorSaver.test.ts +++ b/src/composables/maskeditor/useMaskEditorSaver.test.ts @@ -1,10 +1,11 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' -import { app } from '@/scripts/app' import { api } from '@/scripts/api' +import { app } from '@/scripts/app' import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { useMaskEditorSaver } from './useMaskEditorSaver' @@ -21,7 +22,7 @@ vi.mock('@/stores/maskEditorDataStore', () => ({ })) function createMockCtx(): CanvasRenderingContext2D { - return { + return fromPartial({ drawImage: vi.fn(), getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4 * 4 * 4), @@ -30,11 +31,11 @@ function createMockCtx(): CanvasRenderingContext2D { })), putImageData: vi.fn(), globalCompositeOperation: 'source-over' - } as unknown as CanvasRenderingContext2D + }) } function createMockCanvas(): HTMLCanvasElement { - return { + return fromPartial({ width: 4, height: 4, getContext: vi.fn(() => createMockCtx()), @@ -42,7 +43,7 @@ function createMockCanvas(): HTMLCanvasElement { cb(new Blob(['x'], { type: 'image/png' })) }), toDataURL: vi.fn(() => 'data:image/png;base64,mock') - } as unknown as HTMLCanvasElement + }) } const mockEditorStore: Record = { @@ -96,7 +97,7 @@ describe('useMaskEditorSaver', () => { app.nodeOutputs = {} app.nodePreviewImages = {} - mockNode = { + mockNode = fromAny({ id: 42, type: 'LoadImage', images: [], @@ -107,7 +108,7 @@ describe('useMaskEditorSaver', () => { widgets_values: ['original.png [input]'], properties: { image: 'original.png [input]' }, graph: { setDirtyCanvas: vi.fn() } - } as unknown as LGraphNode + }) mockDataStore.sourceNode = mockNode mockDataStore.inputData = { @@ -135,7 +136,7 @@ describe('useMaskEditorSaver', () => { vi.spyOn(document, 'createElement').mockImplementation( (tagName: string, options?: ElementCreationOptions) => { if (tagName === 'canvas') - return createMockCanvas() as unknown as HTMLCanvasElement + return fromAny(createMockCanvas()) return originalCreateElement(tagName, options) } ) diff --git a/src/composables/node/useNodeImageUpload.test.ts b/src/composables/node/useNodeImageUpload.test.ts index ca755faa4a..b03d80237b 100644 --- a/src/composables/node/useNodeImageUpload.test.ts +++ b/src/composables/node/useNodeImageUpload.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -44,12 +45,12 @@ vi.mock('@/stores/assetsStore', () => ({ })) function createMockNode(): LGraphNode { - return { + return fromAny({ isUploading: false, imgs: [new Image()], graph: { setDirtyCanvas: vi.fn() }, size: [300, 400] - } as unknown as LGraphNode + }) } function createFile(name = 'test.png'): File { diff --git a/src/composables/node/useNodeImageUpload.ts b/src/composables/node/useNodeImageUpload.ts index 587d67bcdc..407f2fbfd0 100644 --- a/src/composables/node/useNodeImageUpload.ts +++ b/src/composables/node/useNodeImageUpload.ts @@ -9,6 +9,7 @@ import { api } from '@/scripts/api' import { useAssetsStore } from '@/stores/assetsStore' const PASTED_IMAGE_EXPIRY_MS = 2000 +const UPLOAD_TIMEOUT_MS = 120_000 interface ImageUploadFormFields { /** @@ -30,7 +31,8 @@ const uploadFile = async ( const resp = await api.fetchApi('/upload/image', { method: 'POST', - body + body, + signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) }) if (resp.status !== 200) { @@ -88,7 +90,11 @@ export const useNodeImageUpload = ( if (!path) return return path } catch (error) { - useToastStore().addAlert(String(error)) + if (error instanceof DOMException && error.name === 'TimeoutError') { + useToastStore().addAlert(t('g.uploadTimedOut')) + } else { + useToastStore().addAlert(String(error)) + } } } diff --git a/src/composables/node/useNodePreviewAndDrag.test.ts b/src/composables/node/useNodePreviewAndDrag.test.ts index bad6d3d7fe..79e365a907 100644 --- a/src/composables/node/useNodePreviewAndDrag.test.ts +++ b/src/composables/node/useNodePreviewAndDrag.test.ts @@ -1,8 +1,8 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' - import { useNodePreviewAndDrag } from './useNodePreviewAndDrag' const mockStartDrag = vi.fn() @@ -72,9 +72,9 @@ describe('useNodePreviewAndDrag', () => { toJSON: () => ({}) }) - const mockEvent = { + const mockEvent = fromPartial({ currentTarget: mockElement - } as Partial as MouseEvent + }) result.handleMouseEnter(mockEvent) expect(result.isHovered.value).toBe(true) @@ -85,9 +85,9 @@ describe('useNodePreviewAndDrag', () => { const result = useNodePreviewAndDrag(nodeDef) const mockElement = document.createElement('div') - const mockEvent = { + const mockEvent = fromPartial({ currentTarget: mockElement - } as Partial as MouseEvent + }) result.handleMouseEnter(mockEvent) expect(result.isHovered.value).toBe(false) @@ -116,9 +116,9 @@ describe('useNodePreviewAndDrag', () => { setData: vi.fn(), setDragImage: vi.fn() } - const mockEvent = { + const mockEvent = fromAny({ dataTransfer: mockDataTransfer - } as unknown as DragEvent + }) result.handleDragStart(mockEvent) @@ -151,10 +151,10 @@ describe('useNodePreviewAndDrag', () => { result.isDragging.value = true - const mockEvent = { + const mockEvent = fromPartial({ clientX: 100, clientY: 200 - } as Partial as DragEvent + }) result.handleDragEnd(mockEvent) @@ -168,11 +168,11 @@ describe('useNodePreviewAndDrag', () => { result.isDragging.value = true - const mockEvent = { + const mockEvent = fromPartial({ dataTransfer: { dropEffect: 'none' }, clientX: 300, clientY: 400 - } as Partial as DragEvent + }) result.handleDragEnd(mockEvent) diff --git a/src/composables/painter/usePainter.test.ts b/src/composables/painter/usePainter.test.ts new file mode 100644 index 0000000000..9e4f643523 --- /dev/null +++ b/src/composables/painter/usePainter.test.ts @@ -0,0 +1,446 @@ +import { createTestingPinia } from '@pinia/testing' +import { render } from '@testing-library/vue' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, nextTick, ref } from 'vue' + +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { api } from '@/scripts/api' + +import { usePainter } from './usePainter' + +vi.mock('vue-i18n', () => ({ + useI18n: vi.fn(() => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key + })) +})) + +vi.mock('@vueuse/core', () => ({ + useElementSize: vi.fn(() => ({ + width: ref(512), + height: ref(512) + })) +})) + +vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({ + StrokeProcessor: vi.fn(() => ({ + addPoint: vi.fn(() => []), + endStroke: vi.fn(() => []) + })) +})) + +vi.mock('@/platform/distribution/types', () => ({ + isCloud: false +})) + +vi.mock('@/platform/updates/common/toastStore', () => { + const store = { addAlert: vi.fn() } + return { useToastStore: () => store } +}) + +vi.mock('@/stores/nodeOutputStore', () => { + const store = { + getNodeImageUrls: vi.fn(() => undefined), + nodeOutputs: {}, + nodePreviewImages: {} + } + return { useNodeOutputStore: () => store } +}) + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: vi.fn((path: string) => `http://localhost:8188${path}`), + fetchApi: vi.fn() + } +})) + +const mockWidgets: IBaseWidget[] = [] +const mockProperties: Record = {} +const mockIsInputConnected = vi.fn(() => false) +const mockGetInputNode = vi.fn(() => null) + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { + graph: { + getNodeById: vi.fn(() => ({ + get widgets() { + return mockWidgets + }, + get properties() { + return mockProperties + }, + isInputConnected: mockIsInputConnected, + getInputNode: mockGetInputNode + })) + } + } + } +})) + +type PainterResult = ReturnType + +function makeWidget(name: string, value: unknown = null): IBaseWidget { + return { + name, + value, + callback: vi.fn(), + serializeValue: undefined + } as unknown as IBaseWidget +} + +/** + * Mounts a thin wrapper component so Vue lifecycle hooks fire. + */ +function mountPainter(nodeId = 'test-node', initialModelValue = '') { + let painter!: PainterResult + const canvasEl = ref(null) + const cursorEl = ref(null) + const modelValue = ref(initialModelValue) + + const Wrapper = defineComponent({ + setup() { + painter = usePainter(nodeId, { + canvasEl, + cursorEl, + modelValue + }) + return {} + }, + render() { + return null + } + }) + + render(Wrapper) + return { painter, canvasEl, cursorEl, modelValue } +} + +describe('usePainter', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetAllMocks() + mockWidgets.length = 0 + for (const key of Object.keys(mockProperties)) { + delete mockProperties[key] + } + mockIsInputConnected.mockReturnValue(false) + mockGetInputNode.mockReturnValue(null) + }) + + describe('syncCanvasSizeFromWidgets', () => { + it('reads width/height from widget values on initialization', () => { + mockWidgets.push(makeWidget('width', 1024), makeWidget('height', 768)) + + const { painter } = mountPainter() + + expect(painter.canvasWidth.value).toBe(1024) + expect(painter.canvasHeight.value).toBe(768) + }) + + it('defaults to 512 when widgets are missing', () => { + const { painter } = mountPainter() + + expect(painter.canvasWidth.value).toBe(512) + expect(painter.canvasHeight.value).toBe(512) + }) + }) + + describe('restoreSettingsFromProperties', () => { + it('restores tool and brush settings from node properties on init', () => { + mockProperties.painterTool = 'eraser' + mockProperties.painterBrushSize = 42 + mockProperties.painterBrushColor = '#ff0000' + mockProperties.painterBrushOpacity = 0.5 + mockProperties.painterBrushHardness = 0.8 + + const { painter } = mountPainter() + + expect(painter.tool.value).toBe('eraser') + expect(painter.brushSize.value).toBe(42) + expect(painter.brushColor.value).toBe('#ff0000') + expect(painter.brushOpacity.value).toBe(0.5) + expect(painter.brushHardness.value).toBe(0.8) + }) + + it('restores backgroundColor from bg_color widget', () => { + mockWidgets.push(makeWidget('bg_color', '#123456')) + + const { painter } = mountPainter() + + expect(painter.backgroundColor.value).toBe('#123456') + }) + + it('keeps defaults when no properties are stored', () => { + const { painter } = mountPainter() + + expect(painter.tool.value).toBe('brush') + expect(painter.brushSize.value).toBe(20) + expect(painter.brushColor.value).toBe('#ffffff') + expect(painter.brushOpacity.value).toBe(1) + expect(painter.brushHardness.value).toBe(1) + }) + }) + + describe('saveSettingsToProperties', () => { + it('persists tool settings to node properties when they change', async () => { + const { painter } = mountPainter() + + painter.tool.value = 'eraser' + painter.brushSize.value = 50 + painter.brushColor.value = '#00ff00' + painter.brushOpacity.value = 0.7 + painter.brushHardness.value = 0.3 + + await nextTick() + + expect(mockProperties.painterTool).toBe('eraser') + expect(mockProperties.painterBrushSize).toBe(50) + expect(mockProperties.painterBrushColor).toBe('#00ff00') + expect(mockProperties.painterBrushOpacity).toBe(0.7) + expect(mockProperties.painterBrushHardness).toBe(0.3) + }) + }) + + describe('syncCanvasSizeToWidgets', () => { + it('syncs canvas dimensions to widgets when size changes', async () => { + const widthWidget = makeWidget('width', 512) + const heightWidget = makeWidget('height', 512) + mockWidgets.push(widthWidget, heightWidget) + + const { painter } = mountPainter() + + painter.canvasWidth.value = 800 + painter.canvasHeight.value = 600 + await nextTick() + + expect(widthWidget.value).toBe(800) + expect(heightWidget.value).toBe(600) + expect(widthWidget.callback).toHaveBeenCalledWith(800) + expect(heightWidget.callback).toHaveBeenCalledWith(600) + }) + }) + + describe('syncBackgroundColorToWidget', () => { + it('syncs background color to widget when color changes', async () => { + const bgWidget = makeWidget('bg_color', '#000000') + mockWidgets.push(bgWidget) + + const { painter } = mountPainter() + + painter.backgroundColor.value = '#ff00ff' + await nextTick() + + expect(bgWidget.value).toBe('#ff00ff') + expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff') + }) + }) + + describe('updateInputImageUrl', () => { + it('sets isImageInputConnected to false when input is not connected', () => { + const { painter } = mountPainter() + + expect(painter.isImageInputConnected.value).toBe(false) + expect(painter.inputImageUrl.value).toBeNull() + }) + + it('sets isImageInputConnected to true when input is connected', () => { + mockIsInputConnected.mockReturnValue(true) + + const { painter } = mountPainter() + + expect(painter.isImageInputConnected.value).toBe(true) + }) + }) + + describe('handleInputImageLoad', () => { + it('updates canvas size and widgets from loaded image dimensions', () => { + const widthWidget = makeWidget('width', 512) + const heightWidget = makeWidget('height', 512) + mockWidgets.push(widthWidget, heightWidget) + + const { painter } = mountPainter() + + const fakeEvent = { + target: { + naturalWidth: 1920, + naturalHeight: 1080 + } + } as unknown as Event + + painter.handleInputImageLoad(fakeEvent) + + expect(painter.canvasWidth.value).toBe(1920) + expect(painter.canvasHeight.value).toBe(1080) + expect(widthWidget.value).toBe(1920) + expect(heightWidget.value).toBe(1080) + }) + }) + + describe('cursor visibility', () => { + it('sets cursorVisible to true on pointer enter', () => { + const { painter } = mountPainter() + + painter.handlePointerEnter() + expect(painter.cursorVisible.value).toBe(true) + }) + + it('sets cursorVisible to false on pointer leave', () => { + const { painter } = mountPainter() + + painter.handlePointerEnter() + painter.handlePointerLeave() + expect(painter.cursorVisible.value).toBe(false) + }) + }) + + describe('displayBrushSize', () => { + it('scales brush size by canvas display ratio', () => { + const { painter } = mountPainter() + + // canvasDisplayWidth=512, canvasWidth=512 → ratio=1 + // hardness=1 → effectiveRadius = radius * 1.0 + // displayBrushSize = (20/2) * 1.0 * 2 * 1 = 20 + expect(painter.displayBrushSize.value).toBe(20) + }) + + it('increases for soft brush hardness', () => { + const { painter } = mountPainter() + + painter.brushHardness.value = 0 + // hardness=0 → effectiveRadius = 10 * 1.5 = 15 + // displayBrushSize = 15 * 2 * 1 = 30 + expect(painter.displayBrushSize.value).toBe(30) + }) + }) + + describe('activeHardness (via displayBrushSize)', () => { + it('returns 1 for eraser regardless of brushHardness', () => { + const { painter } = mountPainter() + + painter.brushHardness.value = 0.3 + painter.tool.value = 'eraser' + + // eraser hardness=1 → displayBrushSize = 10 * 1.0 * 2 = 20 + expect(painter.displayBrushSize.value).toBe(20) + }) + + it('uses brushHardness for brush tool', () => { + const { painter } = mountPainter() + + painter.tool.value = 'brush' + painter.brushHardness.value = 0.5 + // hardness=0.5 → scale=1.25 → 10*1.25*2 = 25 + expect(painter.displayBrushSize.value).toBe(25) + }) + }) + + describe('registerWidgetSerialization', () => { + it('attaches serializeValue to the mask widget on init', () => { + const maskWidget = makeWidget('mask', '') + mockWidgets.push(maskWidget) + + mountPainter() + + expect(maskWidget.serializeValue).toBeTypeOf('function') + }) + }) + + describe('serializeValue', () => { + it('returns empty string when canvas has no strokes', async () => { + const maskWidget = makeWidget('mask', '') + mockWidgets.push(maskWidget) + + mountPainter() + + const result = await maskWidget.serializeValue!({} as LGraphNode, 0) + expect(result).toBe('') + }) + + it('returns existing modelValue when not dirty', async () => { + const maskWidget = makeWidget('mask', '') + mockWidgets.push(maskWidget) + + const { modelValue } = mountPainter() + modelValue.value = 'painter/existing.png [temp]' + + const result = await maskWidget.serializeValue!({} as LGraphNode, 0) + // isCanvasEmpty() is true (no strokes drawn), so returns '' + expect(result).toBe('') + }) + }) + + describe('restoreCanvas', () => { + it('builds correct URL from modelValue on mount', () => { + const { modelValue } = mountPainter() + // Before mount, set the modelValue + // restoreCanvas is called in onMounted, so we test by observing api.apiURL calls + // With empty modelValue, restoreCanvas exits early + expect(modelValue.value).toBe('') + }) + + it('calls api.apiURL with parsed filename params when modelValue is set', () => { + vi.mocked(api.apiURL).mockClear() + + mountPainter('test-node', 'painter/my-image.png [temp]') + + expect(api.apiURL).toHaveBeenCalledWith( + expect.stringContaining('filename=my-image.png') + ) + expect(api.apiURL).toHaveBeenCalledWith( + expect.stringContaining('subfolder=painter') + ) + expect(api.apiURL).toHaveBeenCalledWith( + expect.stringContaining('type=temp') + ) + }) + }) + + describe('handleClear', () => { + it('does not throw when canvas element is null', () => { + const { painter } = mountPainter() + + expect(() => painter.handleClear()).not.toThrow() + }) + }) + + describe('handlePointerDown', () => { + it('ignores non-primary button clicks', () => { + const { painter } = mountPainter() + + const mockSetPointerCapture = vi.fn() + const event = new PointerEvent('pointerdown', { + button: 2 + }) + Object.defineProperty(event, 'target', { + value: { + setPointerCapture: mockSetPointerCapture + } + }) + + painter.handlePointerDown(event) + + expect(mockSetPointerCapture).not.toHaveBeenCalled() + }) + }) + + describe('handlePointerUp', () => { + it('ignores non-primary button releases', () => { + const { painter } = mountPainter() + + const mockReleasePointerCapture = vi.fn() + const event = { + button: 2, + target: { + releasePointerCapture: mockReleasePointerCapture + } + } as unknown as PointerEvent + + painter.handlePointerUp(event) + + expect(mockReleasePointerCapture).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts index 1fdb2bbc39..2d560ac743 100644 --- a/src/composables/queue/useJobList.test.ts +++ b/src/composables/queue/useJobList.test.ts @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils' +import { render } from '@testing-library/vue' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { nextTick, reactive, ref } from 'vue' import type { Ref } from 'vue' @@ -184,14 +184,14 @@ const createTask = ( const mountUseJobList = () => { let composable: ReturnType - const wrapper = mount({ + const result = render({ template: '
', setup() { composable = useJobList() return {} } }) - return { wrapper, composable: composable! } + return { ...result, composable: composable! } } const resetStores = () => { @@ -230,27 +230,27 @@ const flush = async () => { } describe('useJobList', () => { - let wrapper: ReturnType | null = null + let unmount: (() => void) | null = null let api: ReturnType | null = null beforeEach(() => { vi.resetAllMocks() resetStores() - wrapper?.unmount() - wrapper = null + unmount?.() + unmount = null api = null }) afterEach(() => { - wrapper?.unmount() - wrapper = null + unmount?.() + unmount = null api = null vi.useRealTimers() }) const initComposable = () => { const mounted = mountUseJobList() - wrapper = mounted.wrapper + unmount = mounted.unmount api = mounted.composable return api! } @@ -321,8 +321,8 @@ describe('useJobList', () => { await flush() expect(vi.getTimerCount()).toBeGreaterThan(0) - wrapper?.unmount() - wrapper = null + unmount?.() + unmount = null await flush() expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/composables/queue/useQueueNotificationBanners.test.ts b/src/composables/queue/useQueueNotificationBanners.test.ts index 6b51e1e575..179fc5df01 100644 --- a/src/composables/queue/useQueueNotificationBanners.test.ts +++ b/src/composables/queue/useQueueNotificationBanners.test.ts @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils' +import { render } from '@testing-library/vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, reactive } from 'vue' @@ -45,14 +45,14 @@ vi.mock('@/stores/executionStore', () => { const mountComposable = () => { let composable: ReturnType - const wrapper = mount({ + const result = render({ template: '
', setup() { composable = useQueueNotificationBanners() return {} } }) - return { wrapper, composable: composable! } + return { ...result, composable: composable! } } describe(useQueueNotificationBanners, () => { @@ -131,7 +131,7 @@ describe(useQueueNotificationBanners, () => { }) it('shows queued notifications from promptQueued events', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { mockApi.dispatchEvent( @@ -148,12 +148,12 @@ describe(useQueueNotificationBanners, () => { await nextTick() expect(composable.currentNotification.value).toBeNull() } finally { - wrapper.unmount() + unmount() } }) it('shows queued pending then queued confirmation', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { mockApi.dispatchEvent( @@ -182,12 +182,12 @@ describe(useQueueNotificationBanners, () => { requestId: 1 }) } finally { - wrapper.unmount() + unmount() } }) it('falls back to 1 when queued batch count is invalid', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { mockApi.dispatchEvent( @@ -200,12 +200,12 @@ describe(useQueueNotificationBanners, () => { count: 1 }) } finally { - wrapper.unmount() + unmount() } }) it('shows a completed notification from a finished batch', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { await runBatch({ @@ -225,12 +225,12 @@ describe(useQueueNotificationBanners, () => { thumbnailUrls: ['https://example.com/preview.png'] }) } finally { - wrapper.unmount() + unmount() } }) it('shows one completion notification when history updates after queue becomes idle', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { vi.setSystemTime(4_000) @@ -266,12 +266,12 @@ describe(useQueueNotificationBanners, () => { await nextTick() expect(composable.currentNotification.value).toBeNull() } finally { - wrapper.unmount() + unmount() } }) it('queues both completed and failed notifications for mixed batches', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { await runBatch({ @@ -302,12 +302,12 @@ describe(useQueueNotificationBanners, () => { count: 1 }) } finally { - wrapper.unmount() + unmount() } }) it('uses up to two completion thumbnails for notification icon previews', async () => { - const { wrapper, composable } = mountComposable() + const { unmount, composable } = mountComposable() try { await runBatch({ @@ -342,7 +342,7 @@ describe(useQueueNotificationBanners, () => { ] }) } finally { - wrapper.unmount() + unmount() } }) }) diff --git a/src/composables/queue/useQueueProgress.test.ts b/src/composables/queue/useQueueProgress.test.ts index 2f9c194ade..a0d7231828 100644 --- a/src/composables/queue/useQueueProgress.test.ts +++ b/src/composables/queue/useQueueProgress.test.ts @@ -1,8 +1,7 @@ -import { mount } from '@vue/test-utils' -import type { VueWrapper } from '@vue/test-utils' +import { render } from '@testing-library/vue' import { nextTick, ref } from 'vue' import type { Ref } from 'vue' -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { useQueueProgress } from '@/composables/queue/useQueueProgress' import { formatPercent0 } from '@/utils/numberUtil' @@ -32,19 +31,16 @@ vi.mock('@/stores/executionStore', () => ({ useExecutionStore: () => createExecutionStoreMock() })) -const mountedWrappers: VueWrapper[] = [] - const mountUseQueueProgress = () => { let composable: ReturnType - const wrapper = mount({ + render({ template: '
', setup() { composable = useQueueProgress() return {} } }) - mountedWrappers.push(wrapper) - return { wrapper, composable: composable! } + return { composable: composable! } } const setExecutionProgress = (value?: number | null) => { @@ -62,10 +58,6 @@ describe('useQueueProgress', () => { setExecutingNodeProgress(null) }) - afterEach(() => { - mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount()) - }) - it.each([ { description: 'defaults to 0% when execution store values are missing', diff --git a/src/composables/useAppMode.ts b/src/composables/useAppMode.ts index 8f9ff0d9c3..e589e7c4ef 100644 --- a/src/composables/useAppMode.ts +++ b/src/composables/useAppMode.ts @@ -37,8 +37,6 @@ export function useAppMode() { ) function setMode(newMode: AppMode) { - if (newMode === mode.value) return - const workflow = workflowStore.activeWorkflow if (workflow) workflow.activeMode = newMode } diff --git a/src/composables/useBrowserTabTitle.test.ts b/src/composables/useBrowserTabTitle.test.ts index ec0ce59d57..0332923629 100644 --- a/src/composables/useBrowserTabTitle.test.ts +++ b/src/composables/useBrowserTabTitle.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { effectScope, nextTick, reactive } from 'vue' -import type { EffectScope } from 'vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' @@ -88,7 +87,7 @@ describe('useBrowserTabTitle', () => { }) it('sets default title when idle and no workflow', () => { - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) expect(document.title).toBe('ComfyUI') scope.stop() @@ -101,7 +100,7 @@ describe('useBrowserTabTitle', () => { isModified: false, isPersisted: true } - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('myFlow - ComfyUI') @@ -115,7 +114,7 @@ describe('useBrowserTabTitle', () => { isModified: true, isPersisted: true } - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('*myFlow - ComfyUI') @@ -133,9 +132,11 @@ describe('useBrowserTabTitle', () => { isModified: true, isPersisted: true } - useBrowserTabTitle() + const scope = effectScope() + scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('myFlow - ComfyUI') + scope.stop() }) it('hides asterisk while Shift key is held', async () => { @@ -150,21 +151,21 @@ describe('useBrowserTabTitle', () => { isModified: true, isPersisted: true } - useBrowserTabTitle() + const scope = effectScope() + scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('myFlow - ComfyUI') + scope.stop() }) - // Fails when run together with other tests. Suspect to be caused by leaked - // state from previous tests. - it.skip('disables workflow title when menu disabled', async () => { + it('disables workflow title when menu disabled', async () => { vi.mocked(settingStore.get).mockReturnValue('Disabled') workflowStore.activeWorkflow = { filename: 'myFlow', isModified: false, isPersisted: true } - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('ComfyUI') @@ -174,7 +175,7 @@ describe('useBrowserTabTitle', () => { it('shows execution progress when not idle without workflow', async () => { executionStore.isIdle = false executionStore.executionProgress = 0.3 - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('[30%]ComfyUI') @@ -196,7 +197,7 @@ describe('useBrowserTabTitle', () => { } } } - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('[40%][50%] Foo') @@ -216,7 +217,7 @@ describe('useBrowserTabTitle', () => { }, '2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' } } - const scope: EffectScope = effectScope() + const scope = effectScope() scope.run(() => useBrowserTabTitle()) await nextTick() expect(document.title).toBe('[40%][2 nodes running]') diff --git a/src/composables/useContextMenuTranslation.ts b/src/composables/useContextMenuTranslation.ts index 0c69343f55..7b031d7ee2 100644 --- a/src/composables/useContextMenuTranslation.ts +++ b/src/composables/useContextMenuTranslation.ts @@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => { } // for capture translation text of input and widget - const extraInfo = (options.extra || options.parentMenu?.options?.extra) as + const extraInfo = (options.extra || + options.parentMenu?.options?.extra) as | { inputs?: INodeInputSlot[]; widgets?: IWidget[] } | undefined // widgets and inputs diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index 84127747ad..0f8589cc59 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({ })) const mockChangeTracker = vi.hoisted(() => ({ - checkState: vi.fn() + captureCanvasState: vi.fn() })) const mockWorkflowStore = vi.hoisted(() => ({ activeWorkflow: { @@ -382,7 +382,7 @@ describe('useCoreCommands', () => { expect(mockDialogService.prompt).toHaveBeenCalled() expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description') - expect(mockChangeTracker.checkState).toHaveBeenCalled() + expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled() }) it('should not set description when user cancels', async () => { @@ -397,7 +397,7 @@ describe('useCoreCommands', () => { await setDescCommand.function() expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined() - expect(mockChangeTracker.checkState).not.toHaveBeenCalled() + expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled() }) }) @@ -432,7 +432,7 @@ describe('useCoreCommands', () => { 'alias2', 'alias3' ]) - expect(mockChangeTracker.checkState).toHaveBeenCalled() + expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled() }) it('should trim whitespace and filter empty strings', async () => { @@ -478,7 +478,7 @@ describe('useCoreCommands', () => { await setAliasesCommand.function() expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined() - expect(mockChangeTracker.checkState).not.toHaveBeenCalled() + expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled() }) }) }) diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 1f13d65522..9eb5b9a3ac 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] { if (description === null) return extra.BlueprintDescription = description.trim() || undefined - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } }, { @@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] { } extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined - workflowStore.activeWorkflow?.changeTracker?.checkState() + workflowStore.activeWorkflow?.changeTracker?.captureCanvasState() } }, { diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 48de0a2347..153ec60a98 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ default: { splitFilePath: vi.fn(), getResourceURL: vi.fn(), - uploadFile: vi.fn() + uploadFile: vi.fn(), + mapSceneLightIntensityToHdri: vi.fn( + (scene: number, min: number, max: number) => { + const span = max - min + const t = span > 0 ? (scene - min) / span : 0 + const clampedT = Math.min(1, Math.max(0, t)) + const mapped = clampedT * 5 + const minHdri = 0.25 + return Math.min(5, Math.max(minHdri, mapped)) + } + ) } })) @@ -72,7 +82,13 @@ describe('useLoad3d', () => { state: null }, 'Light Config': { - intensity: 5 + intensity: 5, + hdri: { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 + } }, 'Resource Folder': '' }, @@ -122,6 +138,11 @@ describe('useLoad3d', () => { isPlyModel: vi.fn().mockReturnValue(false), hasSkeleton: vi.fn().mockReturnValue(false), setShowSkeleton: vi.fn(), + loadHDRI: vi.fn().mockResolvedValue(undefined), + setHDRIEnabled: vi.fn(), + setHDRIAsBackground: vi.fn(), + setHDRIIntensity: vi.fn(), + clearHDRI: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), remove: vi.fn(), @@ -167,7 +188,13 @@ describe('useLoad3d', () => { fov: 75 }) expect(composable.lightConfig.value).toEqual({ - intensity: 5 + intensity: 5, + hdri: { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 + } }) expect(composable.isRecording.value).toBe(false) expect(composable.hasRecording.value).toBe(false) @@ -476,7 +503,7 @@ describe('useLoad3d', () => { await nextTick() expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10) - expect(mockNode.properties['Light Config']).toEqual({ + expect(mockNode.properties['Light Config']).toMatchObject({ intensity: 10 }) }) @@ -912,6 +939,97 @@ describe('useLoad3d', () => { }) }) + describe('hdri controls', () => { + it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + composable.lightConfig.value = { + ...composable.lightConfig.value, + hdri: { ...composable.lightConfig.value.hdri!, enabled: true } + } + await nextTick() + + expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true) + }) + + it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + composable.lightConfig.value = { + ...composable.lightConfig.value, + hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true } + } + await nextTick() + + expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true) + }) + + it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + composable.lightConfig.value = { + ...composable.lightConfig.value, + hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 } + } + await nextTick() + + expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5) + }) + + it('should upload file, load HDRI and update hdriConfig', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr') + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( + '/view?filename=env.hdr' + ) + vi.mocked(api.apiURL).mockReturnValue( + 'http://localhost/view?filename=env.hdr' + ) + + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + const file = new File([''], 'env.hdr', { type: 'image/x-hdr' }) + await composable.handleHDRIFileUpdate(file) + + expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d') + expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith( + 'http://localhost/view?filename=env.hdr' + ) + expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr') + expect(composable.lightConfig.value.hdri!.enabled).toBe(true) + }) + + it('should clear HDRI when file is null', async () => { + const composable = useLoad3d(mockNode) + const containerRef = document.createElement('div') + await composable.initializeLoad3d(containerRef) + + composable.lightConfig.value = { + ...composable.lightConfig.value, + hdri: { + enabled: true, + hdriPath: '3d/env.hdr', + showAsBackground: true, + intensity: 1 + } + } + + await composable.handleHDRIFileUpdate(null) + + expect(mockLoad3d.clearHDRI).toHaveBeenCalled() + expect(composable.lightConfig.value.hdri!.hdriPath).toBe('') + expect(composable.lightConfig.value.hdri!.enabled).toBe(false) + }) + }) + describe('edge cases', () => { it('should handle null node ref', () => { const nodeRef = ref(null) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 8c325f0189..23ffafcdb2 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -1,6 +1,7 @@ import type { MaybeRef } from 'vue' import { toRef } from '@vueuse/core' +import { getActivePinia } from 'pinia' import { nextTick, ref, toRaw, watch } from 'vue' import Load3d from '@/extensions/core/load3d/Load3d' @@ -24,6 +25,7 @@ import type { import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import { useSettingStore } from '@/platform/settings/settingStore' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { }) const lightConfig = ref({ - intensity: 5 + intensity: 5, + hdri: { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 + } }) + const lastNonHdriLightIntensity = ref(lightConfig.value.intensity) const isRecording = ref(false) const hasRecording = ref(false) @@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } const savedLightConfig = node.properties['Light Config'] as LightConfig + const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false if (savedLightConfig) { - lightConfig.value = savedLightConfig + lightConfig.value = { + intensity: savedLightConfig.intensity ?? lightConfig.value.intensity, + hdri: { + ...lightConfig.value.hdri!, + ...savedLightConfig.hdri, + enabled: false + } + } + lastNonHdriLightIntensity.value = lightConfig.value.intensity + } + + const hdri = lightConfig.value.hdri + let hdriLoaded = false + if (hdri?.hdriPath) { + const hdriUrl = api.apiURL( + Load3dUtils.getResourceURL( + ...Load3dUtils.splitFilePath(hdri.hdriPath), + 'input' + ) + ) + try { + await load3d.loadHDRI(hdriUrl) + hdriLoaded = true + } catch (error) { + console.warn('Failed to restore HDRI:', error) + lightConfig.value = { + ...lightConfig.value, + hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false } + } + } + } + + if (hdriLoaded && savedHdriEnabled) { + lightConfig.value = { + ...lightConfig.value, + hdri: { ...lightConfig.value.hdri!, enabled: true } + } } const modelWidget = node.widgets?.find((w) => w.name === 'model_file') @@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } else if (cameraStateToRestore) { load3d.setCameraState(cameraStateToRestore) } + + applySceneConfigToLoad3d() + applyLightConfigToLoad3d() + } + + const applySceneConfigToLoad3d = () => { + if (!load3d) return + const cfg = sceneConfig.value + load3d.toggleGrid(cfg.showGrid) + if (!lightConfig.value.hdri?.enabled) { + load3d.setBackgroundColor(cfg.backgroundColor) + } + if (cfg.backgroundRenderMode) { + load3d.setBackgroundRenderMode(cfg.backgroundRenderMode) + } + } + + const applyLightConfigToLoad3d = () => { + if (!load3d) return + const cfg = lightConfig.value + load3d.setLightIntensity(cfg.intensity) + const hdri = cfg.hdri + if (!hdri) return + load3d.setHDRIIntensity(hdri.intensity) + load3d.setHDRIAsBackground(hdri.showAsBackground) + load3d.setHDRIEnabled(hdri.enabled) + } + + const persistLightConfigToNode = () => { + const n = nodeRef.value + if (n) { + n.properties['Light Config'] = lightConfig.value + } } const getModelUrl = (modelPath: string): string | null => { @@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { watch( sceneConfig, - async (newValue) => { - if (load3d && nodeRef.value) { + (newValue) => { + if (nodeRef.value) { nodeRef.value.properties['Scene Config'] = newValue - load3d.toggleGrid(newValue.showGrid) - load3d.setBackgroundColor(newValue.backgroundColor) - - await load3d.setBackgroundImage(newValue.backgroundImage || '') - - if (newValue.backgroundRenderMode) { - load3d.setBackgroundRenderMode(newValue.backgroundRenderMode) - } } }, { deep: true } ) + watch( + () => sceneConfig.value.showGrid, + (showGrid) => { + load3d?.toggleGrid(showGrid) + } + ) + + watch( + () => sceneConfig.value.backgroundColor, + (color) => { + if (!load3d || lightConfig.value.hdri?.enabled) return + load3d.setBackgroundColor(color) + } + ) + + watch( + () => sceneConfig.value.backgroundImage, + async (image) => { + if (!load3d) return + await load3d.setBackgroundImage(image || '') + } + ) + + watch( + () => sceneConfig.value.backgroundRenderMode, + (mode) => { + if (mode) load3d?.setBackgroundRenderMode(mode) + } + ) + watch( modelConfig, (newValue) => { @@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { ) watch( - lightConfig, - (newValue) => { - if (load3d && nodeRef.value) { - nodeRef.value.properties['Light Config'] = newValue - load3d.setLightIntensity(newValue.intensity) + () => lightConfig.value.intensity, + (intensity) => { + if (!load3d || !nodeRef.value) return + if (!lightConfig.value.hdri?.enabled) { + lastNonHdriLightIntensity.value = intensity } - }, - { deep: true } + persistLightConfigToNode() + load3d.setLightIntensity(intensity) + } + ) + + watch( + () => lightConfig.value.hdri?.intensity, + (intensity) => { + if (!load3d || !nodeRef.value) return + if (intensity === undefined) return + persistLightConfigToNode() + load3d.setHDRIIntensity(intensity) + } + ) + + watch( + () => lightConfig.value.hdri?.showAsBackground, + (show) => { + if (!load3d || !nodeRef.value) return + if (show === undefined) return + persistLightConfigToNode() + load3d.setHDRIAsBackground(show) + } + ) + + watch( + () => lightConfig.value.hdri?.enabled, + (enabled, prevEnabled) => { + if (!load3d || !nodeRef.value) return + if (enabled === undefined) return + if (enabled && prevEnabled === false) { + lastNonHdriLightIntensity.value = lightConfig.value.intensity + } + if (!enabled && prevEnabled === true) { + lightConfig.value = { + ...lightConfig.value, + intensity: lastNonHdriLightIntensity.value + } + } + persistLightConfigToNode() + load3d.setHDRIEnabled(enabled) + } ) watch(playing, (newValue) => { @@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } + const handleHDRIFileUpdate = async (file: File | null) => { + const capturedLoad3d = load3d + if (!capturedLoad3d) return + + if (!file) { + lightConfig.value = { + ...lightConfig.value, + hdri: { + ...lightConfig.value.hdri!, + hdriPath: '', + enabled: false, + showAsBackground: false + } + } + capturedLoad3d.clearHDRI() + return + } + + const resourceFolder = + (nodeRef.value?.properties['Resource Folder'] as string) || '' + + const subfolder = resourceFolder.trim() + ? `3d/${resourceFolder.trim()}` + : '3d' + + const uploadedPath = await Load3dUtils.uploadFile(file, subfolder) + if (!uploadedPath) { + return + } + + // Re-validate: node may have been removed during upload + if (load3d !== capturedLoad3d) return + + const hdriUrl = api.apiURL( + Load3dUtils.getResourceURL( + ...Load3dUtils.splitFilePath(uploadedPath), + 'input' + ) + ) + + try { + loading.value = true + loadingMessage.value = t('load3d.loadingHDRI') + await capturedLoad3d.loadHDRI(hdriUrl) + + if (load3d !== capturedLoad3d) return + + let sceneMin = 1 + let sceneMax = 10 + if (getActivePinia() != null) { + const settingStore = useSettingStore() + sceneMin = settingStore.get( + 'Comfy.Load3D.LightIntensityMinimum' + ) as number + sceneMax = settingStore.get( + 'Comfy.Load3D.LightIntensityMaximum' + ) as number + } + const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri( + lightConfig.value.intensity, + sceneMin, + sceneMax + ) + lightConfig.value = { + ...lightConfig.value, + hdri: { + ...lightConfig.value.hdri!, + hdriPath: uploadedPath, + enabled: true, + showAsBackground: true, + intensity: mappedHdriIntensity + } + } + } catch (error) { + console.error('Failed to load HDRI:', error) + capturedLoad3d.clearHDRI() + lightConfig.value = { + ...lightConfig.value, + hdri: { + ...lightConfig.value.hdri!, + hdriPath: '', + enabled: false, + showAsBackground: false + } + } + useToastStore().addAlert(t('toastMessages.failedToLoadHDRI')) + } finally { + loading.value = false + loadingMessage.value = '' + } + } + const handleBackgroundImageUpdate = async (file: File | null) => { if (!file) { sceneConfig.value.backgroundImage = '' @@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { handleClearRecording, handleSeek, handleBackgroundImageUpdate, + handleHDRIFileUpdate, handleExportModel, handleModelDrop, cleanup diff --git a/src/composables/useLoad3dViewer.test.ts b/src/composables/useLoad3dViewer.test.ts index 131d267fdd..5b8f424300 100644 --- a/src/composables/useLoad3dViewer.test.ts +++ b/src/composables/useLoad3dViewer.test.ts @@ -430,6 +430,17 @@ describe('useLoad3dViewer', () => { expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false) }) + + it('should sync hover state when mouseenter fires before init', async () => { + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + + viewer.handleMouseEnter() + + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true) + }) }) describe('restoreInitialState', () => { diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index aed54c4895..301b0bd2a9 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -86,6 +86,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { let load3d: Load3d | null = null let sourceLoad3d: Load3d | null = null let currentModelUrl: string | null = null + let mouseOnViewer = false const initialState = ref({ backgroundColor: '#282828', @@ -304,6 +305,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isViewerMode: hasTargetDimensions }) + if (mouseOnViewer) { + load3d.updateStatusMouseOnViewer(true) + } + await useLoad3dService().copyLoad3dState(source, load3d) const sourceCameraState = source.getCameraState() @@ -416,6 +421,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isViewerMode: true }) + if (mouseOnViewer) { + load3d.updateStatusMouseOnViewer(true) + } + await load3d.loadModel(modelUrl) currentModelUrl = modelUrl restoreStandaloneConfig(modelUrl) @@ -522,6 +531,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { * Notifies the viewer that the mouse has entered the viewer area. */ const handleMouseEnter = () => { + mouseOnViewer = true load3d?.updateStatusMouseOnViewer(true) } @@ -529,6 +539,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { * Notifies the viewer that the mouse has left the viewer area. */ const handleMouseLeave = () => { + mouseOnViewer = false load3d?.updateStatusMouseOnViewer(false) } @@ -727,6 +738,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { if (isStandaloneMode.value) { saveStandaloneConfig() } + mouseOnViewer = false load3d?.remove() load3d = null sourceLoad3d = null diff --git a/src/composables/useNodeHelpContent.test.ts b/src/composables/useNodeHelpContent.test.ts index 02b3d102f9..2ce7559da0 100644 --- a/src/composables/useNodeHelpContent.test.ts +++ b/src/composables/useNodeHelpContent.test.ts @@ -1,10 +1,13 @@ -import { flushPromises } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' import { useNodeHelpContent } from '@/composables/useNodeHelpContent' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' +async function flushPromises() { + await new Promise((r) => setTimeout(r, 0)) +} + function createMockNode( overrides: Partial ): ComfyNodeDefImpl { diff --git a/src/composables/useRangeEditor.ts b/src/composables/useRangeEditor.ts new file mode 100644 index 0000000000..6ee483ad8a --- /dev/null +++ b/src/composables/useRangeEditor.ts @@ -0,0 +1,113 @@ +import { onBeforeUnmount, ref } from 'vue' +import type { Ref } from 'vue' + +import { clamp } from 'es-toolkit' + +import { denormalize, normalize } from '@/utils/mathUtil' +import type { RangeValue } from '@/lib/litegraph/src/types/widgets' + +type HandleType = 'min' | 'max' | 'midpoint' + +interface UseRangeEditorOptions { + trackRef: Ref + modelValue: Ref + valueMin: Ref + valueMax: Ref + showMidpoint: Ref +} + +export function useRangeEditor({ + trackRef, + modelValue, + valueMin, + valueMax, + showMidpoint +}: UseRangeEditorOptions) { + const activeHandle = ref(null) + let cleanupDrag: (() => void) | null = null + + function pointerToValue(e: PointerEvent): number { + const el = trackRef.value + if (!el) return valueMin.value + const rect = el.getBoundingClientRect() + const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1) + return denormalize(normalized, valueMin.value, valueMax.value) + } + + function nearestHandle(value: number): HandleType { + const { min, max, midpoint } = modelValue.value + const dMin = Math.abs(value - min) + const dMax = Math.abs(value - max) + let best: HandleType = dMin <= dMax ? 'min' : 'max' + const bestDist = Math.min(dMin, dMax) + if (midpoint !== undefined && showMidpoint.value) { + const midAbs = min + midpoint * (max - min) + if (Math.abs(value - midAbs) < bestDist) { + best = 'midpoint' + } + } + return best + } + + function updateValue(handle: HandleType, value: number) { + const current = modelValue.value + const clamped = clamp(value, valueMin.value, valueMax.value) + + if (handle === 'min') { + modelValue.value = { ...current, min: Math.min(clamped, current.max) } + } else if (handle === 'max') { + modelValue.value = { ...current, max: Math.max(clamped, current.min) } + } else { + const range = current.max - current.min + const midNorm = + range > 0 ? normalize(clamped, current.min, current.max) : 0 + const midpoint = clamp(midNorm, 0, 1) + modelValue.value = { ...current, midpoint } + } + } + + function handleTrackPointerDown(e: PointerEvent) { + if (e.button !== 0) return + startDrag(nearestHandle(pointerToValue(e)), e) + } + + function startDrag(handle: HandleType, e: PointerEvent) { + if (e.button !== 0) return + cleanupDrag?.() + + activeHandle.value = handle + const el = trackRef.value + if (!el) return + + el.setPointerCapture(e.pointerId) + + const onMove = (ev: PointerEvent) => { + if (!activeHandle.value) return + updateValue(activeHandle.value, pointerToValue(ev)) + } + + const endDrag = () => { + if (!activeHandle.value) return + activeHandle.value = null + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerup', endDrag) + el.removeEventListener('lostpointercapture', endDrag) + cleanupDrag = null + } + + cleanupDrag = endDrag + + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerup', endDrag) + el.addEventListener('lostpointercapture', endDrag) + } + + onBeforeUnmount(() => { + cleanupDrag?.() + }) + + return { + handleTrackPointerDown, + startDrag + } +} diff --git a/src/composables/useReconnectingNotification.test.ts b/src/composables/useReconnectingNotification.test.ts new file mode 100644 index 0000000000..d8485231a2 --- /dev/null +++ b/src/composables/useReconnectingNotification.test.ts @@ -0,0 +1,138 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useReconnectingNotification } from '@/composables/useReconnectingNotification' + +const mockToastAdd = vi.fn() +const mockToastRemove = vi.fn() + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ + add: mockToastAdd, + remove: mockToastRemove + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +const settingMocks = vi.hoisted(() => ({ + disableToast: false +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: vi.fn(() => ({ + get: vi.fn((key: string) => { + if (key === 'Comfy.Toast.DisableReconnectingToast') + return settingMocks.disableToast + return undefined + }) + })) +})) + +describe('useReconnectingNotification', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.useFakeTimers() + vi.clearAllMocks() + settingMocks.disableToast = false + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not show toast immediately on reconnecting', () => { + const { onReconnecting } = useReconnectingNotification() + + onReconnecting() + + expect(mockToastAdd).not.toHaveBeenCalled() + }) + + it('shows error toast after delay', () => { + const { onReconnecting } = useReconnectingNotification() + + onReconnecting() + vi.advanceTimersByTime(1500) + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + summary: 'g.reconnecting' + }) + ) + }) + + it('suppresses toast when reconnected before delay expires', () => { + const { onReconnecting, onReconnected } = useReconnectingNotification() + + onReconnecting() + vi.advanceTimersByTime(500) + onReconnected() + vi.advanceTimersByTime(1500) + + expect(mockToastAdd).not.toHaveBeenCalled() + expect(mockToastRemove).not.toHaveBeenCalled() + }) + + it('removes toast and shows success when reconnected after delay', () => { + const { onReconnecting, onReconnected } = useReconnectingNotification() + + onReconnecting() + vi.advanceTimersByTime(1500) + mockToastAdd.mockClear() + + onReconnected() + + expect(mockToastRemove).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + summary: 'g.reconnecting' + }) + ) + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success', + summary: 'g.reconnected', + life: 2000 + }) + ) + }) + + it('does nothing when toast is disabled via setting', () => { + settingMocks.disableToast = true + const { onReconnecting, onReconnected } = useReconnectingNotification() + + onReconnecting() + vi.advanceTimersByTime(1500) + onReconnected() + + expect(mockToastAdd).not.toHaveBeenCalled() + expect(mockToastRemove).not.toHaveBeenCalled() + }) + + it('does nothing when onReconnected is called without prior onReconnecting', () => { + const { onReconnected } = useReconnectingNotification() + + onReconnected() + + expect(mockToastAdd).not.toHaveBeenCalled() + expect(mockToastRemove).not.toHaveBeenCalled() + }) + + it('handles multiple reconnecting events without duplicating toasts', () => { + const { onReconnecting } = useReconnectingNotification() + + onReconnecting() + vi.advanceTimersByTime(1500) // first toast fires + onReconnecting() // second reconnecting event + vi.advanceTimersByTime(1500) // second toast fires + + expect(mockToastAdd).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/composables/useReconnectingNotification.ts b/src/composables/useReconnectingNotification.ts new file mode 100644 index 0000000000..605bf3bf1c --- /dev/null +++ b/src/composables/useReconnectingNotification.ts @@ -0,0 +1,52 @@ +import { useTimeoutFn } from '@vueuse/core' +import type { ToastMessageOptions } from 'primevue/toast' +import { useToast } from 'primevue/usetoast' +import { ref } from 'vue' +import { useI18n } from 'vue-i18n' + +import { useSettingStore } from '@/platform/settings/settingStore' + +const RECONNECT_TOAST_DELAY_MS = 1500 + +export function useReconnectingNotification() { + const { t } = useI18n() + const toast = useToast() + const settingStore = useSettingStore() + + const reconnectingMessage: ToastMessageOptions = { + severity: 'error', + summary: t('g.reconnecting') + } + + const reconnectingToastShown = ref(false) + + const { start, stop } = useTimeoutFn( + () => { + toast.add(reconnectingMessage) + reconnectingToastShown.value = true + }, + RECONNECT_TOAST_DELAY_MS, + { immediate: false } + ) + + function onReconnecting() { + if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return + start() + } + + function onReconnected() { + stop() + + if (reconnectingToastShown.value) { + toast.remove(reconnectingMessage) + toast.add({ + severity: 'success', + summary: t('g.reconnected'), + life: 2000 + }) + reconnectingToastShown.value = false + } + } + + return { onReconnecting, onReconnected } +} diff --git a/src/composables/useServerLogs.test.ts b/src/composables/useServerLogs.test.ts index c056b6f3a0..afa347a1c3 100644 --- a/src/composables/useServerLogs.test.ts +++ b/src/composables/useServerLogs.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { useEventListener } from '@vueuse/core' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -79,10 +80,10 @@ describe('useServerLogs', () => { // Simulate receiving a log event const mockEvent = new CustomEvent('logs', { - detail: { + detail: fromAny({ type: 'logs', entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }] - } as unknown as LogsWsMessage + }) }) as CustomEvent eventCallback(mockEvent) @@ -103,14 +104,14 @@ describe('useServerLogs', () => { ) => void const mockEvent = new CustomEvent('logs', { - detail: { + detail: fromAny({ type: 'logs', entries: [ { m: 'Log message 1 dont remove me' }, { m: 'remove me' }, { m: '' } ] - } as unknown as LogsWsMessage + }) }) as CustomEvent eventCallback(mockEvent) diff --git a/src/composables/useWaveAudioPlayer.test.ts b/src/composables/useWaveAudioPlayer.test.ts index e84b73b774..f77e8dc440 100644 --- a/src/composables/useWaveAudioPlayer.test.ts +++ b/src/composables/useWaveAudioPlayer.test.ts @@ -1,3 +1,4 @@ +import { fromAny } from '@total-typescript/shoehorn' import { ref } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -80,10 +81,12 @@ describe('useWaveAudioPlayer', () => { const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer)) const mockClose = vi.fn().mockResolvedValue(undefined) - globalThis.AudioContext = class { - decodeAudioData = mockDecodeAudioData - close = mockClose - } as unknown as typeof AudioContext + globalThis.AudioContext = fromAny( + class { + decodeAudioData = mockDecodeAudioData + close = mockClose + } + ) mockFetchApi.mockResolvedValue({ ok: true, diff --git a/src/core/graph/subgraph/matchPromotedInput.test.ts b/src/core/graph/subgraph/matchPromotedInput.test.ts index 82787f4057..612c47cf86 100644 --- a/src/core/graph/subgraph/matchPromotedInput.test.ts +++ b/src/core/graph/subgraph/matchPromotedInput.test.ts @@ -1,7 +1,7 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it } from 'vitest' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' - import { matchPromotedInput } from './matchPromotedInput' type MockInput = { @@ -31,10 +31,12 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [aliasInput, exactInput] as unknown as Array<{ - name: string - _widget?: IBaseWidget - }>, + fromPartial< + Array<{ + name: string + _widget?: IBaseWidget + }> + >([aliasInput, exactInput]), targetWidget ) @@ -48,7 +50,7 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>, + fromPartial>([aliasInput]), targetWidget ) @@ -65,10 +67,12 @@ describe(matchPromotedInput, () => { } const matched = matchPromotedInput( - [firstAliasInput, secondAliasInput] as unknown as Array<{ - name: string - _widget?: IBaseWidget - }>, + fromPartial< + Array<{ + name: string + _widget?: IBaseWidget + }> + >([firstAliasInput, secondAliasInput]), targetWidget ) diff --git a/src/core/graph/subgraph/promotedWidgetTypes.ts b/src/core/graph/subgraph/promotedWidgetTypes.ts index 69f28879d9..a1e52f6a5e 100644 --- a/src/core/graph/subgraph/promotedWidgetTypes.ts +++ b/src/core/graph/subgraph/promotedWidgetTypes.ts @@ -24,6 +24,8 @@ export interface PromotedWidgetView extends IBaseWidget { * origin. */ readonly disambiguatingSourceNodeId?: string + /** Whether the resolved source widget is workflow-persistent. */ + readonly sourceSerialize: boolean } export function isPromotedWidgetView( diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 75d471ab67..d80ebeeb3d 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -1,6 +1,7 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { fromAny, fromPartial } from '@total-typescript/shoehorn' // Barrel import must come first to avoid circular dependency // (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel) @@ -97,11 +98,12 @@ function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] { } function callSyncPromotions(node: SubgraphNode) { - ;( - node as unknown as { + fromAny< + { _syncPromotions: () => void - } - )._syncPromotions() + }, + unknown + >(node)._syncPromotions() } describe(createPromotedWidgetView, () => { @@ -156,7 +158,9 @@ describe(createPromotedWidgetView, () => { const [subgraphNode] = setupSubgraph() const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget') // node is defined via Object.defineProperty at runtime but not on the TS interface - expect((view as unknown as Record).node).toBe(subgraphNode) + expect(fromAny, unknown>(view).node).toBe( + subgraphNode + ) }) test('serialize is false', () => { @@ -289,7 +293,7 @@ describe(createPromotedWidgetView, () => { value: 'initial', options: {} } satisfies Pick - const fallbackWidget = fallbackWidgetShape as unknown as IBaseWidget + const fallbackWidget = fromPartial(fallbackWidgetShape) innerNode.widgets = [fallbackWidget] const widgetValueStore = useWidgetValueStore() @@ -398,13 +402,13 @@ describe(createPromotedWidgetView, () => { subgraphNode.pos = [10, 20] const innerNode = firstInnerNode(innerNodes) const mouse = vi.fn(() => true) - const legacyWidget = { + const legacyWidget = fromAny({ name: 'legacyMouse', type: 'mystery-legacy', value: 'val', options: {}, mouse - } as unknown as IBaseWidget + }) innerNode.widgets = [legacyWidget] const view = createPromotedWidgetView( @@ -1448,17 +1452,20 @@ describe('widgets getter caching', () => { subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas const reconcileSpy = vi.spyOn( - subgraphNode as unknown as { - _buildPromotionReconcileState: ( - entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, - linkedEntries: Array<{ - inputName: string - inputKey: string - sourceNodeId: string - sourceWidgetName: string - }> - ) => unknown - }, + fromAny< + { + _buildPromotionReconcileState: ( + entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + sourceNodeId: string + sourceWidgetName: string + }> + ) => unknown + }, + unknown + >(subgraphNode), '_buildPromotionReconcileState' ) @@ -1478,17 +1485,20 @@ describe('widgets getter caching', () => { subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas const reconcileSpy = vi.spyOn( - subgraphNode as unknown as { - _buildPromotionReconcileState: ( - entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, - linkedEntries: Array<{ - inputName: string - inputKey: string - sourceNodeId: string - sourceWidgetName: string - }> - ) => unknown - }, + fromAny< + { + _buildPromotionReconcileState: ( + entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>, + linkedEntries: Array<{ + inputName: string + inputKey: string + sourceNodeId: string + sourceWidgetName: string + }> + ) => unknown + }, + unknown + >(subgraphNode), '_buildPromotionReconcileState' ) @@ -1522,9 +1532,14 @@ describe('widgets getter caching', () => { subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB) const resolveSpy = vi.spyOn( - subgraphNode as unknown as { - _resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown - }, + fromAny< + { + _resolveLinkedPromotionBySubgraphInput: ( + ...args: unknown[] + ) => unknown + }, + unknown + >(subgraphNode), '_resolveLinkedPromotionBySubgraphInput' ) @@ -1923,32 +1938,34 @@ function createFakeCanvasContext() { function createInspectableCanvasContext(fillText = vi.fn()) { const fallback = vi.fn() - return new Proxy( - { - fillText, - beginPath: vi.fn(), - roundRect: vi.fn(), - rect: vi.fn(), - fill: vi.fn(), - stroke: vi.fn(), - moveTo: vi.fn(), - lineTo: vi.fn(), - arc: vi.fn(), - measureText: (text: string) => ({ width: text.length * 8 }), - fillStyle: '#fff', - strokeStyle: '#fff', - textAlign: 'left', - globalAlpha: 1, - lineWidth: 1 - } as Record, - { - get(target, key) { - if (typeof key === 'string' && key in target) - return target[key as keyof typeof target] - return fallback + return fromAny( + new Proxy( + { + fillText, + beginPath: vi.fn(), + roundRect: vi.fn(), + rect: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + measureText: (text: string) => ({ width: text.length * 8 }), + fillStyle: '#fff', + strokeStyle: '#fff', + textAlign: 'left', + globalAlpha: 1, + lineWidth: 1 + } as Record, + { + get(target, key) { + if (typeof key === 'string' && key in target) + return target[key as keyof typeof target] + return fallback + } } - } - ) as unknown as CanvasRenderingContext2D + ) + ) } function createTwoLevelNestedSubgraph() { diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index 610ba4cbec..f7e5ee1789 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -1,3 +1,6 @@ +import { isEqual } from 'es-toolkit' + +import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' @@ -50,6 +53,43 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget { } const designTokenCache = new Map() +const promotedSourceWriteMetaByGraph = new WeakMap< + LGraph, + Map +>() + +interface PromotedSourceWriteMeta { + value: IBaseWidget['value'] + writerInstanceId: string +} + +function cloneWidgetValue( + value: TValue +): TValue { + return value != null && typeof value === 'object' + ? (JSON.parse(JSON.stringify(value)) as TValue) + : value +} + +function getPromotedSourceWriteMeta( + graph: LGraph, + sourceKey: string +): PromotedSourceWriteMeta | undefined { + return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey) +} + +function setPromotedSourceWriteMeta( + graph: LGraph, + sourceKey: string, + meta: PromotedSourceWriteMeta +): void { + let metaBySource = promotedSourceWriteMetaByGraph.get(graph) + if (!metaBySource) { + metaBySource = new Map() + promotedSourceWriteMetaByGraph.set(graph, metaBySource) + } + metaBySource.set(sourceKey, meta) +} export function createPromotedWidgetView( subgraphNode: SubgraphNode, @@ -77,6 +117,15 @@ class PromotedWidgetView implements IPromotedWidgetView { readonly serialize = false + /** + * Whether the resolved source widget is workflow-persistent. + * Used by SubgraphNode.serialize to skip preview/audio/video widgets + * whose source sets serialize = false. + */ + get sourceSerialize(): boolean { + return this.resolveDeepest()?.widget.serialize !== false + } + last_y?: number computedHeight?: number @@ -149,13 +198,52 @@ class PromotedWidgetView implements IPromotedWidgetView { return this.resolveDeepest()?.widget.linkedWidgets } + private get _instanceKey(): string { + return this.disambiguatingSourceNodeId + ? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}` + : `${this.sourceNodeId}:${this.sourceWidgetName}` + } + + private get _sharedSourceKey(): string { + return this.disambiguatingSourceNodeId + ? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}` + : `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}` + } + get value(): IBaseWidget['value'] { - const state = this.getWidgetState() - if (state && isWidgetValue(state.value)) return state.value - return this.resolveAtHost()?.widget.value + return this.getTrackedValue() + } + + /** + * Execution-time serialization — returns the per-instance value stored + * during configure, falling back to the regular value getter. + * + * The widget state store is shared across instances (keyed by inner node + * ID), so the regular getter returns the last-configured value for all + * instances. graphToPrompt already prefers serializeValue over .value, + * so this is the hook that makes multi-instance execution correct. + */ + serializeValue(): IBaseWidget['value'] { + return this.getTrackedValue() } set value(value: IBaseWidget['value']) { + this.captureSiblingFallbackValues() + + // Keep per-instance map in sync for execution (graphToPrompt) + this.subgraphNode._instanceWidgetValues.set( + this._instanceKey, + cloneWidgetValue(value) + ) + setPromotedSourceWriteMeta( + this.subgraphNode.rootGraph, + this._sharedSourceKey, + { + value: cloneWidgetValue(value), + writerInstanceId: String(this.subgraphNode.id) + } + ) + const linkedWidgets = this.getLinkedInputWidgets() if (linkedWidgets.length > 0) { const widgetStore = useWidgetValueStore() @@ -385,6 +473,39 @@ class PromotedWidgetView implements IPromotedWidgetView { return resolved } + private getTrackedValue(): IBaseWidget['value'] { + const instanceValue = this.subgraphNode._instanceWidgetValues.get( + this._instanceKey + ) + const sharedValue = this.getSharedValue() + + if (instanceValue === undefined) return sharedValue + + const sourceWriteMeta = getPromotedSourceWriteMeta( + this.subgraphNode.rootGraph, + this._sharedSourceKey + ) + if ( + sharedValue !== undefined && + sourceWriteMeta && + !isEqual(sharedValue, sourceWriteMeta.value) + ) { + this.subgraphNode._instanceWidgetValues.set( + this._instanceKey, + cloneWidgetValue(sharedValue) + ) + return sharedValue + } + + return instanceValue as IBaseWidget['value'] + } + + private getSharedValue(): IBaseWidget['value'] { + const state = this.getWidgetState() + if (state && isWidgetValue(state.value)) return state.value + return this.resolveAtHost()?.widget.value + } + private getWidgetState() { const linkedState = this.getLinkedInputWidgetStates()[0] if (linkedState) return linkedState @@ -451,6 +572,30 @@ class PromotedWidgetView implements IPromotedWidgetView { .filter((state): state is WidgetState => state !== undefined) } + private captureSiblingFallbackValues(): void { + const { rootGraph } = this.subgraphNode + + for (const node of rootGraph.nodes) { + if (node === this.subgraphNode || !node.isSubgraphNode()) continue + if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue + if (node._instanceWidgetValues.has(this._instanceKey)) continue + + const siblingView = node.widgets.find( + (widget): widget is IPromotedWidgetView => + isPromotedWidgetView(widget) && + widget.sourceNodeId === this.sourceNodeId && + widget.sourceWidgetName === this.sourceWidgetName && + widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId + ) + if (!siblingView) continue + + node._instanceWidgetValues.set( + this._instanceKey, + cloneWidgetValue(siblingView.value) + ) + } + } + private getProjectedWidget(resolved: { node: LGraphNode widget: IBaseWidget diff --git a/src/core/graph/subgraph/promotionUtils.test.ts b/src/core/graph/subgraph/promotionUtils.test.ts index 43da1db058..b5f4d949c7 100644 --- a/src/core/graph/subgraph/promotionUtils.test.ts +++ b/src/core/graph/subgraph/promotionUtils.test.ts @@ -1,13 +1,14 @@ import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { LGraphNode } from '@/lib/litegraph/src/litegraph' import { createTestSubgraph, createTestSubgraphNode } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { usePromotionStore } from '@/stores/promotionStore' const updatePreviewsMock = vi.hoisted(() => vi.fn()) @@ -19,6 +20,7 @@ import { CANVAS_IMAGE_PREVIEW_WIDGET, getPromotableWidgets, hasUnpromotedWidgets, + isLinkedPromotion, isPreviewPseudoWidget, promoteRecommendedWidgets, pruneDisconnected @@ -29,7 +31,7 @@ function widget( Pick > ): IBaseWidget { - return { name: 'widget', ...overrides } as unknown as IBaseWidget + return fromPartial({ name: 'widget', ...overrides }) } describe('isPreviewPseudoWidget', () => { @@ -333,3 +335,84 @@ describe('hasUnpromotedWidgets', () => { expect(hasUnpromotedWidgets(subgraphNode)).toBe(false) }) }) + +describe('isLinkedPromotion', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + function linkedWidget( + sourceNodeId: string, + sourceWidgetName: string, + extra: Record = {} + ): IBaseWidget { + return { + sourceNodeId, + sourceWidgetName, + name: 'value', + type: 'text', + value: '', + options: {}, + y: 0, + ...extra + } as unknown as IBaseWidget + } + + function createSubgraphWithInputs(count = 1) { + const subgraph = createTestSubgraph({ + inputs: Array.from({ length: count }, (_, i) => ({ + name: `input_${i}`, + type: 'STRING' as const + })) + }) + return createTestSubgraphNode(subgraph) + } + + it('returns true when an input has a matching _widget', () => { + const subgraphNode = createSubgraphWithInputs() + subgraphNode.inputs[0]._widget = linkedWidget('3', 'text') + + expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true) + }) + + it('returns false when no inputs exist or none match', () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + + expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false) + }) + + it('returns false when sourceNodeId matches but sourceWidgetName does not', () => { + const subgraphNode = createSubgraphWithInputs() + subgraphNode.inputs[0]._widget = linkedWidget('3', 'text') + + expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false) + }) + + it('returns false when _widget is undefined on input', () => { + const subgraphNode = createSubgraphWithInputs() + + expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false) + }) + + it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => { + const subgraphNode = createSubgraphWithInputs() + subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', { + disambiguatingSourceNodeId: '1' + }) + + expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true) + expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false) + }) + + it('identifies multiple linked widgets across different inputs', () => { + const subgraphNode = createSubgraphWithInputs(2) + subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a') + subgraphNode.inputs[1]._widget = linkedWidget('4', 'value') + + expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true) + expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true) + expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false) + expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false) + }) +}) diff --git a/src/core/graph/subgraph/promotionUtils.ts b/src/core/graph/subgraph/promotionUtils.ts index de1e53dae2..09fe1b7546 100644 --- a/src/core/graph/subgraph/promotionUtils.ts +++ b/src/core/graph/subgraph/promotionUtils.ts @@ -27,6 +27,27 @@ export function getWidgetName(w: IBaseWidget): string { return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name } +/** + * Returns true if the given promotion entry corresponds to a linked promotion + * on the subgraph node. Linked promotions are driven by subgraph input + * connections and cannot be independently hidden or shown. + */ +export function isLinkedPromotion( + subgraphNode: SubgraphNode, + sourceNodeId: string, + sourceWidgetName: string +): boolean { + return subgraphNode.inputs.some((input) => { + const w = input._widget + return ( + w && + isPromotedWidgetView(w) && + w.sourceNodeId === sourceNodeId && + w.sourceWidgetName === sourceWidgetName + ) + }) +} + export function getSourceNodeId(w: IBaseWidget): string | undefined { if (!isPromotedWidgetView(w)) return undefined return w.disambiguatingSourceNodeId ?? w.sourceNodeId @@ -39,7 +60,9 @@ function toPromotionSource( return { sourceNodeId: String(node.id), sourceWidgetName: getWidgetName(widget), - disambiguatingSourceNodeId: getSourceNodeId(widget) + disambiguatingSourceNodeId: isPromotedWidgetView(widget) + ? widget.disambiguatingSourceNodeId + : undefined } } diff --git a/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts index eb56e6ae90..f9cd77a661 100644 --- a/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts +++ b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts @@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => { expect(resolved).toBeDefined() expect( - (resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId + (resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId ).toBe('2') }) }) diff --git a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts index b4c01047c5..c68d262e9e 100644 --- a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts +++ b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromPartial } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' @@ -101,14 +102,14 @@ describe('resolveSubgraphInputLink', () => { vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => { if (typeof linkId !== 'number') return originalGetLink(linkId) if (linkId === stale.linkId) { - return { + return fromPartial>({ resolve: () => ({ inputNode: { inputs: undefined, getWidgetFromSlot: () => ({ name: 'ignored' }) } }) - } as unknown as ReturnType + }) } return originalGetLink(linkId) diff --git a/src/core/graph/subgraph/subgraphNodePromotion.test.ts b/src/core/graph/subgraph/subgraphNodePromotion.test.ts index b0203fe4b7..a6aaba7f58 100644 --- a/src/core/graph/subgraph/subgraphNodePromotion.test.ts +++ b/src/core/graph/subgraph/subgraphNodePromotion.test.ts @@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => { expect(subgraphNode.widgets).toHaveLength(0) }) - test('serialize does not produce widgets_values for promoted views', () => { + test('serialize stores widgets_values for promoted views', () => { const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) usePromotionStore().setPromotions( @@ -265,9 +265,7 @@ describe('Subgraph proxyWidgets', () => { const serialized = subgraphNode.serialize() - // SubgraphNode doesn't set serialize_widgets, so widgets_values is absent. - // Even if it were set, views have serialize: false and would be skipped. - expect(serialized.widgets_values).toBeUndefined() + expect(serialized.widgets_values).toEqual(['value']) }) test('serialize preserves proxyWidgets in properties', () => { diff --git a/src/core/graph/widgets/dynamicWidgets.test.ts b/src/core/graph/widgets/dynamicWidgets.test.ts index 41b34b4fa9..6033b8701c 100644 --- a/src/core/graph/widgets/dynamicWidgets.test.ts +++ b/src/core/graph/widgets/dynamicWidgets.test.ts @@ -143,14 +143,14 @@ describe('Autogrow', () => { test('Can add autogrow with min input count', () => { const node = testNode() addAutogrow(node, { min: 4, input: inputsSpec }) - expect(node.inputs.length).toBe(4) + expect(node.inputs.length).toBe(5) }) test('Adding connections will cause growth up to max', () => { const graph = new LGraph() const node = testNode() graph.add(node) addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test', max: 3 }) - expect(node.inputs.length).toBe(1) + expect(node.inputs.length).toBe(2) connectInput(node, 0, graph) expect(node.inputs.length).toBe(2) @@ -159,7 +159,7 @@ describe('Autogrow', () => { connectInput(node, 2, graph) expect(node.inputs.length).toBe(3) }) - test('Removing connections decreases to min', async () => { + test('Removing connections decreases to min + 1', async () => { const graph = new LGraph() const node = testNode() graph.add(node) @@ -204,9 +204,9 @@ describe('Autogrow', () => { '0.a0', '0.a1', '0.a2', - '1.b0', - '1.b1', - '1.b2', + '2.b0', + '2.b1', + '2.b2', 'aa' ]) }) diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index 6d90c2f56c..2b2e189c24 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -511,7 +511,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) { lastInput ) } - const removalChecks = groupInputs.slice((min - 1) * stride) + const removalChecks = groupInputs.slice(min * stride) let i for (i = removalChecks.length - stride; i >= 0; i -= stride) { if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break @@ -597,6 +597,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) { prefix, inputSpecs: inputsV2 } - for (let i = 0; i === 0 || i < min; i++) + for (let i = 0; i === 0 || i < min + 1; i++) addAutogrowGroup(i, inputSpecV2.name, node) } diff --git a/src/core/graph/widgets/matchTypeConfiguring.test.ts b/src/core/graph/widgets/matchTypeConfiguring.test.ts index 45179b0f52..ac5e4ba15f 100644 --- a/src/core/graph/widgets/matchTypeConfiguring.test.ts +++ b/src/core/graph/widgets/matchTypeConfiguring.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' @@ -72,8 +73,8 @@ describe('MatchType during configure', () => { const link2Id = switchNode.inputs[1].link! const outputTypeBefore = switchNode.outputs[0].type - ;( - app as unknown as { configuringGraphLevel: number } + fromAny<{ configuringGraphLevel: number }, unknown>( + app ).configuringGraphLevel = 1 try { @@ -92,8 +93,8 @@ describe('MatchType during configure', () => { expect(graph.links[link2Id]).toBeDefined() expect(switchNode.outputs[0].type).toBe(outputTypeBefore) } finally { - ;( - app as unknown as { configuringGraphLevel: number } + fromAny<{ configuringGraphLevel: number }, unknown>( + app ).configuringGraphLevel = 0 } }) diff --git a/src/extensions/core/cloudFeedbackTopbarButton.ts b/src/extensions/core/cloudFeedbackTopbarButton.ts index c90e76ad8f..f435946b5d 100644 --- a/src/extensions/core/cloudFeedbackTopbarButton.ts +++ b/src/extensions/core/cloudFeedbackTopbarButton.ts @@ -1,10 +1,9 @@ import { t } from '@/i18n' import { useSettingStore } from '@/platform/settings/settingStore' -import { buildFeedbackUrl } from '@/platform/support/config' import { useExtensionService } from '@/services/extensionService' import type { ActionBarButton } from '@/types/comfy' -const feedbackUrl = buildFeedbackUrl() +const TYPEFORM_SURVEY_URL = 'https://form.typeform.com/to/q7azbWPi' const buttons: ActionBarButton[] = [ { @@ -12,7 +11,7 @@ const buttons: ActionBarButton[] = [ label: t('actionbar.feedback'), tooltip: t('actionbar.feedbackTooltip'), onClick: () => { - window.open(feedbackUrl, '_blank', 'noopener,noreferrer') + window.open(TYPEFORM_SURVEY_URL, '_blank', 'noopener,noreferrer') } } ] diff --git a/src/extensions/core/customWidgets.clone.test.ts b/src/extensions/core/customWidgets.clone.test.ts new file mode 100644 index 0000000000..681c8db418 --- /dev/null +++ b/src/extensions/core/customWidgets.clone.test.ts @@ -0,0 +1,103 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { app } from '@/scripts/app' +import { useExtensionStore } from '@/stores/extensionStore' +import type { ComfyExtension } from '@/types/comfy' + +const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste' + +class TestCustomComboNode extends LGraphNode { + static override title = 'CustomCombo' + + constructor() { + super('CustomCombo') + this.serialize_widgets = true + this.addOutput('value', '*') + this.addWidget('combo', 'value', '', () => {}, { + values: [] as string[] + }) + } +} + +function findWidget(node: LGraphNode, name: string) { + return node.widgets?.find((widget) => widget.name === name) +} + +function getCustomWidgetsExtension(): ComfyExtension { + const extension = useExtensionStore().extensions.find( + (candidate) => candidate.name === 'Comfy.CustomWidgets' + ) + + if (!extension) { + throw new Error('Comfy.CustomWidgets extension was not registered') + } + + return extension +} + +describe('CustomCombo copy/paste', () => { + beforeAll(async () => { + setActivePinia(createTestingPinia({ stubActions: false })) + await import('./customWidgets') + + const extension = getCustomWidgetsExtension() + await extension.beforeRegisterNodeDef?.( + TestCustomComboNode, + { name: 'CustomCombo' } as ComfyNodeDef, + app + ) + + if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) { + LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE) + } + LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode) + }) + + afterAll(() => { + if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) { + LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE) + } + }) + + it('preserves combo options and selected value through clone and paste', () => { + const graph = new LGraph() + type AppWithRootGraph = { rootGraphInternal?: LGraph } + const appWithRootGraph = app as unknown as AppWithRootGraph + const previousRootGraph = appWithRootGraph.rootGraphInternal + appWithRootGraph.rootGraphInternal = graph + + try { + const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)! + graph.add(original) + + findWidget(original, 'option1')!.value = 'alpha' + findWidget(original, 'option2')!.value = 'beta' + findWidget(original, 'option3')!.value = 'gamma' + findWidget(original, 'value')!.value = 'beta' + + const clonedSerialised = original.clone()?.serialize() + + expect(clonedSerialised).toBeDefined() + + const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)! + pasted.configure(clonedSerialised!) + graph.add(pasted) + + expect(findWidget(pasted, 'value')!.value).toBe('beta') + expect(findWidget(pasted, 'option1')!.value).toBe('alpha') + expect(findWidget(pasted, 'option2')!.value).toBe('beta') + expect(findWidget(pasted, 'option3')!.value).toBe('gamma') + expect(findWidget(pasted, 'value')!.options.values).toEqual([ + 'alpha', + 'beta', + 'gamma' + ]) + } finally { + appWithRootGraph.rootGraphInternal = previousRootGraph + } + }) +}) diff --git a/src/extensions/core/customWidgets.ts b/src/extensions/core/customWidgets.ts index d0e08c6743..9db84f61fd 100644 --- a/src/extensions/core/customWidgets.ts +++ b/src/extensions/core/customWidgets.ts @@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) { (w) => w.name.startsWith('option') && w.value ).map((w) => `${w.value}`) ) - if (app.configuringGraph) return + if (app.configuringGraph || !this.graph) return if (values.includes(`${comboWidget.value}`)) return comboWidget.value = values[0] ?? '' comboWidget.callback?.(comboWidget.value) @@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) { comboWidget.callback = useChainCallback(comboWidget.callback, () => this.applyToGraph!() ) + this.onAdded = useChainCallback(this.onAdded, function () { + updateCombo() + }) function addOption(node: LGraphNode) { if (!node.widgets) return @@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) { const widgetName = `option${newCount}` const widget = node.addWidget('string', widgetName, '', () => {}) if (!widget) return + let localValue = `${widget.value ?? ''}` Object.defineProperty(widget, 'value', { get() { - return useWidgetValueStore().getWidget( - app.rootGraph.id, - node.id, - widgetName - )?.value + return ( + useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName) + ?.value ?? localValue + ) }, set(v: string) { + localValue = v const state = useWidgetValueStore().getWidget( app.rootGraph.id, node.id, diff --git a/src/extensions/core/load3d/HDRIManager.test.ts b/src/extensions/core/load3d/HDRIManager.test.ts new file mode 100644 index 0000000000..39b0fd1f71 --- /dev/null +++ b/src/extensions/core/load3d/HDRIManager.test.ts @@ -0,0 +1,223 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { HDRIManager } from './HDRIManager' +import Load3dUtils from './Load3dUtils' + +const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({ + mockFromEquirectangular: vi.fn(), + mockDisposePMREM: vi.fn() +})) + +vi.mock('./Load3dUtils', () => ({ + default: { + getFilenameExtension: vi.fn() + } +})) + +vi.mock('three', async (importOriginal) => { + const actual = await importOriginal() + class MockPMREMGenerator { + compileEquirectangularShader = vi.fn() + fromEquirectangular = mockFromEquirectangular + dispose = mockDisposePMREM + } + return { ...actual, PMREMGenerator: MockPMREMGenerator } +}) + +vi.mock('three/examples/jsm/loaders/EXRLoader', () => { + class EXRLoader { + load( + _url: string, + resolve: (t: THREE.Texture) => void, + _onProgress: undefined, + _reject: (e: unknown) => void + ) { + resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1)) + } + } + return { EXRLoader } +}) + +vi.mock('three/examples/jsm/loaders/RGBELoader', () => { + class RGBELoader { + load( + _url: string, + resolve: (t: THREE.Texture) => void, + _onProgress: undefined, + _reject: (e: unknown) => void + ) { + resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1)) + } + } + return { RGBELoader } +}) + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } +} + +describe('HDRIManager', () => { + let scene: THREE.Scene + let eventManager: ReturnType + let manager: HDRIManager + + beforeEach(() => { + vi.clearAllMocks() + scene = new THREE.Scene() + eventManager = makeMockEventManager() + + mockFromEquirectangular.mockReturnValue({ + texture: new THREE.Texture(), + dispose: vi.fn() + }) + + manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('starts disabled with default intensity', () => { + expect(manager.isEnabled).toBe(false) + expect(manager.showAsBackground).toBe(false) + expect(manager.intensity).toBe(1) + }) + }) + + describe('loadHDRI', () => { + it('loads .exr files without error', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr') + + await expect( + manager.loadHDRI('http://example.com/env.exr') + ).resolves.toBeUndefined() + }) + + it('loads .hdr files without error', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + + await expect( + manager.loadHDRI('http://example.com/env.hdr') + ).resolves.toBeUndefined() + }) + + it('applies to scene immediately when already enabled', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + manager.setEnabled(true) + // No texture loaded yet so scene.environment stays null + expect(scene.environment).toBeNull() + + await manager.loadHDRI('http://example.com/env.hdr') + + expect(scene.environment).not.toBeNull() + }) + + it('does not apply to scene when disabled', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + + await manager.loadHDRI('http://example.com/env.hdr') + + expect(scene.environment).toBeNull() + }) + }) + + describe('setEnabled', () => { + it('applies environment map to scene when enabled after loading', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + + manager.setEnabled(true) + + expect(scene.environment).not.toBeNull() + expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', { + enabled: true, + showAsBackground: false + }) + }) + + it('removes environment map from scene when disabled', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + manager.setEnabled(true) + + manager.setEnabled(false) + + expect(scene.environment).toBeNull() + expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', { + enabled: false, + showAsBackground: false + }) + }) + }) + + describe('setIntensity', () => { + it('updates scene intensity when enabled', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + manager.setEnabled(true) + + manager.setIntensity(2.5) + + expect(scene.environmentIntensity).toBe(2.5) + expect(manager.intensity).toBe(2.5) + }) + + it('stores intensity without applying when disabled', () => { + manager.setIntensity(3) + + expect(manager.intensity).toBe(3) + expect(scene.environmentIntensity).not.toBe(3) + }) + }) + + describe('setShowAsBackground', () => { + it('sets scene background texture when enabled and showing as background', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + manager.setEnabled(true) + + manager.setShowAsBackground(true) + + expect(scene.background).not.toBeNull() + }) + + it('clears scene background when showAsBackground is false', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + manager.setEnabled(true) + manager.setShowAsBackground(true) + + manager.setShowAsBackground(false) + + expect(scene.background).toBeNull() + }) + }) + + describe('clear', () => { + it('removes HDRI from scene and resets state', async () => { + vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr') + await manager.loadHDRI('http://example.com/env.hdr') + manager.setEnabled(true) + + manager.clear() + + expect(manager.isEnabled).toBe(false) + expect(scene.environment).toBeNull() + }) + }) + + describe('dispose', () => { + it('disposes PMREMGenerator', () => { + manager.dispose() + + expect(mockDisposePMREM).toHaveBeenCalled() + }) + }) +}) diff --git a/src/extensions/core/load3d/HDRIManager.ts b/src/extensions/core/load3d/HDRIManager.ts new file mode 100644 index 0000000000..7e27fbce32 --- /dev/null +++ b/src/extensions/core/load3d/HDRIManager.ts @@ -0,0 +1,142 @@ +import * as THREE from 'three' +import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader' +import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader' + +import Load3dUtils from './Load3dUtils' +import type { EventManagerInterface } from './interfaces' + +export class HDRIManager { + private scene: THREE.Scene + private renderer: THREE.WebGLRenderer + private pmremGenerator: THREE.PMREMGenerator + private eventManager: EventManagerInterface + + private hdriTexture: THREE.Texture | null = null + private envMapTarget: THREE.WebGLRenderTarget | null = null + + private _isEnabled: boolean = false + private _showAsBackground: boolean = false + private _intensity: number = 1 + + get isEnabled() { + return this._isEnabled + } + + get showAsBackground() { + return this._showAsBackground + } + + get intensity() { + return this._intensity + } + + constructor( + scene: THREE.Scene, + renderer: THREE.WebGLRenderer, + eventManager: EventManagerInterface + ) { + this.scene = scene + this.renderer = renderer + this.pmremGenerator = new THREE.PMREMGenerator(renderer) + this.pmremGenerator.compileEquirectangularShader() + this.eventManager = eventManager + } + + async loadHDRI(url: string): Promise { + const ext = Load3dUtils.getFilenameExtension(url) + + let newTexture: THREE.Texture + if (ext === 'exr') { + newTexture = await new Promise((resolve, reject) => { + new EXRLoader().load(url, resolve, undefined, reject) + }) + } else { + newTexture = await new Promise((resolve, reject) => { + new RGBELoader().load(url, resolve, undefined, reject) + }) + } + + newTexture.mapping = THREE.EquirectangularReflectionMapping + const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture) + + // Dispose old resources only after the new one is ready + this.hdriTexture?.dispose() + this.envMapTarget?.dispose() + this.hdriTexture = newTexture + this.envMapTarget = newEnvMapTarget + + if (this._isEnabled) { + this.applyToScene() + } + } + + setEnabled(enabled: boolean): void { + this._isEnabled = enabled + if (enabled) { + if (this.envMapTarget) { + this.applyToScene() + } + } else { + this.removeFromScene() + } + } + + setShowAsBackground(show: boolean): void { + this._showAsBackground = show + if (this._isEnabled && this.envMapTarget) { + this.applyToScene() + } + } + + setIntensity(intensity: number): void { + this._intensity = intensity + if (this._isEnabled) { + this.scene.environmentIntensity = intensity + } + } + + private applyToScene(): void { + const envMap = this.envMapTarget?.texture + if (!envMap) return + this.scene.environment = envMap + this.scene.environmentIntensity = this._intensity + this.scene.background = this._showAsBackground ? this.hdriTexture : null + this.renderer.toneMapping = THREE.ACESFilmicToneMapping + this.renderer.toneMappingExposure = 1.0 + this.eventManager.emitEvent('hdriChange', { + enabled: this._isEnabled, + showAsBackground: this._showAsBackground + }) + } + + private removeFromScene(): void { + this.scene.environment = null + if (this.scene.background === this.hdriTexture) { + this.scene.background = null + } + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.toneMappingExposure = 1.0 + this.eventManager.emitEvent('hdriChange', { + enabled: false, + showAsBackground: this._showAsBackground + }) + } + + private clearResources(): void { + this.removeFromScene() + this.hdriTexture?.dispose() + this.envMapTarget?.dispose() + this.hdriTexture = null + this.envMapTarget = null + } + + clear(): void { + this.clearResources() + this._isEnabled = false + } + + dispose(): void { + this.clearResources() + this.pmremGenerator.dispose() + } +} diff --git a/src/extensions/core/load3d/LightingManager.ts b/src/extensions/core/load3d/LightingManager.ts index 198edf59de..fc65e34ee3 100644 --- a/src/extensions/core/load3d/LightingManager.ts +++ b/src/extensions/core/load3d/LightingManager.ts @@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface { currentIntensity: number = 3 private scene: THREE.Scene private eventManager: EventManagerInterface + private lightMultipliers = new Map() constructor(scene: THREE.Scene, eventManager: EventManagerInterface) { this.scene = scene @@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface { this.scene.remove(light) }) this.lights = [] + this.lightMultipliers.clear() } setupLights(): void { - const ambientLight = new THREE.AmbientLight(0xffffff, 0.5) - this.scene.add(ambientLight) - this.lights.push(ambientLight) + const addLight = (light: THREE.Light, multiplier: number) => { + this.scene.add(light) + this.lights.push(light) + this.lightMultipliers.set(light, multiplier) + } + + addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5) const mainLight = new THREE.DirectionalLight(0xffffff, 0.8) mainLight.position.set(0, 10, 10) - this.scene.add(mainLight) - this.lights.push(mainLight) + addLight(mainLight, 0.8) const backLight = new THREE.DirectionalLight(0xffffff, 0.5) backLight.position.set(0, 10, -10) - this.scene.add(backLight) - this.lights.push(backLight) + addLight(backLight, 0.5) const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3) leftFillLight.position.set(-10, 0, 0) - this.scene.add(leftFillLight) - this.lights.push(leftFillLight) + addLight(leftFillLight, 0.3) const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3) rightFillLight.position.set(10, 0, 0) - this.scene.add(rightFillLight) - this.lights.push(rightFillLight) + addLight(rightFillLight, 0.3) const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2) bottomLight.position.set(0, -10, 0) - this.scene.add(bottomLight) - this.lights.push(bottomLight) + addLight(bottomLight, 0.2) } setLightIntensity(intensity: number): void { this.currentIntensity = intensity this.lights.forEach((light) => { - if (light instanceof THREE.DirectionalLight) { - if (light === this.lights[1]) { - light.intensity = intensity * 0.8 - } else if (light === this.lights[2]) { - light.intensity = intensity * 0.5 - } else if (light === this.lights[5]) { - light.intensity = intensity * 0.2 - } else { - light.intensity = intensity * 0.3 - } - } else if (light instanceof THREE.AmbientLight) { - light.intensity = intensity * 0.5 - } + light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1) }) this.eventManager.emitEvent('lightIntensityChange', intensity) } + setHDRIMode(hdriActive: boolean): void { + this.lights.forEach((light) => { + light.visible = !hdriActive + }) + } + reset(): void {} } diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index d3e6351fd1..816a94b488 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { CameraConfig, CameraState, + HDRIConfig, LightConfig, ModelConfig, SceneConfig @@ -113,6 +114,7 @@ class Load3DConfiguration { const lightConfig = this.loadLightConfig() this.applyLightConfig(lightConfig) + if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri) } private loadSceneConfig(): SceneConfig { @@ -140,13 +142,27 @@ class Load3DConfiguration { } private loadLightConfig(): LightConfig { + const hdriDefaults: HDRIConfig = { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 + } + if (this.properties && 'Light Config' in this.properties) { - return this.properties['Light Config'] as LightConfig + const saved = this.properties['Light Config'] as Partial + return { + intensity: + saved.intensity ?? + (useSettingStore().get('Comfy.Load3D.LightIntensity') as number), + hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) } + } } return { - intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') - } as LightConfig + intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number, + hdri: hdriDefaults + } } private loadModelConfig(): ModelConfig { @@ -190,6 +206,15 @@ class Load3DConfiguration { this.load3d.setLightIntensity(config.intensity) } + private applyHDRISettings(config: HDRIConfig) { + if (!config.hdriPath) return + this.load3d.setHDRIIntensity(config.intensity) + this.load3d.setHDRIAsBackground(config.showAsBackground) + if (config.enabled) { + this.load3d.setHDRIEnabled(true) + } + } + private applyModelConfig(config: ModelConfig) { this.load3d.setUpDirection(config.upDirection) this.load3d.setMaterialMode(config.materialMode) diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 7eeba28a59..5d629cbe58 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager' import { CameraManager } from './CameraManager' import { ControlsManager } from './ControlsManager' import { EventManager } from './EventManager' +import { HDRIManager } from './HDRIManager' import { LightingManager } from './LightingManager' import { LoaderManager } from './LoaderManager' import { ModelExporter } from './ModelExporter' @@ -54,6 +55,7 @@ class Load3d { cameraManager: CameraManager controlsManager: ControlsManager lightingManager: LightingManager + hdriManager: HDRIManager viewHelperManager: ViewHelperManager loaderManager: LoaderManager modelManager: SceneModelManager @@ -126,6 +128,12 @@ class Load3d { this.eventManager ) + this.hdriManager = new HDRIManager( + this.sceneManager.scene, + this.renderer, + this.eventManager + ) + this.viewHelperManager = new ViewHelperManager( this.renderer, this.getActiveCamera.bind(this), @@ -635,6 +643,33 @@ class Load3d { this.forceRender() } + async loadHDRI(url: string): Promise { + await this.hdriManager.loadHDRI(url) + this.forceRender() + } + + setHDRIEnabled(enabled: boolean): void { + this.hdriManager.setEnabled(enabled) + this.lightingManager.setHDRIMode(enabled) + this.forceRender() + } + + setHDRIAsBackground(show: boolean): void { + this.hdriManager.setShowAsBackground(show) + this.forceRender() + } + + setHDRIIntensity(intensity: number): void { + this.hdriManager.setIntensity(intensity) + this.forceRender() + } + + clearHDRI(): void { + this.hdriManager.clear() + this.lightingManager.setHDRIMode(false) + this.forceRender() + } + setTargetSize(width: number, height: number): void { this.targetWidth = width this.targetHeight = height @@ -858,6 +893,7 @@ class Load3d { this.cameraManager.dispose() this.controlsManager.dispose() this.lightingManager.dispose() + this.hdriManager.dispose() this.viewHelperManager.dispose() this.loaderManager.dispose() this.modelManager.dispose() diff --git a/src/extensions/core/load3d/Load3dUtils.test.ts b/src/extensions/core/load3d/Load3dUtils.test.ts new file mode 100644 index 0000000000..d1c0507a0b --- /dev/null +++ b/src/extensions/core/load3d/Load3dUtils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' + +describe('Load3dUtils.mapSceneLightIntensityToHdri', () => { + it('maps scene slider low end to a small positive HDRI intensity', () => { + expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25) + expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5) + }) + + it('maps midpoint proportionally', () => { + expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo( + 2.5 + ) + }) + + it('clamps scene ratio and HDRI ceiling', () => { + expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25) + expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5) + }) + + it('uses minimum HDRI when span is zero', () => { + expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25) + }) +}) diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts index 13095ac96b..6e5e5c9cfd 100644 --- a/src/extensions/core/load3d/Load3dUtils.ts +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -89,6 +89,15 @@ class Load3dUtils { return uploadPath } + static getFilenameExtension(url: string): string | undefined { + const queryString = url.split('?')[1] + if (queryString) { + const filename = new URLSearchParams(queryString).get('filename') + if (filename) return filename.split('.').pop()?.toLowerCase() + } + return url.split('?')[0].split('.').pop()?.toLowerCase() + } + static splitFilePath(path: string): [string, string] { const folder_separator = path.lastIndexOf('/') if (folder_separator === -1) { @@ -122,6 +131,19 @@ class Load3dUtils { await Promise.all(uploadPromises) } + + static mapSceneLightIntensityToHdri( + sceneIntensity: number, + sceneMin: number, + sceneMax: number + ): number { + const span = sceneMax - sceneMin + const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0 + const clampedT = Math.min(1, Math.max(0, t)) + const mapped = clampedT * 5 + const minHdri = 0.25 + return Math.min(5, Math.max(minHdri, mapped)) + } } export default Load3dUtils diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts index b142da44a9..fb9cc0d985 100644 --- a/src/extensions/core/load3d/constants.ts +++ b/src/extensions/core/load3d/constants.ts @@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([ ]) export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',') + +export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr']) + +export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [ + ...SUPPORTED_HDRI_EXTENSIONS +].join(',') diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 6563eaa2d7..d78fd239dc 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -47,6 +47,14 @@ export interface CameraConfig { export interface LightConfig { intensity: number + hdri?: HDRIConfig +} + +export interface HDRIConfig { + enabled: boolean + hdriPath: string + showAsBackground: boolean + intensity: number } export interface EventCallback { diff --git a/src/extensions/core/webcamCapture.ts b/src/extensions/core/webcamCapture.ts index af41466e74..1350d06a77 100644 --- a/src/extensions/core/webcamCapture.ts +++ b/src/extensions/core/webcamCapture.ts @@ -143,9 +143,10 @@ app.registerExtension({ throw new Error(err) } const data = await resp.json() - const serverName = data.name ?? name - const subfolder = data.subfolder ?? 'webcam' - return `${subfolder}/${serverName} [temp]` + const serverName = data.name || name + const subfolder = data.subfolder || 'webcam' + const type = data.type || 'temp' + return `${subfolder}/${serverName} [${type}]` } // @ts-expect-error fixme ts strict error diff --git a/src/lib/litegraph/src/LGraph.configure.test.ts b/src/lib/litegraph/src/LGraph.configure.test.ts deleted file mode 100644 index 2401dfe302..0000000000 --- a/src/lib/litegraph/src/LGraph.configure.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: Fix these tests after migration -import { describe } from 'vitest' - -import { LGraph } from '@/lib/litegraph/src/litegraph' - -import { dirtyTest } from './__fixtures__/testExtensions' - -describe.skip('LGraph configure()', () => { - dirtyTest( - 'LGraph matches previous snapshot (normal configure() usage)', - ({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => { - const configuredMinGraph = new LGraph() - configuredMinGraph.configure(minimalSerialisableGraph) - expect(configuredMinGraph).toMatchSnapshot('configuredMinGraph') - - const configuredBasicGraph = new LGraph() - configuredBasicGraph.configure(basicSerialisableGraph) - expect(configuredBasicGraph).toMatchSnapshot('configuredBasicGraph') - } - ) -}) diff --git a/src/lib/litegraph/src/LGraph.constructor.test.ts b/src/lib/litegraph/src/LGraph.constructor.test.ts deleted file mode 100644 index 4e214cae57..0000000000 --- a/src/lib/litegraph/src/LGraph.constructor.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -// TODO: Fix these tests after migration -import { describe } from 'vitest' - -import { LGraph } from '@/lib/litegraph/src/litegraph' - -import { dirtyTest } from './__fixtures__/testExtensions' - -describe.skip('LGraph (constructor only)', () => { - dirtyTest( - 'Matches previous snapshot', - ({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => { - const minLGraph = new LGraph(minimalSerialisableGraph) - expect(minLGraph).toMatchSnapshot('minLGraph') - - const basicLGraph = new LGraph(basicSerialisableGraph) - expect(basicLGraph).toMatchSnapshot('basicLGraph') - } - ) -}) diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 3517e035a5..668e109a1e 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -12,6 +12,7 @@ import { } from '@/lib/litegraph/src/litegraph' import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import { zeroUuid } from '@/lib/litegraph/src/utils/uuid' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' import { @@ -1005,3 +1006,25 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => { expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22])) }) }) + +describe('Zero UUID handling in configure', () => { + beforeEach(() => { + setActivePinia(createTestingPinia()) + }) + + it('rejects zeroUuid for root graphs and assigns a new ID', () => { + const graph = new LGraph() + const data = graph.serialize() + data.id = zeroUuid + graph.configure(data) + expect(graph.id).not.toBe(zeroUuid) + }) + + it('preserves zeroUuid for subgraphs', () => { + const graph = new LGraph() + const subgraphData = { ...createTestSubgraphData(), id: zeroUuid } + const subgraph = graph.createSubgraph(subgraphData) + subgraph.configure(subgraphData) + expect(subgraph.id).toBe(zeroUuid) + }) +}) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index ea16441665..9699e5f453 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -2480,8 +2480,8 @@ export class LGraph protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void { const { id, extra } = data - // Create a new graph ID if none is provided - if (id) { + // Create a new graph ID if none is provided or the zero UUID is used on the root graph + if (id && !(this.isRootGraph && id === zeroUuid)) { this.id = id } else if (this.id === zeroUuid) { this.id = createUuidv4() diff --git a/src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts b/src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts new file mode 100644 index 0000000000..7cd5c7c36b --- /dev/null +++ b/src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import { LayoutSource } from '@/renderer/core/layout/types' +import type { NodeLayout } from '@/renderer/core/layout/types' + +import { + LGraph, + LGraphCanvas, + LGraphNode, + LiteGraph +} from '@/lib/litegraph/src/litegraph' + +const TEST_NODE_TYPE = 'test/CloneZIndex' as const + +class TestNode extends LGraphNode { + static override type = TEST_NODE_TYPE + + constructor(title?: string) { + super(title ?? TEST_NODE_TYPE) + this.type = TEST_NODE_TYPE + } +} + +function createCanvas(graph: LGraph): LGraphCanvas { + const el = document.createElement('canvas') + el.width = 800 + el.height = 600 + + const ctx = { + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillText: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 50 }), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fill: vi.fn(), + closePath: vi.fn(), + arc: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + clearRect: vi.fn(), + setTransform: vi.fn(), + roundRect: vi.fn(), + getTransform: vi + .fn() + .mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + globalAlpha: 1, + textAlign: 'left' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline + } satisfies Partial + + el.getContext = vi + .fn() + .mockReturnValue(ctx as unknown as CanvasRenderingContext2D) + el.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 600 + }) + + return new LGraphCanvas(el, graph, { skip_render: true }) +} + +function createLayoutEntry(node: LGraphNode, zIndex: number) { + const nodeId = String(node.id) + const layout: NodeLayout = { + id: nodeId, + position: { x: node.pos[0], y: node.pos[1] }, + size: { width: node.size[0], height: node.size[1] }, + zIndex, + visible: true, + bounds: { + x: node.pos[0], + y: node.pos[1], + width: node.size[0], + height: node.size[1] + } + } + layoutStore.applyOperation({ + type: 'createNode', + entity: 'node', + nodeId, + layout, + timestamp: Date.now(), + source: LayoutSource.Canvas, + actor: 'test' + }) +} + +function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) { + layoutStore.applyOperation({ + type: 'setNodeZIndex', + entity: 'node', + nodeId, + zIndex, + previousZIndex, + timestamp: Date.now(), + source: LayoutSource.Canvas, + actor: 'test' + }) +} + +describe('cloned node z-index in Vue renderer', () => { + let graph: LGraph + let canvas: LGraphCanvas + let previousVueNodesMode: boolean + + beforeEach(() => { + vi.clearAllMocks() + previousVueNodesMode = LiteGraph.vueNodesMode + LiteGraph.vueNodesMode = true + LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode) + + graph = new LGraph() + canvas = createCanvas(graph) + LGraphCanvas.active_canvas = canvas + + layoutStore.initializeFromLiteGraph([]) + + // Simulate Vue runtime: create layout entries when nodes are added + graph.onNodeAdded = (node: LGraphNode) => { + createLayoutEntry(node, 0) + } + }) + + afterEach(() => { + LiteGraph.vueNodesMode = previousVueNodesMode + }) + + it('places cloned nodes above the original node z-index', () => { + const originalNode = new TestNode() + originalNode.pos = [100, 100] + originalNode.size = [200, 100] + graph.add(originalNode) + + const originalNodeId = String(originalNode.id) + + setZIndex(originalNodeId, 5, 0) + + const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value + expect(originalLayout?.zIndex).toBe(5) + + // Clone the node via cloneNodes (same path as right-click > clone) + const result = LGraphCanvas.cloneNodes([originalNode]) + expect(result).toBeDefined() + expect(result!.created.length).toBe(1) + + const clonedNode = result!.created[0] as LGraphNode + const clonedNodeId = String(clonedNode.id) + + // The cloned node should have a z-index higher than the original + const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value + expect(clonedLayout).toBeDefined() + expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex) + }) + + it('assigns distinct sequential z-indices when cloning multiple nodes', () => { + const nodeA = new TestNode() + nodeA.pos = [100, 100] + nodeA.size = [200, 100] + graph.add(nodeA) + setZIndex(String(nodeA.id), 3, 0) + + const nodeB = new TestNode() + nodeB.pos = [400, 100] + nodeB.size = [200, 100] + graph.add(nodeB) + setZIndex(String(nodeB.id), 7, 0) + + const result = LGraphCanvas.cloneNodes([nodeA, nodeB]) + expect(result).toBeDefined() + expect(result!.created.length).toBe(2) + + const clonedA = result!.created[0] as LGraphNode + const clonedB = result!.created[1] as LGraphNode + const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value! + const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value! + + // Both cloned nodes should be above the highest original (z-index 7) + expect(layoutA.zIndex).toBeGreaterThan(7) + expect(layoutB.zIndex).toBeGreaterThan(7) + + // Each cloned node should have a distinct z-index + expect(layoutA.zIndex).not.toBe(layoutB.zIndex) + }) +}) diff --git a/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts b/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts index ba7ea88b83..4e42b02e18 100644 --- a/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts +++ b/src/lib/litegraph/src/LGraphCanvas.groupSelection.test.ts @@ -1,7 +1,7 @@ +import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' - import { LGraph, LGraphCanvas, @@ -60,7 +60,7 @@ function createCanvas(graph: LGraph): LGraphCanvas { el.getContext = vi .fn() - .mockReturnValue(ctx as unknown as CanvasRenderingContext2D) + .mockReturnValue(fromAny(ctx)) el.getBoundingClientRect = vi.fn().mockReturnValue({ left: 0, top: 0, diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index d13c646d81..2d5a19ff54 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan' import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations' +import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { LayoutSource } from '@/renderer/core/layout/types' import { forEachNode } from '@/utils/graphTraversalUtil' @@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas) layoutStore.batchUpdateNodeBounds(newPositions) + // Bring cloned/pasted nodes to front so they render above the originals + const allNodes = layoutStore.getAllNodes().value + let maxZIndex = 0 + for (const [, layout] of allNodes) { + if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex + } + const { setNodeZIndex } = useLayoutMutations() + for (let i = 0; i < newPositions.length; i++) { + setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1) + } + this.selectItems(created) forEachNode(graph, (n) => n.onGraphConfigured?.()) forEachNode(graph, (n) => n.onAfterGraphConfigured?.()) diff --git a/src/lib/litegraph/src/__fixtures__/assets/testGraphs.ts b/src/lib/litegraph/src/__fixtures__/assets/testGraphs.ts index 03a3a12605..5070524db0 100644 --- a/src/lib/litegraph/src/__fixtures__/assets/testGraphs.ts +++ b/src/lib/litegraph/src/__fixtures__/assets/testGraphs.ts @@ -39,29 +39,3 @@ export const minimalSerialisableGraph: SerialisableGraph = { links: [], groups: [] } - -export const basicSerialisableGraph: SerialisableGraph = { - id: 'ca9da7d8-fddd-4707-ad32-67be9be13140', - revision: 0, - version: 1, - config: {}, - state: { - lastNodeId: 0, - lastLinkId: 0, - lastGroupId: 0, - lastRerouteId: 0 - }, - groups: [ - { - id: 123, - bounding: [20, 20, 1, 3], - color: '#6029aa', - font_size: 14, - title: 'A group to test with' - } - ], - nodes: [ - { id: 1, type: 'mustBeSet' } as Partial as ISerialisedNode - ], - links: [] -} diff --git a/src/lib/litegraph/src/__fixtures__/testExtensions.ts b/src/lib/litegraph/src/__fixtures__/testExtensions.ts index 6aff554388..e72aaf55ce 100644 --- a/src/lib/litegraph/src/__fixtures__/testExtensions.ts +++ b/src/lib/litegraph/src/__fixtures__/testExtensions.ts @@ -2,7 +2,6 @@ import { test as baseTest } from 'vitest' import { LGraph } from '@/lib/litegraph/src/LGraph' -import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ISerialisedGraph, SerialisableGraph @@ -12,11 +11,7 @@ import floatingBranch from './assets/floatingBranch.json' with { type: 'json' } import floatingLink from './assets/floatingLink.json' with { type: 'json' } import linkedNodes from './assets/linkedNodes.json' with { type: 'json' } import reroutesComplex from './assets/reroutesComplex.json' with { type: 'json' } -import { - basicSerialisableGraph, - minimalSerialisableGraph, - oldSchemaGraph -} from './assets/testGraphs' +import { minimalSerialisableGraph, oldSchemaGraph } from './assets/testGraphs' interface LitegraphFixtures { minimalGraph: LGraph @@ -28,11 +23,7 @@ interface LitegraphFixtures { reroutesComplexGraph: LGraph } -/** These fixtures alter global state, and are difficult to reset. Relies on a single test per-file to reset state. */ -interface DirtyFixtures { - basicSerialisableGraph: SerialisableGraph -} - +/** LiteGraph test fixtures. Each creates an LGraph from cloned data; LGraph singletons may still share some global state. */ export const test = baseTest.extend({ minimalGraph: async ({}, use) => { // Before each test function @@ -65,17 +56,3 @@ export const test = baseTest.extend({ await use(graph) } }) - -/** Test that use {@link DirtyFixtures}. One test per file. */ -export const dirtyTest = test.extend({ - basicSerialisableGraph: async ({}, use) => { - if (!basicSerialisableGraph.nodes) throw new Error('Invalid test object') - - // Register node types - for (const node of basicSerialisableGraph.nodes) { - LiteGraph.registerNodeType(node.type!, LiteGraph.LGraphNode) - } - - await use(structuredClone(basicSerialisableGraph)) - } -}) diff --git a/src/lib/litegraph/src/canvas/LinkConnector.test.ts b/src/lib/litegraph/src/canvas/LinkConnector.test.ts index 9ec8e3dfbc..75a8558cb1 100644 --- a/src/lib/litegraph/src/canvas/LinkConnector.test.ts +++ b/src/lib/litegraph/src/canvas/LinkConnector.test.ts @@ -1,9 +1,7 @@ -// TODO: Fix these tests after migration import { beforeEach, describe, expect, test, vi } from 'vitest' import { LinkConnector } from '@/lib/litegraph/src/litegraph' import { - createMockCanvasPointerEvent, createMockLGraphNode, createMockLinkNetwork, createMockNodeInputSlot, @@ -42,7 +40,7 @@ function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem { const mockNode = createMockLGraphNode() const mockInput = createMockNodeInputSlot() -describe.skip('LinkConnector', () => { +describe('LinkConnector', () => { let connector: LinkConnector beforeEach(() => { @@ -52,7 +50,7 @@ describe.skip('LinkConnector', () => { vi.clearAllMocks() }) - describe.skip('isInputValidDrop', () => { + describe('isInputValidDrop', () => { test('should return false if there are no render links', () => { expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false) }) @@ -74,110 +72,5 @@ describe.skip('LinkConnector', () => { expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput) }) - - test('should call canConnectToInput on each render link until one returns true', () => { - const link1 = mockRenderLinkImpl(false) - const link2 = mockRenderLinkImpl(true) // This one can connect - const link3 = mockRenderLinkImpl(false) - connector.renderLinks.push(link1, link2, link3) - - expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true) - - expect(link1.canConnectToInput).toHaveBeenCalledTimes(1) - expect(link2.canConnectToInput).toHaveBeenCalledTimes(1) // Stops here - expect(link3.canConnectToInput).not.toHaveBeenCalled() // Should not be called - }) - }) - - describe.skip('listenUntilReset', () => { - test('should add listener for the specified event and for reset', () => { - const listener = vi.fn() - const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener') - - connector.listenUntilReset('before-drop-links', listener) - - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'before-drop-links', - listener, - undefined - ) - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'reset', - expect.any(Function), - { once: true } - ) - }) - - test('should call the listener when the event is dispatched before reset', () => { - const listener = vi.fn() - const eventData = { - renderLinks: [], - event: createMockCanvasPointerEvent(0, 0) - } - connector.listenUntilReset('before-drop-links', listener) - - connector.events.dispatch('before-drop-links', eventData) - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith( - new CustomEvent('before-drop-links') - ) - }) - - test('should remove the listener when reset is dispatched', () => { - const listener = vi.fn() - const removeEventListenerSpy = vi.spyOn( - connector.events, - 'removeEventListener' - ) - - connector.listenUntilReset('before-drop-links', listener) - - // Simulate the reset event being dispatched - connector.events.dispatch('reset', false) - - // Check if removeEventListener was called correctly for the original listener - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'before-drop-links', - listener - ) - }) - - test('should not call the listener after reset is dispatched', () => { - const listener = vi.fn() - const eventData = { - renderLinks: [], - event: createMockCanvasPointerEvent(0, 0) - } - connector.listenUntilReset('before-drop-links', listener) - - // Dispatch reset first - connector.events.dispatch('reset', false) - - // Then dispatch the original event - connector.events.dispatch('before-drop-links', eventData) - - expect(listener).not.toHaveBeenCalled() - }) - - test('should pass options to addEventListener', () => { - const listener = vi.fn() - const options = { once: true } - const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener') - - connector.listenUntilReset('after-drop-links', listener, options) - - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'after-drop-links', - listener, - options - ) - // Still adds the reset listener - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'reset', - expect.any(Function), - { once: true } - ) - }) }) }) diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts index 1ea33b319d..bacfd609e2 100644 --- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts +++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts @@ -186,11 +186,16 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { if (!widget) return // Special case: SubgraphNode widget. + // Prefer serializeValue (per-instance) over the shared .value getter + // so multiple SubgraphNode instances return their own configured values. + const widgetValue = widget.serializeValue + ? widget.serializeValue(subgraphNode, -1) + : widget.value return { node: this, origin_id: this.id, origin_slot: -1, - widgetInfo: { value: widget.value } + widgetInfo: { value: widgetValue } } } diff --git a/src/lib/litegraph/src/subgraph/Subgraph.test.ts b/src/lib/litegraph/src/subgraph/Subgraph.test.ts index ad01e601bb..5826c4ca94 100644 --- a/src/lib/litegraph/src/subgraph/Subgraph.test.ts +++ b/src/lib/litegraph/src/subgraph/Subgraph.test.ts @@ -6,12 +6,12 @@ * and basic I/O management. */ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' import type { LGraph } from '@/lib/litegraph/src/litegraph' import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph' - import { subgraphTest } from './__fixtures__/subgraphFixtures' import { assertSubgraphStructure, @@ -48,7 +48,7 @@ describe('Subgraph Construction', () => { it('should require a root graph', () => { const subgraphData = createTestSubgraphData() const createWithoutRoot = () => - new Subgraph(null as unknown as LGraph, subgraphData) + new Subgraph(fromAny(null), subgraphData) expect(createWithoutRoot).toThrow('Root graph is required') }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts new file mode 100644 index 0000000000..66f2817cc3 --- /dev/null +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts @@ -0,0 +1,261 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { ISlotType } from '@/lib/litegraph/src/litegraph' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' + +import { + createTestSubgraph, + createTestSubgraphNode, + resetSubgraphFixtureState +} from './__fixtures__/subgraphHelpers' + +function createNodeWithWidget( + title: string, + widgetValue: number = 42, + slotType: ISlotType = 'number' +) { + const node = new LGraphNode(title) + const input = node.addInput('value', slotType) + node.addOutput('out', slotType) + + const widget = node.addWidget('number', 'widget', widgetValue, () => {}, { + min: 0, + max: 100, + step: 1 + }) + input.widget = { name: widget.name } + + return { node, widget, input } +} + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + resetSubgraphFixtureState() +}) + +describe('SubgraphNode multi-instance widget isolation', () => { + it('preserves per-instance widget values after configure', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node } = createNodeWithWidget('TestNode', 0) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const instance1 = createTestSubgraphNode(subgraph, { id: 201 }) + const instance2 = createTestSubgraphNode(subgraph, { id: 202 }) + + // Simulate what LGraph.configure does: call configure with different widgets_values + instance1.configure({ + id: 201, + type: subgraph.id, + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [], + mode: 0, + order: 0, + flags: {}, + properties: { proxyWidgets: [['-1', 'widget']] }, + widgets_values: [10] + }) + + instance2.configure({ + id: 202, + type: subgraph.id, + pos: [400, 100], + size: [200, 100], + inputs: [], + outputs: [], + mode: 0, + order: 1, + flags: {}, + properties: { proxyWidgets: [['-1', 'widget']] }, + widgets_values: [20] + }) + + const widgets1 = instance1.widgets! + const widgets2 = instance2.widgets! + + expect(widgets1.length).toBeGreaterThan(0) + expect(widgets2.length).toBeGreaterThan(0) + expect(widgets1[0].value).toBe(10) + expect(widgets2[0].value).toBe(20) + expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10) + expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20) + expect(instance1.serialize().widgets_values).toEqual([10]) + expect(instance2.serialize().widgets_values).toEqual([20]) + }) + + it('round-trips per-instance widget values through serialize and configure', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node } = createNodeWithWidget('TestNode', 0) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const originalInstance = createTestSubgraphNode(subgraph, { id: 301 }) + originalInstance.configure({ + id: 301, + type: subgraph.id, + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [], + mode: 0, + order: 0, + flags: {}, + properties: { proxyWidgets: [['-1', 'widget']] }, + widgets_values: [33] + }) + + const serialized = originalInstance.serialize() + + const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 }) + restoredInstance.configure({ + ...serialized, + id: 302, + type: subgraph.id + }) + + const restoredWidget = restoredInstance.widgets?.[0] + expect(restoredWidget?.value).toBe(33) + expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33) + }) + + it('keeps fresh sibling instances isolated before save or reload', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node } = createNodeWithWidget('TestNode', 7) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const instance1 = createTestSubgraphNode(subgraph, { id: 401 }) + const instance2 = createTestSubgraphNode(subgraph, { id: 402 }) + instance1.graph!.add(instance1) + instance2.graph!.add(instance2) + + const widget1 = instance1.widgets?.[0] + const widget2 = instance2.widgets?.[0] + + expect(widget1?.value).toBe(7) + expect(widget2?.value).toBe(7) + + widget1!.value = 10 + + expect(widget1?.value).toBe(10) + expect(widget2?.value).toBe(7) + expect(widget1?.serializeValue?.(instance1, 0)).toBe(10) + expect(widget2?.serializeValue?.(instance2, 0)).toBe(7) + }) + + it('syncs restored promoted widgets when the inner source widget changes directly', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node, widget } = createNodeWithWidget('TestNode', 0) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const originalInstance = createTestSubgraphNode(subgraph, { id: 601 }) + originalInstance.configure({ + id: 601, + type: subgraph.id, + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [], + mode: 0, + order: 0, + flags: {}, + properties: { proxyWidgets: [['-1', 'widget']] }, + widgets_values: [33] + }) + + const serialized = originalInstance.serialize() + + const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 }) + restoredInstance.configure({ + ...serialized, + id: 602, + type: subgraph.id + }) + + expect(restoredInstance.widgets?.[0].value).toBe(33) + + widget.value = 45 + + expect(restoredInstance.widgets?.[0].value).toBe(45) + expect( + restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0) + ).toBe(45) + }) + + it('clears stale per-instance values when reconfigured without widgets_values', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node, widget } = createNodeWithWidget('TestNode', 5) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + const instance = createTestSubgraphNode(subgraph, { id: 701 }) + instance.graph!.add(instance) + + const promotedWidget = instance.widgets?.[0] + promotedWidget!.value = 11 + widget.value = 17 + + const serialized = instance.serialize() + delete serialized.widgets_values + + instance.configure({ + ...serialized, + id: instance.id, + type: subgraph.id + }) + + expect(instance.widgets?.[0].value).toBe(17) + expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17) + }) + + it('skips non-serializable source widgets during serialize', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'value', type: 'number' }] + }) + + const { node, widget } = createNodeWithWidget('TestNode', 10) + subgraph.add(node) + subgraph.inputNode.slots[0].connect(node.inputs[0], node) + + // Mark the source widget as non-persistent (e.g. preview widget) + widget.serialize = false + + const instance = createTestSubgraphNode(subgraph, { id: 501 }) + instance.configure({ + id: 501, + type: subgraph.id, + pos: [100, 100], + size: [200, 100], + inputs: [], + outputs: [], + mode: 0, + order: 0, + flags: {}, + properties: { proxyWidgets: [['-1', 'widget']] }, + widgets_values: [] + }) + + const serialized = instance.serialize() + expect(serialized.widgets_values).toBeUndefined() + }) +}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts index d044e5dfc2..8d0d586d60 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts @@ -4,12 +4,14 @@ * Tests for SubgraphNode instances including construction, * IO synchronization, and edge cases. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation' import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' +import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation' +import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums' import { subgraphTest } from './__fixtures__/subgraphFixtures' import { @@ -933,14 +935,17 @@ describe('SubgraphNode promotion view keys', () => { const subgraph = createTestSubgraph() const subgraphNode = createTestSubgraphNode(subgraph) - const nodeWithKeyBuilder = subgraphNode as unknown as { - _makePromotionViewKey: ( - inputKey: string, - interiorNodeId: string, - widgetName: string, - inputName?: string - ) => string - } + const nodeWithKeyBuilder = fromAny< + { + _makePromotionViewKey: ( + inputKey: string, + interiorNodeId: string, + widgetName: string, + inputName?: string + ) => string + }, + unknown + >(subgraphNode) const firstKey = nodeWithKeyBuilder._makePromotionViewKey( '65', @@ -985,21 +990,43 @@ describe('SubgraphNode label propagation', () => { }) it('should propagate label via renaming-input event', () => { - const subgraph = createTestSubgraph() - const subgraphNode = createTestSubgraphNode(subgraph) + const subgraph = createTestSubgraph({ + inputs: [{ name: 'steps', type: 'number' }] + }) - subgraph.addInput('steps', 'number') - expect(subgraphNode.inputs[0].label).toBeUndefined() + const interiorNode = new LGraphNode('Interior') + const input = interiorNode.addInput('value', 'number') + input.widget = { name: 'value' } + interiorNode.addOutput('out', 'number') + interiorNode.addWidget('number', 'value', 0, () => {}) + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph) + const promotedInput = subgraphNode.inputs[0] + const originalWidgetName = promotedInput.widget?.name + const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger') + + expect(promotedInput.label).toBeUndefined() + expect(promotedInput._widget).toBeDefined() subgraph.renameInput(subgraph.inputs[0], 'Steps Count') - expect(subgraphNode.inputs[0].label).toBe('Steps Count') - expect(subgraphNode.inputs[0].name).toBe('steps') + expect(promotedInput.label).toBe('Steps Count') + expect(promotedInput.name).toBe('steps') + expect(promotedInput.widget?.name).toBe(originalWidgetName) + expect(promotedInput._widget?.label).toBe('Steps Count') + expect(subgraphNode.widgets?.[0].label).toBe('Steps Count') + expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', { + nodeId: subgraphNode.id, + slotType: NodeSlotType.INPUT + }) }) it('should propagate label via renaming-output event', () => { const subgraph = createTestSubgraph() const subgraphNode = createTestSubgraphNode(subgraph) + const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger') subgraph.addOutput('result', 'number') expect(subgraphNode.outputs[0].label).toBeUndefined() @@ -1008,6 +1035,10 @@ describe('SubgraphNode label propagation', () => { expect(subgraphNode.outputs[0].label).toBe('Final Result') expect(subgraphNode.outputs[0].name).toBe('result') + expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', { + nodeId: subgraphNode.id, + slotType: NodeSlotType.OUTPUT + }) }) it('should preserve localized_name from configure path', () => { diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index e9ca28f001..880314249e 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -993,7 +993,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } + /** Temporarily stored during configure for use by _internalConfigureAfterSlots */ + private _pendingWidgetsValues?: unknown[] + + /** + * Per-instance promoted widget values. + * Multiple SubgraphNode instances share the same inner nodes, so + * promoted widget values must be stored per-instance to avoid collisions. + * Key: `${sourceNodeId}:${sourceWidgetName}` + */ + readonly _instanceWidgetValues = new Map() + override configure(info: ExportedSubgraphInstance): void { + this._instanceWidgetValues.clear() + this._pendingWidgetsValues = info.widgets_values + for (const input of this.inputs) { if ( input._listenerController && @@ -1124,6 +1138,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (store.isPromoted(this.rootGraph.id, this.id, source)) continue store.promote(this.rootGraph.id, this.id, source) } + + // Hydrate per-instance promoted widget values from serialized data. + // LGraphNode.configure skips promoted widgets (serialize === false on + // the view), so they must be applied here after promoted views exist. + // Only iterate serializable views to match what serialize() wrote. + if (this._pendingWidgetsValues) { + const views = this._getPromotedViews() + let i = 0 + for (const view of views) { + if (!view.sourceSerialize) continue + if (i >= this._pendingWidgetsValues.length) break + view.value = this._pendingWidgetsValues[i++] as typeof view.value + } + this._pendingWidgetsValues = undefined + } } /** @@ -1518,6 +1547,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { override onRemoved(): void { this._eventAbortController.abort() this._invalidatePromotedViewsCache() + this._instanceWidgetValues.clear() for (const widget of this.widgets) { if (isPromotedWidgetView(widget)) { @@ -1573,28 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ctx.restore() } - /** - * Synchronizes widget values from this SubgraphNode instance to the - * corresponding widgets in the subgraph definition before serialization. - * This ensures nested subgraph widget values are preserved when saving. - */ override serialize(): ISerialisedNode { - // Sync widget values to subgraph definition before serialization. - // Only sync for inputs that are linked to a promoted widget via _widget. - for (const input of this.inputs) { - if (!input._widget) continue - - const subgraphInput = - input._subgraphSlot ?? - this.subgraph.inputNode.slots.find((slot) => slot.name === input.name) - if (!subgraphInput) continue - - const connectedWidgets = subgraphInput.getConnectedWidgets() - for (const connectedWidget of connectedWidgets) { - connectedWidget.value = input._widget.value - } - } - // Write promotion store state back to properties for serialization const entries = usePromotionStore().getPromotions( this.rootGraph.id, @@ -1602,7 +1611,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) this.properties.proxyWidgets = this._serializeEntries(entries) - return super.serialize() + const serialized = super.serialize() + const views = this._getPromotedViews() + + const serializableViews = views.filter((view) => view.sourceSerialize) + if (serializableViews.length > 0) { + serialized.widgets_values = serializableViews.map((view) => { + const value = view.serializeValue + ? view.serializeValue(this, -1) + : view.value + return value != null && typeof value === 'object' + ? JSON.parse(JSON.stringify(value)) + : (value ?? null) + }) + } + + return serialized } override clone() { const clone = super.clone() diff --git a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts index 893d39a035..b07f4da1a8 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts @@ -4,10 +4,11 @@ * Tests for saving, loading, and version compatibility of subgraphs. * This covers serialization, deserialization, data integrity, and migration scenarios. */ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' +import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds' import { LGraph, LGraphNode, @@ -22,6 +23,11 @@ import { resetSubgraphFixtureState } from './__fixtures__/subgraphHelpers' +class DummyNode extends LGraphNode {} + +const DUPLICATE_ID_SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' +const DUPLICATE_ID_SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' + function createRegisteredNode( graph: LGraph | Subgraph, inputs: ISlotType[] = [], @@ -50,6 +56,11 @@ function createRegisteredNode( beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) resetSubgraphFixtureState() + LiteGraph.registerNodeType('dummy', DummyNode) +}) + +afterEach(() => { + delete LiteGraph.registered_node_types.dummy }) describe('SubgraphSerialization - Basic Serialization', () => { @@ -475,4 +486,57 @@ describe('SubgraphSerialization - Data Integrity', () => { expect(targetNode!.inputs[link.target_slot]).toBeDefined() } }) + + it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => { + const graph = new LGraph() + graph.configure(structuredClone(duplicateSubgraphNodeIds)) + + const rootIds = graph.nodes + .map((node) => node.id) + .filter((id): id is number => typeof id === 'number') + .sort((a, b) => a - b) + expect(rootIds).toEqual([102, 103]) + + const subgraphAIds = new Set( + graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_A)!.nodes.map((node) => node.id) + ) + const subgraphBIds = new Set( + graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!.nodes.map((node) => node.id) + ) + + expect(subgraphAIds).toEqual(new Set([3, 8, 37])) + for (const id of subgraphAIds) { + expect(subgraphBIds.has(id)).toBe(false) + } + }) + + it('patches remapped link and proxyWidget references during duplicate-ID hydration', () => { + const graph = new LGraph() + graph.configure(structuredClone(duplicateSubgraphNodeIds)) + + const subgraphAIds = new Set( + graph.subgraphs + .get(DUPLICATE_ID_SUBGRAPH_A)! + .nodes.map((node) => String(node.id)) + ) + const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)! + const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id))) + + const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets + expect(Array.isArray(rootProxyWidgetsA)).toBe(true) + for (const entry of rootProxyWidgetsA as string[][]) { + expect(subgraphAIds.has(String(entry[0]))).toBe(true) + } + + const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets + expect(Array.isArray(rootProxyWidgetsB)).toBe(true) + for (const entry of rootProxyWidgetsB as string[][]) { + expect(subgraphBIds.has(String(entry[0]))).toBe(true) + } + + for (const [, link] of subgraphB.links) { + expect(subgraphBIds.has(String(link.origin_id))).toBe(true) + expect(subgraphBIds.has(String(link.target_id))).toBe(true) + } + }) }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index d85d0c8750..f54017d54a 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -266,6 +266,40 @@ describe('SubgraphWidgetPromotion', () => { }) describe('Nested Subgraph Widget Promotion', () => { + it('should hydrate legacy -1 proxyWidgets to a concrete promoted widget with preserved options', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'batch_size', type: 'INT' }] + }) + + const interiorNode = new LGraphNode('EmptyLatentImage') + const interiorInput = interiorNode.addInput('batch_size', 'INT') + interiorNode.addOutput('LATENT', 'LATENT') + interiorNode.addWidget('number', 'batch_size', 1, () => {}, { + step: 10, + min: 1 + }) + interiorInput.widget = { name: 'batch_size' } + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const hostNode = createTestSubgraphNode(subgraph) + const serializedHostNode = hostNode.serialize() + serializedHostNode.properties = { + ...serializedHostNode.properties, + proxyWidgets: [['-1', 'batch_size']] + } + + hostNode.configure(serializedHostNode) + + expect(hostNode.properties.proxyWidgets).toStrictEqual([ + [String(interiorNode.id), 'batch_size'] + ]) + expect(hostNode.widgets).toHaveLength(1) + expect(hostNode.widgets[0].name).toBe('batch_size') + expect(hostNode.widgets[0].value).toBe(1) + expect(hostNode.widgets[0].options.step).toBe(10) + }) + it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => { // Reproduces the bug where packing nodes into a nested subgraph leaves // stale proxyWidgets on the outer subgraph node referencing grandchild @@ -378,6 +412,47 @@ describe('SubgraphWidgetPromotion', () => { [String(nestedNode.id), 'noise_seed', String(samplerNode.id)] ]) }) + + it('should preserve promoted widget entries after cloning', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'text', type: 'STRING' }] + }) + + const interiorNode = new LGraphNode('CLIPTextEncode') + const interiorInput = interiorNode.addInput('text', 'STRING') + interiorNode.addOutput('CONDITIONING', 'CONDITIONING') + interiorNode.addWidget('text', 'text', '', () => {}) + interiorInput.widget = { name: 'text' } + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const hostNode = createTestSubgraphNode(subgraph) + + // serialize() syncs the promotion store into properties.proxyWidgets + const serialized = hostNode.serialize() + const originalProxyWidgets = serialized.properties! + .proxyWidgets as string[][] + + expect(originalProxyWidgets.length).toBeGreaterThan(0) + expect( + originalProxyWidgets.some(([, widgetName]) => widgetName === 'text') + ).toBe(true) + + // Simulate clone: create a second SubgraphNode configured from serialized data + const cloneNode = createTestSubgraphNode(subgraph) + cloneNode.configure(serialized) + const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][] + + expect(cloneProxyWidgets.length).toBeGreaterThan(0) + expect( + cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text') + ).toBe(true) + + // Clone's proxyWidgets should reference the same interior node + const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId) + const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId) + expect(cloneNodeIds).toStrictEqual(originalNodeIds) + }) }) describe('Tooltip Promotion', () => { diff --git a/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts index 15a28f5aa7..1dea0113fa 100644 --- a/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts +++ b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts @@ -1,3 +1,4 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { describe, expect, it, vi } from 'vitest' import { createBitmapCache } from './svgBitmapCache' @@ -25,9 +26,9 @@ describe('createBitmapCache', () => { ) } - const stubContext = { + const stubContext = fromPartial({ drawImage: vi.fn() - } as unknown as CanvasRenderingContext2D + }) it('returns the SVG when image is not yet complete', () => { const svg = mockSvg({ complete: false, naturalWidth: 0 }) diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index c7ce79d7a2..f0c2cf8eff 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -139,6 +139,7 @@ export type IWidget = | IBoundingBoxWidget | ICurveWidget | IPainterWidget + | IRangeWidget export interface IBooleanWidget extends IBaseWidget { type: 'toggle' @@ -341,6 +342,30 @@ export interface IPainterWidget extends IBaseWidget { value: string } +export interface RangeValue { + min: number + max: number + midpoint?: number +} + +export interface IWidgetRangeOptions extends IWidgetOptions { + display?: 'plain' | 'gradient' | 'histogram' + gradient_stops?: ColorStop[] + show_midpoint?: boolean + midpoint_scale?: 'linear' | 'gamma' + value_min?: number + value_max?: number +} + +export interface IRangeWidget extends IBaseWidget< + RangeValue, + 'range', + IWidgetRangeOptions +> { + type: 'range' + value: RangeValue +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/utils/feedback.ts b/src/lib/litegraph/src/utils/feedback.ts index 8fbe54e207..8215735a43 100644 --- a/src/lib/litegraph/src/utils/feedback.ts +++ b/src/lib/litegraph/src/utils/feedback.ts @@ -25,3 +25,34 @@ export function warnDeprecated(message: string, source?: object): void { callback(message, source) } } + +/** + * Defines a deprecated property alias that proxies to a current property, + * logging a deprecation warning on first access. + * + * Warning is deduplicated by {@link warnDeprecated} (once per unique message per session). + * + * @param target The object to define the deprecated property on. + * @param deprecatedKey The old property name to deprecate. + * @param currentKey The new property name to proxy to. + * @param message The deprecation warning message. + */ +export function defineDeprecatedProperty( + target: T, + deprecatedKey: string, + currentKey: keyof T & string, + message: string +): void { + Object.defineProperty(target, deprecatedKey, { + get() { + warnDeprecated(message) + return this[currentKey] + }, + set(value: unknown) { + warnDeprecated(message) + this[currentKey] = value + }, + configurable: true, + enumerable: false + }) +} diff --git a/src/lib/litegraph/src/utils/textMeasureCache.test.ts b/src/lib/litegraph/src/utils/textMeasureCache.test.ts index fdb5782dfd..04bdfc7e77 100644 --- a/src/lib/litegraph/src/utils/textMeasureCache.test.ts +++ b/src/lib/litegraph/src/utils/textMeasureCache.test.ts @@ -1,12 +1,13 @@ +import { fromPartial } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import { cachedMeasureText, clearTextMeasureCache } from './textMeasureCache' function createMockCtx(font = '12px sans-serif'): CanvasRenderingContext2D { - return { + return fromPartial({ font, measureText: vi.fn((text: string) => ({ width: text.length * 7 })) - } as unknown as CanvasRenderingContext2D + }) } describe('textMeasureCache', () => { diff --git a/src/lib/litegraph/src/widgets/BaseWidget.test.ts b/src/lib/litegraph/src/widgets/BaseWidget.test.ts index 0f72dc5e26..8c8c8482cc 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.test.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.test.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' @@ -167,7 +168,7 @@ describe('BaseWidget store integration', () => { const defaultValue = 'You are an expert image-generation engine.' const widget = createTestWidget(node, { name: 'system_prompt', - value: undefined as unknown as number + value: fromAny(undefined) }) // Simulate what addDOMWidget does: override value with getter/setter diff --git a/src/lib/litegraph/src/widgets/RangeWidget.ts b/src/lib/litegraph/src/widgets/RangeWidget.ts new file mode 100644 index 0000000000..4bdc469cb6 --- /dev/null +++ b/src/lib/litegraph/src/widgets/RangeWidget.ts @@ -0,0 +1,16 @@ +import type { IRangeWidget } from '../types/widgets' +import { BaseWidget } from './BaseWidget' +import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget' + +export class RangeWidget + extends BaseWidget + implements IRangeWidget +{ + override type = 'range' as const + + drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void { + this.drawVueOnlyWarning(ctx, options, 'Range') + } + + onClick(_options: WidgetEventOptions): void {} +} diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index cdd24308d3..86fe73d9e0 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -22,6 +22,7 @@ import { GalleriaWidget } from './GalleriaWidget' import { GradientSliderWidget } from './GradientSliderWidget' import { ImageCompareWidget } from './ImageCompareWidget' import { PainterWidget } from './PainterWidget' +import { RangeWidget } from './RangeWidget' import { ImageCropWidget } from './ImageCropWidget' import { KnobWidget } from './KnobWidget' import { LegacyWidget } from './LegacyWidget' @@ -60,6 +61,7 @@ export type WidgetTypeMap = { boundingbox: BoundingBoxWidget curve: CurveWidget painter: PainterWidget + range: RangeWidget [key: string]: BaseWidget } @@ -140,6 +142,8 @@ export function toConcreteWidget( return toClass(CurveWidget, narrowedWidget, node) case 'painter': return toClass(PainterWidget, narrowedWidget, node) + case 'range': + return toClass(RangeWidget, narrowedWidget, node) default: { if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node) } diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index 082a2604c4..707f70c745 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -1331,6 +1331,7 @@ "updating": "جارٍ التحديث", "upload": "رفع", "uploadAlreadyInProgress": "الرفع جارٍ بالفعل", + "uploadTimedOut": "انتهت مهلة التحميل. يرجى المحاولة مرة أخرى.", "usageHint": "تلميح الاستخدام", "use": "استخدم", "user": "المستخدم", @@ -1641,9 +1642,18 @@ "exportRecording": "تصدير التسجيل", "exportingModel": "جارٍ تصدير النموذج...", "fov": "مجال الرؤية (FOV)", + "hdri": { + "changeFile": "تغيير HDRI", + "intensity": "الشدة", + "label": "بيئة HDRI", + "removeFile": "إزالة HDRI", + "showAsBackground": "عرض كخلفية", + "uploadFile": "رفع HDRI (.hdr, .exr)" + }, "light": "الإضاءة", "lightIntensity": "شدة الإضاءة", "loadingBackgroundImage": "جارٍ تحميل صورة الخلفية", + "loadingHDRI": "جارٍ تحميل HDRI...", "loadingModel": "جارٍ تحميل النموذج ثلاثي الأبعاد...", "materialMode": "وضع المادة", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "مجموعة بيانات", "debug": "تصحيح", "deprecated": "مهمل", + "detection": "الكشف", "edit_models": "تحرير النماذج", "flux": "تدفق", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "لا توجد مدخلات", "inputsNoneTooltip": "العقدة ليس لديها مدخلات", "locateNode": "تحديد موقع العقدة على اللوحة", + "missingMedia": { + "audio": "الصوتيات", + "cancelSelection": "إلغاء الاختيار", + "collapseNodes": "إخفاء العقد المشار إليها", + "confirmSelection": "تأكيد الاختيار", + "expandNodes": "عرض العقد المشار إليها", + "image": "الصور", + "locateNode": "تحديد موقع العقدة", + "missingMediaTitle": "المدخلات المفقودة", + "or": "أو", + "selectedFromLibrary": "تم الاختيار من المكتبة", + "uploadFile": "رفع {type}", + "uploaded": "تم الرفع", + "uploading": "جاري الرفع...", + "useFromLibrary": "استخدام من المكتبة", + "video": "الفيديوهات" + }, "missingModels": { "alreadyExistsInCategory": "هذا النموذج موجود بالفعل في \"{category}\"", "assetLoadTimeout": "انتهت مهلة اكتشاف النموذج. حاول إعادة تحميل سير العمل.", @@ -3239,6 +3267,7 @@ "termsAgreement": "بالمتابعة، أنت توافق على {terms} و{privacy} الخاصة بـ Comfy Org.", "totalDueToday": "الإجمالي المستحق اليوم" }, + "refreshCredits": "تحديث الرصيد", "renewsDate": "تجديد في {date}", "required": { "pollingFailed": "فشل تفعيل الاشتراك", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "فشل في بدء شراء الرصيد: {error}", "failedToInitiateSubscription": "فشل في بدء الاشتراك: {error}", "failedToLoadBackgroundImage": "فشل في تحميل صورة الخلفية", + "failedToLoadHDRI": "فشل في تحميل ملف HDRI", "failedToLoadModel": "فشل في تحميل النموذج ثلاثي الأبعاد", "failedToPurchaseCredits": "فشل في شراء الرصيد: {error}", "failedToQueue": "فشل في الإضافة إلى قائمة الانتظار", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "يرجى اختيار عقد الإخراج", "unableToGetModelFilePath": "غير قادر على الحصول على مسار ملف النموذج", "unauthorizedDomain": "النطاق الخاص بك {domain} غير مخول لاستخدام هذه الخدمة. يرجى الاتصال بـ {email} لإضافة النطاق إلى القائمة البيضاء.", + "unsupportedHDRIFormat": "تنسيق الملف غير مدعوم. يرجى رفع ملف .hdr أو .exr.", "updateRequested": "تم طلب التحديث", "useApiKeyTip": "نصيحة: لا يمكنك الدخول عبر تسجيل الدخول العادي؟ استخدم خيار مفتاح API الخاص بـ Comfy.", "userNotAuthenticated": "المستخدم غير مصدق" diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index d27555e153..b496eea956 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "الصورة" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "ما إذا كان سيتم تمديد الاقتصاص ليتناسب مع حجم الإخراج، أو إضافة حواف سوداء للحفاظ على نسبة العرض إلى الارتفاع." + }, "output_height": { "name": "ارتفاع الناتج", "tooltip": "الارتفاع الذي يتم تغيير حجم كل قص إليه." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "رسم مربعات الإحاطة", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "موجّه CFG مزدوج", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "مخطط تكراري للصورة", + "inputs": { + "image": { + "name": "صورة" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "الإضاءة", + "tooltip": null + }, + "2": { + "name": "أحمر", + "tooltip": null + }, + "3": { + "name": "أخضر", + "tooltip": null + }, + "4": { + "name": "أزرق", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "عكس الصورة", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "اكتشاف RT-DETR", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "تصفية الاكتشافات حسب الفئة. اختر 'all' لتعطيل التصفية." + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "الحد الأقصى لعدد الاكتشافات التي سيتم إرجاعها لكل صورة. بالترتيب التنازلي حسب درجة الثقة." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "قص عشوائي للصور", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "صورة UV", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "إنشاء فيديو من صورة الإطار الأول، مع إمكانية إضافة صورة الإطار الأخير وصوت اختياري.", + "display_name": "وان 2.7 من صورة إلى فيديو", + "inputs": { + "audio": { + "name": "الصوت", + "tooltip": "الصوت المستخدم لتوجيه توليد الفيديو (مثل مزامنة الشفاه أو حركة متوافقة مع الإيقاع). المدة: ۲-۳۰ ثانية. إذا لم يتم توفيره، يقوم النموذج تلقائيًا بإنشاء موسيقى خلفية أو مؤثرات صوتية مناسبة." + }, + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "first_frame": { + "name": "الإطار الأول", + "tooltip": "صورة الإطار الأول. يتم اشتقاق نسبة العرض إلى الارتفاع للإخراج من هذه الصورة." + }, + "last_frame": { + "name": "الإطار الأخير", + "tooltip": "صورة الإطار الأخير. يقوم النموذج بإنشاء فيديو ينتقل من الإطار الأول إلى الأخير." + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_negative_prompt": { + "name": "توجيه سلبي" + }, + "model_prompt": { + "name": "توجيه" + }, + "model_resolution": { + "name": "الدقة" + }, + "prompt_extend": { + "name": "تعزيز التوجيه", + "tooltip": "ما إذا كان سيتم تعزيز التوجيه بمساعدة الذكاء الاصطناعي." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "ما إذا كان سيتم إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "إنشاء فيديو يعرض شخصًا أو كائنًا من مواد مرجعية. يدعم أداء شخصية واحدة وتفاعل عدة شخصيات.", + "display_name": "وان 2.7 من مرجع إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_negative_prompt": { + "name": "توجيه سلبي" + }, + "model_prompt": { + "name": "توجيه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "ما إذا كان سيتم إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "ينشئ فيديو بناءً على وصف نصي باستخدام نموذج وان 2.7.", + "display_name": "وان 2.7 تحويل النص إلى فيديو", + "inputs": { + "audio": { + "name": "الصوت", + "tooltip": "الصوت المستخدم لتوجيه توليد الفيديو (مثل مزامنة الشفاه أو الحركة المتوافقة مع الإيقاع). المدة: ٣-٣٠ ثانية. إذا لم يتم توفيره، سيقوم النموذج تلقائيًا بإنشاء موسيقى خلفية أو مؤثرات صوتية مناسبة." + }, + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_negative_prompt": { + "name": "الوصف السلبي" + }, + "model_prompt": { + "name": "الوصف" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "prompt_extend": { + "name": "توسيع الوصف", + "tooltip": "ما إذا كان سيتم تعزيز الوصف بمساعدة الذكاء الاصطناعي." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "ما إذا كان سيتم إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "استكمال الفيديو من حيث توقف، مع إمكانية التحكم في الإطار الأخير.", + "display_name": "وان 2.7 استكمال الفيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "first_clip": { + "name": "المقطع الأول", + "tooltip": "الفيديو المدخل للاستكمال منه. المدة: ٢-١٠ ثوانٍ. يتم اشتقاق نسبة العرض إلى الارتفاع من هذا الفيديو." + }, + "last_frame": { + "name": "الإطار الأخير", + "tooltip": "صورة الإطار الأخير. سيتم الانتقال في الاستكمال نحو هذا الإطار." + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_negative_prompt": { + "name": "الوصف السلبي" + }, + "model_prompt": { + "name": "الوصف" + }, + "model_resolution": { + "name": "الدقة" + }, + "prompt_extend": { + "name": "توسيع الوصف", + "tooltip": "ما إذا كان سيتم تعزيز الوصف بمساعدة الذكاء الاصطناعي." + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "ما إذا كان سيتم إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "تحرير فيديو باستخدام تعليمات نصية أو صور مرجعية أو نقل النمط.", + "display_name": "وان 2.7 تحرير الفيديو", + "inputs": { + "audio_setting": { + "name": "إعداد الصوت", + "tooltip": "'تلقائي': يقرر النموذج ما إذا كان سيعيد توليد الصوت بناءً على الوصف. 'الأصلي': الحفاظ على الصوت الأصلي من الفيديو المدخل." + }, + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_prompt": { + "name": "الوصف" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "video": { + "name": "الفيديو", + "tooltip": "الفيديو المراد تحريره." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "ما إذا كان سيتم إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index d18724fcc8..6868f54f3c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -199,6 +199,7 @@ "control_before_generate": "control before generate", "choose_file_to_upload": "choose file to upload", "uploadAlreadyInProgress": "Upload already in progress", + "uploadTimedOut": "Upload timed out. Please try again.", "capture": "capture", "nodes": "Nodes", "nodesCount": "{count} node | {count} nodes", @@ -1678,6 +1679,8 @@ "scheduling": "scheduling", "create": "create", "deprecated": "deprecated", + "detection": "detection", + "": "", "debug": "debug", "model": "model", "ElevenLabs": "ElevenLabs", @@ -1688,7 +1691,6 @@ "unet": "unet", "sigmas": "sigmas", "BFL": "BFL", - "": "", "Gemini": "Gemini", "video_models": "video_models", "gligen": "gligen", @@ -1742,8 +1744,8 @@ "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", - "camera": "camera", "Wan": "Wan", + "camera": "camera", "WaveSpeed": "WaveSpeed", "zimage": "zimage" }, @@ -1986,7 +1988,16 @@ "openIn3DViewer": "Open in 3D Viewer", "dropToLoad": "Drop 3D model to load", "unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)", - "uploadingModel": "Uploading 3D model..." + "uploadingModel": "Uploading 3D model...", + "loadingHDRI": "Loading HDRI...", + "hdri": { + "label": "HDRI Environment", + "uploadFile": "Upload HDRI (.hdr, .exr)", + "changeFile": "Change HDRI", + "removeFile": "Remove HDRI", + "showAsBackground": "Show as Background", + "intensity": "Intensity" + } }, "imageCrop": { "loading": "Loading...", @@ -2081,7 +2092,9 @@ "failedToUpdateMaterialMode": "Failed to update material mode", "failedToUpdateEdgeThreshold": "Failed to update edge threshold", "failedToUploadBackgroundImage": "Failed to upload background image", - "failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}" + "failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}", + "failedToLoadHDRI": "Failed to load HDRI file", + "unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file." }, "nodeErrors": { "render": "Node Render Error", @@ -2358,6 +2371,7 @@ "tierNameYearly": "{name} Yearly", "messageSupport": "Message support", "invoiceHistory": "Invoice history", + "refreshCredits": "Refresh credits", "benefits": { "benefit1": "Monthly credits for Partner Nodes — top up when needed", "benefit1FreeTier": "More monthly credits, top up anytime", @@ -3211,7 +3225,9 @@ "createProfileToPublish": "Create a profile to publish to ComfyHub", "createProfileCta": "Create a profile", "publishFailedTitle": "Publish failed", - "publishFailedDescription": "Something went wrong while publishing your workflow. Please try again." + "publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.", + "publishSuccessTitle": "Published successfully", + "publishSuccessDescription": "Your workflow is now live on ComfyHub." }, "comfyHubProfile": { "checkingAccess": "Checking your publishing access...", @@ -3400,6 +3416,8 @@ }, "rightSidePanel": { "togglePanel": "Toggle properties panel", + "editTitle": "Edit title", + "editSubgraph": "Edit subgraph", "noSelection": "Select a node to see its properties and info.", "workflowOverview": "Workflow Overview", "title": "No item(s) selected | 1 item selected | {count} items selected", @@ -3526,6 +3544,23 @@ "missingModelsTitle": "Missing Models", "assetLoadTimeout": "Model detection timed out. Try reloading the workflow.", "downloadAll": "Download all" + }, + "missingMedia": { + "missingMediaTitle": "Missing Inputs", + "image": "Images", + "video": "Videos", + "audio": "Audio", + "locateNode": "Locate node", + "expandNodes": "Show referencing nodes", + "collapseNodes": "Hide referencing nodes", + "uploadFile": "Upload {type}", + "uploading": "Uploading...", + "uploaded": "Uploaded", + "selectedFromLibrary": "Selected from library", + "useFromLibrary": "Use from Library", + "confirmSelection": "Confirm selection", + "cancelSelection": "Cancel selection", + "or": "OR" } }, "errorOverlay": { diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index a2184df82c..4a59ddd21f 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -798,7 +798,7 @@ } }, "CaseConverter": { - "display_name": "Case Converter", + "display_name": "Text Case Converter", "inputs": { "string": { "name": "string" @@ -2201,6 +2201,10 @@ "padding": { "name": "padding", "tooltip": "Extra padding in pixels added on each side of the bbox before cropping." + }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "Whether to stretch the crop to fit the output size, or pad with black pixels to preserve aspect ratio." } }, "outputs": { @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "Draw BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "DualCFGGuider", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Image Histogram", + "inputs": { + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "rgb", + "tooltip": null + }, + "1": { + "name": "luminance", + "tooltip": null + }, + "2": { + "name": "red", + "tooltip": null + }, + "3": { + "name": "green", + "tooltip": null + }, + "4": { + "name": "blue", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Invert Image", "inputs": { @@ -12819,7 +12870,7 @@ } }, "RegexExtract": { - "display_name": "Regex Extract", + "display_name": "Text Extract Substring", "inputs": { "string": { "name": "string" @@ -12850,7 +12901,7 @@ } }, "RegexMatch": { - "display_name": "Regex Match", + "display_name": "Text Match", "inputs": { "string": { "name": "string" @@ -12876,7 +12927,7 @@ } }, "RegexReplace": { - "display_name": "Regex Replace", + "display_name": "Text Replace (Regex)", "description": "Find and replace text using regex patterns.", "inputs": { "string": { @@ -13417,6 +13468,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR Detect", + "inputs": { + "model": { + "name": "model" + }, + "image": { + "name": "image" + }, + "threshold": { + "name": "threshold" + }, + "class_name": { + "name": "class_name", + "tooltip": "Filter detections by class. Set to 'all' to disable filtering." + }, + "max_detections": { + "name": "max_detections", + "tooltip": "Maximum number of detections to return per image. In order of descending confidence score." + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RunwayFirstLastFrameNode": { "display_name": "Runway First-Last-Frame to Video", "description": "Upload first and last keyframes, draft a prompt, and generate a video. More complex transitions, such as cases where the Last frame is completely different from the First frame, may benefit from the longer 10s duration. This would give the generation more time to smoothly transition between the two inputs. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3.", @@ -15171,7 +15250,7 @@ } }, "StringCompare": { - "display_name": "Compare", + "display_name": "Text Compare", "inputs": { "string_a": { "name": "string_a" @@ -15193,7 +15272,7 @@ } }, "StringConcatenate": { - "display_name": "Concatenate", + "display_name": "Text Concatenate", "inputs": { "string_a": { "name": "string_a" @@ -15212,7 +15291,7 @@ } }, "StringContains": { - "display_name": "Contains", + "display_name": "Text Contains", "inputs": { "string": { "name": "string" @@ -15232,7 +15311,7 @@ } }, "StringLength": { - "display_name": "Length", + "display_name": "Text Length", "inputs": { "string": { "name": "string" @@ -15246,7 +15325,7 @@ } }, "StringReplace": { - "display_name": "Replace", + "display_name": "Text Replace", "inputs": { "string": { "name": "string" @@ -15265,7 +15344,7 @@ } }, "StringSubstring": { - "display_name": "Substring", + "display_name": "Text Substring", "inputs": { "string": { "name": "string" @@ -15284,7 +15363,7 @@ } }, "StringTrim": { - "display_name": "Trim", + "display_name": "Text Trim", "inputs": { "string": { "name": "string" @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17923,6 +18006,241 @@ } } }, + "Wan2ImageToVideoApi": { + "display_name": "Wan 2.7 Image to Video", + "description": "Generate a video from a first-frame image, with optional last-frame image and audio.", + "inputs": { + "model": { + "name": "model" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "First frame image. The output aspect ratio is derived from this image." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Whether to enhance the prompt with AI assistance." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Last frame image. The model generates a video transitioning from first to last frame." + }, + "audio": { + "name": "audio", + "tooltip": "Audio for driving video generation (e.g., lip sync, beat-matched motion). Duration: 2s-30s. If not provided, the model automatically generates matching background music or sound effects." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "display_name": "Wan 2.7 Reference to Video", + "description": "Generate a video featuring a person or object from reference materials. Supports single-character performances and multi-character interactions.", + "inputs": { + "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 result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "display_name": "Wan 2.7 Text to Video", + "description": "Generates a video based on a text prompt using the Wan 2.7 model.", + "inputs": { + "model": { + "name": "model" + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Whether to enhance the prompt with AI assistance." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "audio": { + "name": "audio", + "tooltip": "Audio for driving video generation (e.g., lip sync, beat-matched motion). Duration: 3s-30s. If not provided, the model automatically generates matching background music or sound effects." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "display_name": "Wan 2.7 Video Continuation", + "description": "Continue a video from where it left off, with optional last-frame control.", + "inputs": { + "model": { + "name": "model" + }, + "first_clip": { + "name": "first_clip", + "tooltip": "Input video to continue from. Duration: 2s-10s. The output aspect ratio is derived from this video." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Whether to enhance the prompt with AI assistance." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Last frame image. The continuation will transition towards this frame." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "display_name": "Wan 2.7 Video Edit", + "description": "Edit a video using text instructions, reference images, or style transfer.", + "inputs": { + "model": { + "name": "model" + }, + "video": { + "name": "video", + "tooltip": "The video to edit." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "audio_setting": { + "name": "audio_setting", + "tooltip": "'auto': model decides whether to regenerate audio based on the prompt. 'origin': preserve the original audio from the input video." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/es/main.json b/src/locales/es/main.json index bfe4895ca1..deb630ba5b 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -1331,6 +1331,7 @@ "updating": "Actualizando", "upload": "Subir", "uploadAlreadyInProgress": "La carga ya está en curso", + "uploadTimedOut": "La carga ha excedido el tiempo de espera. Por favor, inténtalo de nuevo.", "usageHint": "Sugerencia de uso", "use": "Usar", "user": "Usuario", @@ -1641,9 +1642,18 @@ "exportRecording": "Exportar grabación", "exportingModel": "Exportando modelo...", "fov": "FOV", + "hdri": { + "changeFile": "Cambiar HDRI", + "intensity": "Intensidad", + "label": "Entorno HDRI", + "removeFile": "Eliminar HDRI", + "showAsBackground": "Mostrar como fondo", + "uploadFile": "Subir HDRI (.hdr, .exr)" + }, "light": "Luz", "lightIntensity": "Intensidad de luz", "loadingBackgroundImage": "Cargando imagen de fondo", + "loadingHDRI": "Cargando HDRI...", "loadingModel": "Cargando modelo 3D...", "materialMode": "Modo de material", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "conjunto de datos", "debug": "depurar", "deprecated": "obsoleto", + "detection": "detección", "edit_models": "editar_modelos", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "SIN ENTRADAS", "inputsNoneTooltip": "El nodo no tiene entradas", "locateNode": "Localizar nodo en el lienzo", + "missingMedia": { + "audio": "Audio", + "cancelSelection": "Cancelar selección", + "collapseNodes": "Ocultar nodos de referencia", + "confirmSelection": "Confirmar selección", + "expandNodes": "Mostrar nodos de referencia", + "image": "Imágenes", + "locateNode": "Localizar nodo", + "missingMediaTitle": "Entradas faltantes", + "or": "O", + "selectedFromLibrary": "Seleccionado de la biblioteca", + "uploadFile": "Subir {type}", + "uploaded": "Subido", + "uploading": "Subiendo...", + "useFromLibrary": "Usar de la biblioteca", + "video": "Videos" + }, "missingModels": { "alreadyExistsInCategory": "Este modelo ya existe en \"{category}\"", "assetLoadTimeout": "El tiempo de detección del modelo se agotó. Intenta recargar el flujo de trabajo.", @@ -3239,6 +3267,7 @@ "termsAgreement": "Al continuar, aceptas los {terms} y la {privacy} de Comfy Org.", "totalDueToday": "Total a pagar hoy" }, + "refreshCredits": "Actualizar créditos", "renewsDate": "Se renueva el {date}", "required": { "pollingFailed": "Error al activar la suscripción", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "No se pudo iniciar la compra de créditos: {error}", "failedToInitiateSubscription": "Error al iniciar la suscripción: {error}", "failedToLoadBackgroundImage": "Error al cargar la imagen de fondo", + "failedToLoadHDRI": "No se pudo cargar el archivo HDRI", "failedToLoadModel": "Error al cargar el modelo 3D", "failedToPurchaseCredits": "No se pudo comprar créditos: {error}", "failedToQueue": "Error al encolar", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida", "unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo", "unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.", + "unsupportedHDRIFormat": "Formato de archivo no compatible. Por favor, sube un archivo .hdr o .exr.", "updateRequested": "Actualización solicitada", "useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de clave API de Comfy.", "userNotAuthenticated": "Usuario no autenticado" diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 41e98a622d..babb6438f0 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "imagen" }, + "keep_aspect": { + "name": "mantener_proporción", + "tooltip": "Indica si se debe estirar el recorte para ajustarse al tamaño de salida, o rellenar con píxeles negros para preservar la relación de aspecto." + }, "output_height": { "name": "alto de salida", "tooltip": "Alto al que se redimensiona cada recorte." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "Dibujar BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "imagen" + } + }, + "outputs": { + "0": { + "name": "imagen_salida", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "Guía Dual CFG", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Histograma de imagen", + "inputs": { + "image": { + "name": "imagen" + } + }, + "outputs": { + "0": { + "name": "rgb", + "tooltip": null + }, + "1": { + "name": "luminancia", + "tooltip": null + }, + "2": { + "name": "rojo", + "tooltip": null + }, + "3": { + "name": "verde", + "tooltip": null + }, + "4": { + "name": "azul", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Invertir Imagen", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "Detección RT-DETR", + "inputs": { + "class_name": { + "name": "nombre_clase", + "tooltip": "Filtrar detecciones por clase. Establecer en 'all' para desactivar el filtrado." + }, + "image": { + "name": "imagen" + }, + "max_detections": { + "name": "máx_detecciones", + "tooltip": "Número máximo de detecciones a devolver por imagen. En orden descendente de puntuación de confianza." + }, + "model": { + "name": "modelo" + }, + "threshold": { + "name": "umbral" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "Recorte Aleatorio de Imágenes", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "imagen UV", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "Genera un video a partir de una imagen del primer fotograma, con opción de imagen del último fotograma y audio.", + "display_name": "Wan 2.7 Imagen a Video", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio para guiar la generación del video (por ejemplo, sincronización labial, movimiento al ritmo). Duración: 2s-30s. Si no se proporciona, el modelo genera automáticamente música de fondo o efectos de sonido acordes." + }, + "control_after_generate": { + "name": "controlar después de generar" + }, + "first_frame": { + "name": "primer_fotograma", + "tooltip": "Imagen del primer fotograma. La relación de aspecto de salida se deriva de esta imagen." + }, + "last_frame": { + "name": "último_fotograma", + "tooltip": "Imagen del último fotograma. El modelo genera un video que transiciona del primer al último fotograma." + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolución" + }, + "prompt_extend": { + "name": "extender_prompt", + "tooltip": "Si se debe mejorar el prompt con asistencia de IA." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca_de_agua", + "tooltip": "Si se debe añadir una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "Genera un video con una persona u objeto a partir de materiales de referencia. Soporta actuaciones de un solo personaje e interacciones entre varios personajes.", + "display_name": "Wan 2.7 Referencia a Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca_de_agua", + "tooltip": "Si se debe añadir una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Genera un video a partir de un texto usando el modelo Wan 2.7.", + "display_name": "Wan 2.7 Texto a Video", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio para guiar la generación del video (por ejemplo, sincronización labial, movimiento al ritmo). Duración: 3s-30s. Si no se proporciona, el modelo genera automáticamente música de fondo o efectos de sonido acordes." + }, + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación de aspecto" + }, + "model_resolution": { + "name": "resolución" + }, + "prompt_extend": { + "name": "extender_prompt", + "tooltip": "Si se debe mejorar el prompt con asistencia de IA." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Si se debe añadir una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "Continúa un video desde donde terminó, con control opcional del último fotograma.", + "display_name": "Wan 2.7 Continuación de Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "first_clip": { + "name": "primer_clip", + "tooltip": "Video de entrada desde el cual continuar. Duración: 2s-10s. La relación de aspecto de salida se deriva de este video." + }, + "last_frame": { + "name": "último_fotograma", + "tooltip": "Imagen del último fotograma. La continuación hará la transición hacia este fotograma." + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolución" + }, + "prompt_extend": { + "name": "extender_prompt", + "tooltip": "Si se debe mejorar el prompt con asistencia de IA." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Si se debe añadir una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "Edita un video usando instrucciones de texto, imágenes de referencia o transferencia de estilo.", + "display_name": "Wan 2.7 Edición de Video", + "inputs": { + "audio_setting": { + "name": "configuración_de_audio", + "tooltip": "'auto': el modelo decide si regenerar el audio según el prompt. 'origin': conserva el audio original del video de entrada." + }, + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación de aspecto" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "video": { + "name": "video", + "tooltip": "El video a editar." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Si se debe añadir una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index 23ca227b82..b6a265918d 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -1331,6 +1331,7 @@ "updating": "در حال به‌روزرسانی {id}", "upload": "بارگذاری", "uploadAlreadyInProgress": "بارگذاری در حال انجام است", + "uploadTimedOut": "زمان بارگذاری به پایان رسید. لطفاً دوباره تلاش کنید.", "usageHint": "راهنمای استفاده", "use": "استفاده", "user": "کاربر", @@ -1641,9 +1642,18 @@ "exportRecording": "خروجی گرفتن ضبط", "exportingModel": "در حال خروجی گرفتن مدل...", "fov": "زاویه دید (FOV)", + "hdri": { + "changeFile": "تغییر HDRI", + "intensity": "شدت", + "label": "محیط HDRI", + "removeFile": "حذف HDRI", + "showAsBackground": "نمایش به عنوان پس‌زمینه", + "uploadFile": "بارگذاری HDRI (‏.hdr، ‏.exr)" + }, "light": "نور", "lightIntensity": "شدت نور", "loadingBackgroundImage": "در حال بارگذاری تصویر پس‌زمینه", + "loadingHDRI": "در حال بارگذاری HDRI...", "loadingModel": "در حال بارگذاری مدل سه‌بعدی...", "materialMode": "حالت متریال", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "داده‌نما", "debug": "اشکال‌زدایی", "deprecated": "منسوخ", + "detection": "شناسایی", "edit_models": "ویرایش مدل‌ها", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "بدون ورودی", "inputsNoneTooltip": "این نود ورودی ندارد", "locateNode": "یافتن node در canvas", + "missingMedia": { + "audio": "صدا", + "cancelSelection": "لغو انتخاب", + "collapseNodes": "پنهان کردن nodeهای ارجاع‌دهنده", + "confirmSelection": "تأیید انتخاب", + "expandNodes": "نمایش nodeهای ارجاع‌دهنده", + "image": "تصاویر", + "locateNode": "یافتن node", + "missingMediaTitle": "ورودی‌های گمشده", + "or": "یا", + "selectedFromLibrary": "انتخاب شده از کتابخانه", + "uploadFile": "بارگذاری {type}", + "uploaded": "بارگذاری شد", + "uploading": "در حال بارگذاری...", + "useFromLibrary": "استفاده از کتابخانه", + "video": "ویدیوها" + }, "missingModels": { "alreadyExistsInCategory": "این مدل قبلاً در «{category}» وجود دارد", "assetLoadTimeout": "شناسایی مدل زمان‌بر شد. لطفاً workflow را مجدداً بارگذاری کنید.", @@ -3251,6 +3279,7 @@ "termsAgreement": "با ادامه، شما با {terms} و {privacy} Comfy Org موافقت می‌کنید.", "totalDueToday": "مبلغ قابل پرداخت امروز" }, + "refreshCredits": "به‌روزرسانی اعتبارها", "renewsDate": "تمدید در {date}", "required": { "pollingFailed": "فعال‌سازی اشتراک ناموفق بود", @@ -3423,6 +3452,7 @@ "failedToInitiateCreditPurchase": "آغاز خرید اعتبار انجام نشد: {error}", "failedToInitiateSubscription": "آغاز اشتراک انجام نشد: {error}", "failedToLoadBackgroundImage": "بارگذاری تصویر پس‌زمینه انجام نشد", + "failedToLoadHDRI": "بارگذاری فایل HDRI ناموفق بود", "failedToLoadModel": "بارگذاری مدل سه‌بعدی انجام نشد", "failedToPurchaseCredits": "خرید اعتبار انجام نشد: {error}", "failedToQueue": "صف‌بندی انجام نشد", @@ -3458,6 +3488,7 @@ "pleaseSelectOutputNodes": "لطفاً nodeهای خروجی را انتخاب کنید", "unableToGetModelFilePath": "امکان دریافت مسیر فایل مدل وجود ندارد", "unauthorizedDomain": "دامنه شما ({domain}) مجاز به استفاده از این سرویس نیست. لطفاً برای افزودن دامنه خود به لیست سفید با {email} تماس بگیرید.", + "unsupportedHDRIFormat": "فرمت فایل پشتیبانی نمی‌شود. لطفاً یک فایل .hdr یا .exr بارگذاری کنید.", "updateRequested": "درخواست به‌روزرسانی ثبت شد", "useApiKeyTip": "نکته: به ورود عادی دسترسی ندارید؟ از گزینه Comfy API Key استفاده کنید.", "userNotAuthenticated": "کاربر احراز هویت نشده است" diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 5e892115b1..6e77f19f65 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "تصویر" }, + "keep_aspect": { + "name": "حفظ نسبت تصویر", + "tooltip": "آیا برش تصویر برای تطبیق با اندازه خروجی کشیده شود یا با پد سیاه نسبت تصویر حفظ گردد." + }, "output_height": { "name": "ارتفاع خروجی", "tooltip": "ارتفاعی که هر برش به آن تغییر اندازه داده می‌شود." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "ترسیم BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "تصویر" + } + }, + "outputs": { + "0": { + "name": "تصویر خروجی", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "راهنمای DualCFG", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "هیستوگرام تصویر", + "inputs": { + "image": { + "name": "تصویر" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "درخشندگی", + "tooltip": null + }, + "2": { + "name": "قرمز", + "tooltip": null + }, + "3": { + "name": "سبز", + "tooltip": null + }, + "4": { + "name": "آبی", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "معکوس‌سازی تصویر", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "شناسایی RT-DETR", + "inputs": { + "class_name": { + "name": "نام کلاس", + "tooltip": "فیلتر کردن شناسایی‌ها بر اساس کلاس. برای غیرفعال کردن فیلتر، مقدار 'all' را قرار دهید." + }, + "image": { + "name": "تصویر" + }, + "max_detections": { + "name": "حداکثر شناسایی", + "tooltip": "حداکثر تعداد شناسایی برای هر تصویر. به ترتیب امتیاز اطمینان نزولی." + }, + "model": { + "name": "مدل" + }, + "threshold": { + "name": "آستانه" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "برش تصادفی تصاویر", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "تصویر UV", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "تولید ویدیو از تصویر فریم اول، با امکان افزودن تصویر فریم آخر و صدا به صورت اختیاری.", + "display_name": "وان ۲.۷ تبدیل تصویر به ویدیو", + "inputs": { + "audio": { + "name": "صدا", + "tooltip": "صدا برای هدایت تولید ویدیو (مثلاً هماهنگی لب، حرکت هماهنگ با ضرب‌آهنگ). مدت زمان: ۲ تا ۳۰ ثانیه. در صورت عدم ارائه، مدل به طور خودکار موسیقی پس‌زمینه یا افکت صوتی مناسب تولید می‌کند." + }, + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "first_frame": { + "name": "فریم اول", + "tooltip": "تصویر فریم اول. نسبت ابعاد خروجی از این تصویر استخراج می‌شود." + }, + "last_frame": { + "name": "فریم آخر", + "tooltip": "تصویر فریم آخر. مدل ویدیویی با انتقال از فریم اول به فریم آخر تولید می‌کند." + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_negative_prompt": { + "name": "پرامپت منفی" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "prompt_extend": { + "name": "گسترش پرامپت", + "tooltip": "آیا پرامپت با کمک هوش مصنوعی بهبود یابد یا خیر." + }, + "seed": { + "name": "بذر", + "tooltip": "بذر مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "تولید ویدیو با حضور یک شخص یا شیء بر اساس مواد مرجع. پشتیبانی از اجرای تک‌نفره و تعامل چندنفره.", + "display_name": "وان ۲.۷ مرجع به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_negative_prompt": { + "name": "پرامپت منفی" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "بذر", + "tooltip": "بذر مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "تولید ویدیو بر اساس یک پرامپت متنی با استفاده از مدل وان ۲.۷.", + "display_name": "وان ۲.۷ تبدیل متن به ویدیو", + "inputs": { + "audio": { + "name": "صدا", + "tooltip": "صدا برای هدایت تولید ویدیو (مثلاً هماهنگی لب، حرکت مطابق با ضرب). مدت زمان: ۳ تا ۳۰ ثانیه. در صورت عدم ارائه، مدل به طور خودکار موسیقی پس‌زمینه یا افکت صوتی مناسب تولید می‌کند." + }, + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_negative_prompt": { + "name": "پرامپت منفی" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "وضوح" + }, + "prompt_extend": { + "name": "گسترش پرامپت", + "tooltip": "آیا پرامپت با کمک هوش مصنوعی بهبود یابد." + }, + "seed": { + "name": "بذر", + "tooltip": "بذر مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "ادامه دادن یک ویدیو از جایی که متوقف شده است، با امکان کنترل فریم آخر.", + "display_name": "وان ۲.۷ ادامه ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "first_clip": { + "name": "کلیپ اول", + "tooltip": "ویدیوی ورودی برای ادامه دادن. مدت زمان: ۲ تا ۱۰ ثانیه. نسبت تصویر خروجی از این ویدیو گرفته می‌شود." + }, + "last_frame": { + "name": "فریم آخر", + "tooltip": "تصویر فریم آخر. ادامه ویدیو به سمت این فریم انتقال می‌یابد." + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_negative_prompt": { + "name": "پرامپت منفی" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_resolution": { + "name": "وضوح" + }, + "prompt_extend": { + "name": "گسترش پرامپت", + "tooltip": "آیا پرامپت با کمک هوش مصنوعی بهبود یابد." + }, + "seed": { + "name": "بذر", + "tooltip": "بذر مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "ویرایش ویدیو با استفاده از دستور متنی، تصاویر مرجع یا انتقال سبک.", + "display_name": "وان ۲.۷ ویرایش ویدیو", + "inputs": { + "audio_setting": { + "name": "تنظیمات صدا", + "tooltip": "'auto': مدل تصمیم می‌گیرد که آیا صدا بر اساس پرامپت بازتولید شود یا نه. 'origin': صدای اصلی ویدیوی ورودی حفظ می‌شود." + }, + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "وضوح" + }, + "seed": { + "name": "بذر", + "tooltip": "بذر مورد استفاده برای تولید." + }, + "video": { + "name": "ویدیو", + "tooltip": "ویدیویی که باید ویرایش شود." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 90c50ad139..32e0bae97a 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -1331,6 +1331,7 @@ "updating": "Mise à jour", "upload": "Téléverser", "uploadAlreadyInProgress": "Téléversement déjà en cours", + "uploadTimedOut": "Le téléchargement a expiré. Veuillez réessayer.", "usageHint": "Conseil d'utilisation", "use": "Utiliser", "user": "Utilisateur", @@ -1641,9 +1642,18 @@ "exportRecording": "Exporter l'enregistrement", "exportingModel": "Exportation du modèle en cours...", "fov": "FOV", + "hdri": { + "changeFile": "Changer l'HDRI", + "intensity": "Intensité", + "label": "Environnement HDRI", + "removeFile": "Supprimer l'HDRI", + "showAsBackground": "Afficher comme arrière-plan", + "uploadFile": "Télécharger un HDRI (.hdr, .exr)" + }, "light": "Lumière", "lightIntensity": "Intensité de la lumière", "loadingBackgroundImage": "Chargement de l’image d’arrière-plan", + "loadingHDRI": "Chargement de l'HDRI...", "loadingModel": "Chargement du modèle 3D...", "materialMode": "Mode Matériel", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "jeu de données", "debug": "débogage", "deprecated": "déprécié", + "detection": "détection", "edit_models": "edit_models", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "AUCUNE ENTRÉE", "inputsNoneTooltip": "Le nœud n’a pas d’entrées", "locateNode": "Localiser le nœud sur le canevas", + "missingMedia": { + "audio": "Audio", + "cancelSelection": "Annuler la sélection", + "collapseNodes": "Masquer les nœuds de référence", + "confirmSelection": "Confirmer la sélection", + "expandNodes": "Afficher les nœuds de référence", + "image": "Images", + "locateNode": "Localiser le nœud", + "missingMediaTitle": "Entrées manquantes", + "or": "OU", + "selectedFromLibrary": "Sélectionné depuis la bibliothèque", + "uploadFile": "Téléverser {type}", + "uploaded": "Téléversé", + "uploading": "Téléversement en cours...", + "useFromLibrary": "Utiliser depuis la bibliothèque", + "video": "Vidéos" + }, "missingModels": { "alreadyExistsInCategory": "Ce modèle existe déjà dans « {category} »", "assetLoadTimeout": "Le délai de détection du modèle est dépassé. Essayez de recharger le workflow.", @@ -3239,6 +3267,7 @@ "termsAgreement": "En continuant, vous acceptez les {terms} et la {privacy} de Comfy Org.", "totalDueToday": "Total dû aujourd'hui" }, + "refreshCredits": "Actualiser les crédits", "renewsDate": "Renouvellement le {date}", "required": { "pollingFailed": "Échec de l'activation de l'abonnement", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "Échec de l'initiation de l'achat de crédits : {error}", "failedToInitiateSubscription": "Échec de l'initialisation de l'abonnement : {error}", "failedToLoadBackgroundImage": "Échec du chargement de l'image d'arrière-plan", + "failedToLoadHDRI": "Échec du chargement du fichier HDRI", "failedToLoadModel": "Échec du chargement du modèle 3D", "failedToPurchaseCredits": "Échec de l'achat de crédits : {error}", "failedToQueue": "Échec de la mise en file d'attente", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie", "unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle", "unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.", + "unsupportedHDRIFormat": "Format de fichier non pris en charge. Veuillez télécharger un fichier .hdr ou .exr.", "updateRequested": "Mise à jour demandée", "useApiKeyTip": "Astuce : Vous ne pouvez pas accéder à la connexion normale ? Utilisez l’option Clé API Comfy.", "userNotAuthenticated": "Utilisateur non authentifié" diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 1c41e9993e..3d0e770bc2 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "image" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "Déterminer s'il faut étirer le recadrage pour correspondre à la taille de sortie, ou ajouter des pixels noirs pour préserver le ratio d'aspect." + }, "output_height": { "name": "output_height", "tooltip": "Hauteur à laquelle chaque découpe est redimensionnée." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "Dessiner les BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "GuideurDualCFG", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Histogramme d'image", + "inputs": { + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "rvb", + "tooltip": null + }, + "1": { + "name": "luminance", + "tooltip": null + }, + "2": { + "name": "rouge", + "tooltip": null + }, + "3": { + "name": "vert", + "tooltip": null + }, + "4": { + "name": "bleu", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Inverser l'image", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "Détection RT-DETR", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "Filtrer les détections par classe. Définir sur 'all' pour désactiver le filtrage." + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "Nombre maximal de détections à retourner par image. Par ordre décroissant du score de confiance." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "Rogner aléatoirement des images", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "Générez une vidéo à partir d'une image de première image, avec une image de dernière image et un audio optionnels.", + "display_name": "Wan 2.7 Image vers Vidéo", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio pour guider la génération vidéo (ex : synchronisation labiale, mouvement sur le rythme). Durée : 2s-30s. Si non fourni, le modèle génère automatiquement une musique de fond ou des effets sonores adaptés." + }, + "control_after_generate": { + "name": "contrôle après génération" + }, + "first_frame": { + "name": "première image", + "tooltip": "Image de la première image. Le format de sortie est dérivé de cette image." + }, + "last_frame": { + "name": "dernière image", + "tooltip": "Image de la dernière image. Le modèle génère une vidéo passant de la première à la dernière image." + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_negative_prompt": { + "name": "prompt négatif" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "résolution" + }, + "prompt_extend": { + "name": "extension de prompt", + "tooltip": "Permet d'améliorer le prompt avec l'aide de l'IA." + }, + "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 au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "Générez une vidéo mettant en scène une personne ou un objet à partir de références. Prend en charge les performances à un personnage et les interactions multi-personnages.", + "display_name": "Wan 2.7 Référence vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_negative_prompt": { + "name": "prompt négatif" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "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 au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Génère une vidéo à partir d'une invite textuelle en utilisant le modèle Wan 2.7.", + "display_name": "Wan 2.7 Texte en Vidéo", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Audio pour guider la génération vidéo (ex : synchronisation labiale, mouvement sur le rythme). Durée : 3s-30s. Si non fourni, le modèle génère automatiquement une musique de fond ou des effets sonores adaptés." + }, + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_negative_prompt": { + "name": "invite négative" + }, + "model_prompt": { + "name": "invite" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "prompt_extend": { + "name": "extension d'invite", + "tooltip": "Améliorer l'invite avec l'assistance de l'IA." + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "Continue une vidéo à partir de l'endroit où elle s'est arrêtée, avec un contrôle optionnel de la dernière image.", + "display_name": "Wan 2.7 Continuation Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "first_clip": { + "name": "premier clip", + "tooltip": "Vidéo d'entrée à continuer. Durée : 2s-10s. Le ratio de sortie est dérivé de cette vidéo." + }, + "last_frame": { + "name": "dernière image", + "tooltip": "Image de la dernière frame. La continuation effectuera une transition vers cette image." + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_negative_prompt": { + "name": "invite négative" + }, + "model_prompt": { + "name": "invite" + }, + "model_resolution": { + "name": "résolution" + }, + "prompt_extend": { + "name": "extension d'invite", + "tooltip": "Améliorer l'invite avec l'assistance de l'IA." + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "Éditez une vidéo à l'aide d'instructions textuelles, d'images de référence ou de transfert de style.", + "display_name": "Wan 2.7 Édition Vidéo", + "inputs": { + "audio_setting": { + "name": "paramètre audio", + "tooltip": "'auto' : le modèle décide de régénérer ou non l'audio selon l'invite. 'origin' : préserve l'audio original de la vidéo d'entrée." + }, + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_prompt": { + "name": "invite" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "video": { + "name": "vidéo", + "tooltip": "La vidéo à éditer." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 5568940e7f..6f8caaa9f4 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -1331,6 +1331,7 @@ "updating": "更新中", "upload": "アップロード", "uploadAlreadyInProgress": "アップロードはすでに進行中です", + "uploadTimedOut": "アップロードがタイムアウトしました。もう一度お試しください。", "usageHint": "使用ヒント", "use": "使用", "user": "ユーザー", @@ -1641,9 +1642,18 @@ "exportRecording": "録画をエクスポート", "exportingModel": "モデルをエクスポート中...", "fov": "FOV", + "hdri": { + "changeFile": "HDRIを変更", + "intensity": "強度", + "label": "HDRI環境", + "removeFile": "HDRIを削除", + "showAsBackground": "背景として表示", + "uploadFile": "HDRIをアップロード(.hdr、.exr)" + }, "light": "ライト", "lightIntensity": "光の強度", "loadingBackgroundImage": "背景画像を読み込んでいます", + "loadingHDRI": "HDRIを読み込み中...", "loadingModel": "3Dモデルを読み込んでいます...", "materialMode": "マテリアルモード", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "データセット", "debug": "デバッグ", "deprecated": "非推奨", + "detection": "検出", "edit_models": "モデル編集", "flux": "flux", "gligen": "グライジェン", @@ -2526,6 +2537,23 @@ "inputsNone": "入力なし", "inputsNoneTooltip": "このノードには入力がありません", "locateNode": "キャンバス上でノードを探す", + "missingMedia": { + "audio": "音声", + "cancelSelection": "選択をキャンセル", + "collapseNodes": "参照ノードを非表示", + "confirmSelection": "選択を確定", + "expandNodes": "参照ノードを表示", + "image": "画像", + "locateNode": "ノードを特定", + "missingMediaTitle": "入力がありません", + "or": "または", + "selectedFromLibrary": "ライブラリから選択済み", + "uploadFile": "{type}をアップロード", + "uploaded": "アップロード完了", + "uploading": "アップロード中...", + "useFromLibrary": "ライブラリから使用", + "video": "動画" + }, "missingModels": { "alreadyExistsInCategory": "このモデルはすでに「{category}」に存在します", "assetLoadTimeout": "モデルの検出がタイムアウトしました。ワークフローを再読み込みしてください。", @@ -3239,6 +3267,7 @@ "termsAgreement": "続行することで、Comfy Orgの{terms}および{privacy}に同意したものとみなされます。", "totalDueToday": "本日のお支払い合計" }, + "refreshCredits": "クレジットを更新", "renewsDate": "{date} に更新", "required": { "pollingFailed": "サブスクリプションの有効化に失敗しました", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "クレジット購入の開始に失敗しました: {error}", "failedToInitiateSubscription": "サブスクリプションの開始に失敗しました: {error}", "failedToLoadBackgroundImage": "背景画像の読み込みに失敗しました", + "failedToLoadHDRI": "HDRIファイルの読み込みに失敗しました", "failedToLoadModel": "3Dモデルの読み込みに失敗しました", "failedToPurchaseCredits": "クレジットの購入に失敗しました: {error}", "failedToQueue": "キューに追加できませんでした", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "出力ノードを選択してください", "unableToGetModelFilePath": "モデルファイルのパスを取得できません", "unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。", + "unsupportedHDRIFormat": "サポートされていないファイル形式です。.hdrまたは.exrファイルをアップロードしてください。", "updateRequested": "更新が要求されました", "useApiKeyTip": "ヒント:通常のログインにアクセスできませんか?Comfy APIキーオプションを使用してください。", "userNotAuthenticated": "ユーザーが認証されていません" diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 33803e4389..2131fb7390 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "画像" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "クロップを出力サイズに合わせて引き伸ばすか、アスペクト比を維持するために黒いピクセルでパディングするかを選択します。" + }, "output_height": { "name": "出力高さ", "tooltip": "各切り抜き画像がリサイズされる高さ。" @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "バウンディングボックスを描画", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "デュアルCFGガイダー", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "画像ヒストグラム", + "inputs": { + "image": { + "name": "画像" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "輝度", + "tooltip": null + }, + "2": { + "name": "赤", + "tooltip": null + }, + "3": { + "name": "緑", + "tooltip": null + }, + "4": { + "name": "青", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "画像を反転", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR 検出", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "クラスで検出結果をフィルタリングします。「all」に設定するとフィルタリングを無効にします。" + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "画像ごとに返す検出の最大数。信頼度スコアの高い順に並びます。" + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "ランダムクロップ画像", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv画像", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "最初のフレーム画像から動画を生成します。オプションで最後のフレーム画像や音声も指定できます。", + "display_name": "Wan 2.7 画像から動画へ", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "動画生成を制御する音声(例:リップシンク、ビートに合わせた動き)。長さ:2秒~30秒。指定しない場合は、モデルが自動的に合うBGMや効果音を生成します。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "最初のフレーム画像。この画像から出力動画のアスペクト比が決まります。" + }, + "last_frame": { + "name": "last_frame", + "tooltip": "最後のフレーム画像。最初から最後のフレームへと遷移する動画を生成します。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "AIによるプロンプトの強化を行うかどうか。" + }, + "seed": { + "name": "seed", + "tooltip": "生成に使用するシード値。" + }, + "watermark": { + "name": "watermark", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "リファレンス素材から人物や物体を特徴とする動画を生成します。単一キャラクターの演技や複数キャラクターのインタラクションに対応しています。", + "display_name": "Wan 2.7 リファレンスから動画へ", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "生成に使用するシード値。" + }, + "watermark": { + "name": "watermark", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Wan 2.7モデルを使用してテキストプロンプトに基づいたビデオを生成します。", + "display_name": "Wan 2.7 テキストからビデオへ", + "inputs": { + "audio": { + "name": "オーディオ", + "tooltip": "ビデオ生成を駆動する音声(例:リップシンク、ビートに合わせた動き)。長さ:3秒~30秒。未指定の場合、モデルが自動的に合うBGMや効果音を生成します。" + }, + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "長さ" + }, + "model_negative_prompt": { + "name": "ネガティブプロンプト" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "アスペクト比" + }, + "model_resolution": { + "name": "解像度" + }, + "prompt_extend": { + "name": "プロンプト拡張", + "tooltip": "AIアシストでプロンプトを強化するかどうか。" + }, + "seed": { + "name": "シード値", + "tooltip": "生成に使用するシード値。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "ビデオの続きから生成を行い、オプションでラストフレーム制御も可能です。", + "display_name": "Wan 2.7 ビデオ継続生成", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "first_clip": { + "name": "最初のクリップ", + "tooltip": "継続元となる入力ビデオ。長さ:2秒~10秒。出力のアスペクト比はこのビデオから取得されます。" + }, + "last_frame": { + "name": "ラストフレーム", + "tooltip": "ラストフレーム画像。継続生成はこのフレームへと遷移します。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "長さ" + }, + "model_negative_prompt": { + "name": "ネガティブプロンプト" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_resolution": { + "name": "解像度" + }, + "prompt_extend": { + "name": "プロンプト拡張", + "tooltip": "AIアシストでプロンプトを強化するかどうか。" + }, + "seed": { + "name": "シード値", + "tooltip": "生成に使用するシード値。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "テキスト指示、参照画像、またはスタイル転送を使ってビデオを編集します。", + "display_name": "Wan 2.7 ビデオ編集", + "inputs": { + "audio_setting": { + "name": "オーディオ設定", + "tooltip": "「auto」:プロンプトに基づきモデルが音声再生成の有無を判断。「origin」:入力ビデオの元の音声を保持。" + }, + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "長さ" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "アスペクト比" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード値", + "tooltip": "生成に使用するシード値。" + }, + "video": { + "name": "ビデオ", + "tooltip": "編集対象のビデオ。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 7b5ad9fced..d69456ad05 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -1331,6 +1331,7 @@ "updating": "업데이트 중", "upload": "업로드", "uploadAlreadyInProgress": "업로드가 이미 진행 중입니다", + "uploadTimedOut": "업로드 시간이 초과되었습니다. 다시 시도해 주세요.", "usageHint": "사용 힌트", "use": "사용", "user": "사용자", @@ -1641,9 +1642,18 @@ "exportRecording": "녹화 내보내기", "exportingModel": "모델 내보내기 중...", "fov": "FOV", + "hdri": { + "changeFile": "HDRI 변경", + "intensity": "강도", + "label": "HDRI 환경", + "removeFile": "HDRI 제거", + "showAsBackground": "배경으로 표시", + "uploadFile": "HDRI 업로드 (.hdr, .exr)" + }, "light": "빛", "lightIntensity": "조명 강도", "loadingBackgroundImage": "배경 이미지 불러오는 중", + "loadingHDRI": "HDRI 불러오는 중...", "loadingModel": "3D 모델 로딩 중...", "materialMode": "재질 모드", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "데이터셋", "debug": "디버그", "deprecated": "지원 중단", + "detection": "감지", "edit_models": "edit_models", "flux": "flux", "gligen": "글리젠", @@ -2526,6 +2537,23 @@ "inputsNone": "입력 없음", "inputsNoneTooltip": "노드에 입력이 없습니다", "locateNode": "캔버스에서 노드 찾기", + "missingMedia": { + "audio": "오디오", + "cancelSelection": "선택 취소", + "collapseNodes": "참조 노드 숨기기", + "confirmSelection": "선택 확인", + "expandNodes": "참조 노드 표시", + "image": "이미지", + "locateNode": "노드 위치 찾기", + "missingMediaTitle": "입력 누락", + "or": "또는", + "selectedFromLibrary": "라이브러리에서 선택됨", + "uploadFile": "{type} 업로드", + "uploaded": "업로드 완료", + "uploading": "업로드 중...", + "useFromLibrary": "라이브러리에서 사용", + "video": "비디오" + }, "missingModels": { "alreadyExistsInCategory": "이 모델은 이미 \"{category}\"에 존재합니다", "assetLoadTimeout": "모델 감지 시간이 초과되었습니다. 워크플로우를 다시 불러와 보세요.", @@ -3239,6 +3267,7 @@ "termsAgreement": "계속 진행하면 Comfy Org의 {terms} 및 {privacy}에 동의하게 됩니다.", "totalDueToday": "오늘 결제 금액 합계" }, + "refreshCredits": "크레딧 새로고침", "renewsDate": "{date}에 갱신됨", "required": { "pollingFailed": "구독 활성화에 실패했습니다", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "크레딧 구매를 시작하지 못했습니다: {error}", "failedToInitiateSubscription": "구독을 시작하지 못함: {error}", "failedToLoadBackgroundImage": "배경 이미지를 로드하지 못함", + "failedToLoadHDRI": "HDRI 파일을 불러오지 못했습니다", "failedToLoadModel": "3D 모델을 로드하지 못함", "failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}", "failedToQueue": "대기열 추가 실패", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "출력 노드를 선택해 주세요", "unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다", "unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.", + "unsupportedHDRIFormat": "지원되지 않는 파일 형식입니다. .hdr 또는 .exr 파일을 업로드해 주세요.", "updateRequested": "업데이트 요청됨", "useApiKeyTip": "팁: 일반 로그인을 사용할 수 없나요? Comfy API Key 옵션을 사용하세요.", "userNotAuthenticated": "사용자가 인증되지 않았습니다" diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index 0d2889ca72..f2e1920251 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "image" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "출력 크기에 맞게 자르기를 늘릴지, 아니면 종횡비를 유지하기 위해 검은색 픽셀로 채울지 여부입니다." + }, "output_height": { "name": "output_height", "tooltip": "각 잘라낸 이미지의 높이로 크기가 조정됩니다." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "BBoxes 그리기", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "이중 CFG 가이드", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "이미지 히스토그램", + "inputs": { + "image": { + "name": "이미지" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "휘도", + "tooltip": null + }, + "2": { + "name": "레드", + "tooltip": null + }, + "3": { + "name": "그린", + "tooltip": null + }, + "4": { + "name": "블루", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "이미지 반전", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR 감지", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "클래스로 감지 결과를 필터링합니다. 필터링을 비활성화하려면 'all'로 설정하세요." + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "이미지당 반환할 최대 감지 개수입니다. 신뢰도 점수 내림차순으로 정렬됩니다." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "무작위 이미지 자르기", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "첫 프레임 이미지를 기반으로 비디오를 생성합니다. 마지막 프레임 이미지와 오디오를 선택적으로 추가할 수 있습니다.", + "display_name": "Wan 2.7 이미지 → 비디오", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "비디오 생성에 사용할 오디오입니다(예: 립싱크, 비트에 맞춘 동작). 길이: 2초~30초. 제공하지 않으면 모델이 자동으로 배경 음악 또는 효과음을 생성합니다." + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "첫 프레임 이미지입니다. 출력 비율은 이 이미지에서 결정됩니다." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "마지막 프레임 이미지입니다. 모델이 첫 프레임에서 마지막 프레임으로 전환되는 비디오를 생성합니다." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "AI의 도움으로 프롬프트를 확장할지 여부입니다." + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드 값입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI가 생성한 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "레퍼런스 자료를 기반으로 인물 또는 오브젝트가 등장하는 비디오를 생성합니다. 단일 캐릭터 연기와 다중 캐릭터 상호작용을 지원합니다.", + "display_name": "Wan 2.7 레퍼런스 → 비디오", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드 값입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI가 생성한 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Wan 2.7 모델을 사용하여 텍스트 프롬프트 기반으로 비디오를 생성합니다.", + "display_name": "Wan 2.7 텍스트 투 비디오", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "비디오 생성에 사용할 오디오(예: 립싱크, 비트에 맞춘 동작). 길이: 3초~30초. 제공하지 않으면 모델이 자동으로 배경 음악이나 효과음을 생성합니다." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "AI의 도움으로 프롬프트를 확장할지 여부입니다." + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI가 생성한 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "비디오가 끝난 지점부터 이어서 생성하며, 마지막 프레임 제어도 선택적으로 지원합니다.", + "display_name": "Wan 2.7 비디오 연속 생성", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_clip": { + "name": "first_clip", + "tooltip": "이어 생성할 입력 비디오입니다. 길이: 2초~10초. 출력 비율은 이 비디오에서 파생됩니다." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "마지막 프레임 이미지입니다. 이어지는 비디오는 이 프레임으로 전환됩니다." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "AI의 도움으로 프롬프트를 확장할지 여부입니다." + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI가 생성한 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "텍스트 지시, 참조 이미지 또는 스타일 전송을 사용하여 비디오를 편집합니다.", + "display_name": "Wan 2.7 비디오 편집", + "inputs": { + "audio_setting": { + "name": "audio_setting", + "tooltip": "'auto': 프롬프트에 따라 오디오를 재생성할지 모델이 결정합니다. 'origin': 입력 비디오의 원본 오디오를 유지합니다." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "video": { + "name": "video", + "tooltip": "편집할 비디오입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI가 생성한 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "완애니메이트투비디오", "inputs": { diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 131e5e338f..7f0f75be17 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -1331,6 +1331,7 @@ "updating": "Atualizando {id}", "upload": "Enviar", "uploadAlreadyInProgress": "O upload já está em andamento", + "uploadTimedOut": "O envio excedeu o tempo limite. Por favor, tente novamente.", "usageHint": "Dica de uso", "use": "Usar", "user": "Usuário", @@ -1641,9 +1642,18 @@ "exportRecording": "Exportar Gravação", "exportingModel": "Exportando modelo...", "fov": "Campo de Visão (FOV)", + "hdri": { + "changeFile": "Alterar HDRI", + "intensity": "Intensidade", + "label": "Ambiente HDRI", + "removeFile": "Remover HDRI", + "showAsBackground": "Exibir como fundo", + "uploadFile": "Enviar HDRI (.hdr, .exr)" + }, "light": "Luz", "lightIntensity": "Intensidade da Luz", "loadingBackgroundImage": "Carregando Imagem de Fundo", + "loadingHDRI": "Carregando HDRI...", "loadingModel": "Carregando Modelo 3D...", "materialMode": "Modo de Material", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "conjunto_de_dados", "debug": "depuração", "deprecated": "obsoleto", + "detection": "detecção", "edit_models": "editar_modelos", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "SEM ENTRADAS", "inputsNoneTooltip": "O nó não possui entradas", "locateNode": "Localizar nó no canvas", + "missingMedia": { + "audio": "Áudio", + "cancelSelection": "Cancelar seleção", + "collapseNodes": "Ocultar nós de referência", + "confirmSelection": "Confirmar seleção", + "expandNodes": "Mostrar nós de referência", + "image": "Imagens", + "locateNode": "Localizar nó", + "missingMediaTitle": "Entradas Ausentes", + "or": "OU", + "selectedFromLibrary": "Selecionado da biblioteca", + "uploadFile": "Enviar {type}", + "uploaded": "Enviado", + "uploading": "Enviando...", + "useFromLibrary": "Usar da Biblioteca", + "video": "Vídeos" + }, "missingModels": { "alreadyExistsInCategory": "Este modelo já existe em \"{category}\"", "assetLoadTimeout": "Tempo esgotado na detecção do modelo. Tente recarregar o fluxo de trabalho.", @@ -3251,6 +3279,7 @@ "termsAgreement": "Ao continuar, você concorda com os {terms} e {privacy} da Comfy Org.", "totalDueToday": "Total devido hoje" }, + "refreshCredits": "Atualizar créditos", "renewsDate": "Renova em {date}", "required": { "pollingFailed": "Falha ao ativar a assinatura", @@ -3423,6 +3452,7 @@ "failedToInitiateCreditPurchase": "Falha ao iniciar compra de créditos: {error}", "failedToInitiateSubscription": "Falha ao iniciar assinatura: {error}", "failedToLoadBackgroundImage": "Falha ao carregar imagem de fundo", + "failedToLoadHDRI": "Falha ao carregar o arquivo HDRI", "failedToLoadModel": "Falha ao carregar modelo 3D", "failedToPurchaseCredits": "Falha ao comprar créditos: {error}", "failedToQueue": "Falha ao enfileirar", @@ -3458,6 +3488,7 @@ "pleaseSelectOutputNodes": "Por favor, selecione os nós de saída", "unableToGetModelFilePath": "Não foi possível obter o caminho do arquivo do modelo", "unauthorizedDomain": "Seu domínio {domain} não está autorizado a usar este serviço. Por favor, entre em contato com {email} para adicionar seu domínio à lista de permissões.", + "unsupportedHDRIFormat": "Formato de arquivo não suportado. Por favor, envie um arquivo .hdr ou .exr.", "updateRequested": "Atualização solicitada", "useApiKeyTip": "Dica: Não consegue acessar o login normal? Use a opção Comfy API Key.", "userNotAuthenticated": "Usuário não autenticado" diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index c8aa44a526..90e48dfc45 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "imagem" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "Escolher entre esticar o recorte para caber no tamanho de saída ou preencher com pixels pretos para preservar a proporção." + }, "output_height": { "name": "altura_de_saida", "tooltip": "Altura para a qual cada recorte será redimensionado." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "Desenhar BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "Guia DualCFG", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Histograma de Imagem", + "inputs": { + "image": { + "name": "imagem" + } + }, + "outputs": { + "0": { + "name": "rgb", + "tooltip": null + }, + "1": { + "name": "luminância", + "tooltip": null + }, + "2": { + "name": "vermelho", + "tooltip": null + }, + "3": { + "name": "verde", + "tooltip": null + }, + "4": { + "name": "azul", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Inverter Imagem", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR Detectar", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "Filtrar detecções por classe. Defina como 'all' para desabilitar o filtro." + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "Número máximo de detecções a retornar por imagem. Em ordem decrescente de confiança." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "Corte Aleatório de Imagens", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "imagem UV", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "Gere um vídeo a partir de uma imagem do primeiro quadro, com opção de imagem do último quadro e áudio.", + "display_name": "Wan 2.7 Imagem para Vídeo", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Áudio para direcionar a geração do vídeo (ex: sincronização labial, movimento no ritmo da batida). Duração: 2s-30s. Se não for fornecido, o modelo gera automaticamente música de fundo ou efeitos sonoros correspondentes." + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "Imagem do primeiro quadro. A proporção de aspecto de saída é derivada desta imagem." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Imagem do último quadro. O modelo gera um vídeo fazendo a transição do primeiro para o último quadro." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Se deseja aprimorar o prompt com assistência de IA." + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "watermark": { + "name": "watermark", + "tooltip": "Se deseja adicionar uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "Gere um vídeo apresentando uma pessoa ou objeto a partir de materiais de referência. Suporta performances de personagem único e interações entre múltiplos personagens.", + "display_name": "Wan 2.7 Referência para Vídeo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "watermark": { + "name": "watermark", + "tooltip": "Se deseja adicionar uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Gera um vídeo com base em um prompt de texto usando o modelo Wan 2.7.", + "display_name": "Wan 2.7 Texto para Vídeo", + "inputs": { + "audio": { + "name": "áudio", + "tooltip": "Áudio para direcionar a geração do vídeo (ex: sincronização labial, movimento no ritmo da música). Duração: 3s-30s. Se não fornecido, o modelo gera automaticamente música de fundo ou efeitos sonoros compatíveis." + }, + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duração" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "proporção" + }, + "model_resolution": { + "name": "resolução" + }, + "prompt_extend": { + "name": "estender_prompt", + "tooltip": "Se deseja aprimorar o prompt com assistência de IA." + }, + "seed": { + "name": "semente", + "tooltip": "Semente a ser usada para a geração." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deseja adicionar uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "Continue um vídeo de onde parou, com controle opcional do último quadro.", + "display_name": "Wan 2.7 Continuação de Vídeo", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "first_clip": { + "name": "primeiro_clip", + "tooltip": "Vídeo de entrada para continuar. Duração: 2s-10s. A proporção de aspecto de saída é derivada deste vídeo." + }, + "last_frame": { + "name": "último_quadro", + "tooltip": "Imagem do último quadro. A continuação fará a transição para este quadro." + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duração" + }, + "model_negative_prompt": { + "name": "prompt_negativo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolução" + }, + "prompt_extend": { + "name": "estender_prompt", + "tooltip": "Se deseja aprimorar o prompt com assistência de IA." + }, + "seed": { + "name": "semente", + "tooltip": "Semente a ser usada para a geração." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deseja adicionar uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "Edite um vídeo usando instruções de texto, imagens de referência ou transferência de estilo.", + "display_name": "Wan 2.7 Edição de Vídeo", + "inputs": { + "audio_setting": { + "name": "configuração_de_áudio", + "tooltip": "'auto': o modelo decide se deve regenerar o áudio com base no prompt. 'origin': preserva o áudio original do vídeo de entrada." + }, + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duração" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "proporção" + }, + "model_resolution": { + "name": "resolução" + }, + "seed": { + "name": "semente", + "tooltip": "Semente a ser usada para a geração." + }, + "video": { + "name": "vídeo", + "tooltip": "O vídeo a ser editado." + }, + "watermark": { + "name": "marca_d'água", + "tooltip": "Se deseja adicionar uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 0a6b1c7ba4..750898e8f5 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -1331,6 +1331,7 @@ "updating": "Обновление", "upload": "Загрузить", "uploadAlreadyInProgress": "Загрузка уже выполняется", + "uploadTimedOut": "Время загрузки истекло. Пожалуйста, попробуйте еще раз.", "usageHint": "Подсказка по использованию", "use": "Использовать", "user": "Пользователь", @@ -1641,9 +1642,18 @@ "exportRecording": "Экспортировать запись", "exportingModel": "Экспорт модели...", "fov": "Угол обзора", + "hdri": { + "changeFile": "Сменить HDRI", + "intensity": "Интенсивность", + "label": "HDRI-окружение", + "removeFile": "Удалить HDRI", + "showAsBackground": "Показать как фон", + "uploadFile": "Загрузить HDRI (.hdr, .exr)" + }, "light": "Свет", "lightIntensity": "Интенсивность света", "loadingBackgroundImage": "Загрузка фонового изображения", + "loadingHDRI": "Загрузка HDRI...", "loadingModel": "Загрузка 3D модели...", "materialMode": "Режим Материала", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "dataset", "debug": "отладка", "deprecated": "устаревший", + "detection": "детекция", "edit_models": "редактировать_модели", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "НЕТ ВХОДОВ", "inputsNoneTooltip": "Узел не имеет входов", "locateNode": "Найти узел на холсте", + "missingMedia": { + "audio": "Аудио", + "cancelSelection": "Отменить выбор", + "collapseNodes": "Скрыть ссылающиеся узлы", + "confirmSelection": "Подтвердить выбор", + "expandNodes": "Показать ссылающиеся узлы", + "image": "Изображения", + "locateNode": "Найти узел", + "missingMediaTitle": "Отсутствующие входные данные", + "or": "ИЛИ", + "selectedFromLibrary": "Выбрано из библиотеки", + "uploadFile": "Загрузить {type}", + "uploaded": "Загружено", + "uploading": "Загрузка...", + "useFromLibrary": "Использовать из библиотеки", + "video": "Видео" + }, "missingModels": { "alreadyExistsInCategory": "Эта модель уже существует в «{category}»", "assetLoadTimeout": "Время ожидания обнаружения модели истекло. Попробуйте перезагрузить рабочий процесс.", @@ -3239,6 +3267,7 @@ "termsAgreement": "Продолжая, вы соглашаетесь с {terms} и {privacy} Comfy Org.", "totalDueToday": "Итого к оплате сегодня" }, + "refreshCredits": "Обновить кредиты", "renewsDate": "Обновляется {date}", "required": { "pollingFailed": "Не удалось активировать подписку", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "Не удалось начать покупку кредитов: {error}", "failedToInitiateSubscription": "Не удалось инициировать подписку: {error}", "failedToLoadBackgroundImage": "Не удалось загрузить фоновое изображение", + "failedToLoadHDRI": "Не удалось загрузить файл HDRI", "failedToLoadModel": "Не удалось загрузить 3D-модель", "failedToPurchaseCredits": "Не удалось купить кредиты: {error}", "failedToQueue": "Не удалось поставить в очередь", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы", "unableToGetModelFilePath": "Не удалось получить путь к файлу модели", "unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.", + "unsupportedHDRIFormat": "Неподдерживаемый формат файла. Пожалуйста, загрузите файл .hdr или .exr.", "updateRequested": "Запрошено обновление", "useApiKeyTip": "Совет: Нет доступа к обычному входу? Используйте опцию Comfy API Key.", "userNotAuthenticated": "Пользователь не аутентифицирован" diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index 3725445952..ed14451dfe 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "image" }, + "keep_aspect": { + "name": "keep_aspect", + "tooltip": "Растягивать ли обрезку до нужного размера или добавлять чёрные поля для сохранения соотношения сторон." + }, "output_height": { "name": "output_height", "tooltip": "Высота, до которой изменяется каждый обрезанный фрагмент." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "Нарисовать BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "image" + } + }, + "outputs": { + "0": { + "name": "out_image", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "Двойной CFG Гид", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Гистограмма изображения", + "inputs": { + "image": { + "name": "изображение" + } + }, + "outputs": { + "0": { + "name": "rgb", + "tooltip": null + }, + "1": { + "name": "яркость", + "tooltip": null + }, + "2": { + "name": "красный", + "tooltip": null + }, + "3": { + "name": "зелёный", + "tooltip": null + }, + "4": { + "name": "синий", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Инвертировать изображение", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR Обнаружение", + "inputs": { + "class_name": { + "name": "class_name", + "tooltip": "Фильтровать обнаружения по классу. Установите 'all', чтобы отключить фильтрацию." + }, + "image": { + "name": "image" + }, + "max_detections": { + "name": "max_detections", + "tooltip": "Максимальное количество обнаружений на изображение. В порядке убывания оценки уверенности." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "threshold" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "Случайное кадрирование изображений", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "Создайте видео из изображения первого кадра с возможностью добавить изображение последнего кадра и аудио.", + "display_name": "Wan 2.7 Изображение в Видео", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Аудио для управления генерацией видео (например, синхронизация губ, движение в ритм музыки). Длительность: 2–30 сек. Если не указано, модель автоматически сгенерирует подходящую фоновую музыку или звуковые эффекты." + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "Изображение первого кадра. Соотношение сторон результата определяется этим изображением." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Изображение последнего кадра. Модель создаёт видео с переходом от первого к последнему кадру." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Улучшить ли промпт с помощью AI." + }, + "seed": { + "name": "seed", + "tooltip": "Сид для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "Создайте видео с участием человека или объекта на основе референсных материалов. Поддерживает выступления одного персонажа и взаимодействие нескольких персонажей.", + "display_name": "Wan 2.7 Референс в Видео", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Сид для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Генерирует видео на основе текстового запроса с использованием модели Wan 2.7.", + "display_name": "Wan 2.7 Текст в видео", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Аудио для управления генерацией видео (например, синхронизация губ, движение в ритм музыки). Длительность: 3–30 сек. Если не указано, модель автоматически сгенерирует подходящую фоновую музыку или звуковые эффекты." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Улучшать ли запрос с помощью ИИ." + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли водяной знак, сгенерированный ИИ, к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "Продолжает видео с того места, где оно закончилось, с возможностью контроля последнего кадра.", + "display_name": "Wan 2.7 Продолжение видео", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_clip": { + "name": "first_clip", + "tooltip": "Входное видео для продолжения. Длительность: 2–10 сек. Соотношение сторон результата определяется этим видео." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Изображение последнего кадра. Продолжение будет переходить к этому кадру." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "Улучшать ли запрос с помощью ИИ." + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли водяной знак, сгенерированный ИИ, к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "Редактируйте видео с помощью текстовых инструкций, референсных изображений или переноса стиля.", + "display_name": "Wan 2.7 Редактирование видео", + "inputs": { + "audio_setting": { + "name": "audio_setting", + "tooltip": "'auto': модель решает, нужно ли перегенерировать аудио на основе запроса. 'origin': сохранить оригинальное аудио из входного видео." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "video": { + "name": "video", + "tooltip": "Видео для редактирования." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли водяной знак, сгенерированный ИИ, к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 5ddf18f986..f8f70857af 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -1331,6 +1331,7 @@ "updating": "{id} güncelleniyor", "upload": "Yükle", "uploadAlreadyInProgress": "Yükleme zaten devam ediyor", + "uploadTimedOut": "Yükleme zaman aşımına uğradı. Lütfen tekrar deneyin.", "usageHint": "Kullanım ipucu", "use": "Kullan", "user": "Kullanıcı", @@ -1641,9 +1642,18 @@ "exportRecording": "Kaydı Dışa Aktar", "exportingModel": "Model dışa aktarılıyor...", "fov": "FOV", + "hdri": { + "changeFile": "HDRI Değiştir", + "intensity": "Yoğunluk", + "label": "HDRI Ortamı", + "removeFile": "HDRI Kaldır", + "showAsBackground": "Arka Plan Olarak Göster", + "uploadFile": "HDRI Yükle (.hdr, .exr)" + }, "light": "Işık", "lightIntensity": "Işık Yoğunluğu", "loadingBackgroundImage": "Arka Plan Resmi Yükleniyor", + "loadingHDRI": "HDRI Yükleniyor...", "loadingModel": "3D Model Yükleniyor...", "materialMode": "Malzeme Modu", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "veri seti", "debug": "hata ayıklama", "deprecated": "kullanımdan kaldırılmış", + "detection": "tespit", "edit_models": "modelleri_düzenle", "flux": "flux", "gligen": "gligen", @@ -2526,6 +2537,23 @@ "inputsNone": "GİRİŞ YOK", "inputsNoneTooltip": "Düğümün girişi yok", "locateNode": "Düğümü tuvalde bul", + "missingMedia": { + "audio": "Ses", + "cancelSelection": "Seçimi iptal et", + "collapseNodes": "Referans veren düğümleri gizle", + "confirmSelection": "Seçimi onayla", + "expandNodes": "Referans veren düğümleri göster", + "image": "Görseller", + "locateNode": "Düğümü bul", + "missingMediaTitle": "Eksik Girdiler", + "or": "VEYA", + "selectedFromLibrary": "Kütüphaneden seçildi", + "uploadFile": "{type} Yükle", + "uploaded": "Yüklendi", + "uploading": "Yükleniyor...", + "useFromLibrary": "Kütüphaneden Kullan", + "video": "Videolar" + }, "missingModels": { "alreadyExistsInCategory": "Bu model zaten \"{category}\" içinde mevcut", "assetLoadTimeout": "Model algılama zaman aşımına uğradı. Lütfen iş akışını yeniden yüklemeyi deneyin.", @@ -3239,6 +3267,7 @@ "termsAgreement": "Devam ederek Comfy Org'un {terms} ve {privacy} politikasını kabul etmiş olursunuz.", "totalDueToday": "Bugün ödenecek toplam tutar" }, + "refreshCredits": "Kredileri yenile", "renewsDate": "{date} tarihinde yenilenir", "required": { "pollingFailed": "Abonelik etkinleştirme başarısız oldu", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "Kredi satın alma başlatılamadı: {error}", "failedToInitiateSubscription": "Abonelik başlatılamadı: {error}", "failedToLoadBackgroundImage": "Arka plan görseli yüklenemedi", + "failedToLoadHDRI": "HDRI dosyası yüklenemedi", "failedToLoadModel": "3B model yüklenemedi", "failedToPurchaseCredits": "Kredi satın alınamadı: {error}", "failedToQueue": "Kuyruğa alınamadı", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "Lütfen çıktı düğümlerini seçin", "unableToGetModelFilePath": "Model dosyası yolu alınamıyor", "unauthorizedDomain": "{domain} alan adınız bu hizmeti kullanma yetkisine sahip değil. Alan adınızı beyaz listeye eklemek için lütfen {email} ile iletişime geçin.", + "unsupportedHDRIFormat": "Desteklenmeyen dosya formatı. Lütfen .hdr veya .exr dosyası yükleyin.", "updateRequested": "Güncelleme istendi", "useApiKeyTip": "İpucu: Normal girişe erişemiyor musunuz? Comfy API Anahtarı seçeneğini kullanın.", "userNotAuthenticated": "Kullanıcı doğrulanmadı" diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 52e6435c35..01b1f5efe5 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "görüntü" }, + "keep_aspect": { + "name": "oranı_koru", + "tooltip": "Kırpmanın çıktı boyutuna sığması için esnetilip esnetilmeyeceği veya en-boy oranını korumak için siyah piksellerle doldurulup doldurulmayacağı." + }, "output_height": { "name": "çıktı_yüksekliği", "tooltip": "Her kırpmanın yeniden boyutlandırılacağı yükseklik." @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "BBox'ları Çiz", + "inputs": { + "bboxes": { + "name": "bboxlar" + }, + "image": { + "name": "görsel" + } + }, + "outputs": { + "0": { + "name": "çıktı_görseli", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "İkili CFG Rehberi", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "Görüntü Histogramı", + "inputs": { + "image": { + "name": "görüntü" + } + }, + "outputs": { + "0": { + "name": "rgb", + "tooltip": null + }, + "1": { + "name": "parlaklık", + "tooltip": null + }, + "2": { + "name": "kırmızı", + "tooltip": null + }, + "3": { + "name": "yeşil", + "tooltip": null + }, + "4": { + "name": "mavi", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "Görüntüyü Ters Çevir", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR Tespit", + "inputs": { + "class_name": { + "name": "sınıf_adı", + "tooltip": "Tespitleri sınıfa göre filtrele. Filtrelemeyi devre dışı bırakmak için 'all' olarak ayarlayın." + }, + "image": { + "name": "görsel" + }, + "max_detections": { + "name": "maksimum_tespit", + "tooltip": "Görsel başına döndürülecek en fazla tespit sayısı. Güven puanına göre azalan sırada." + }, + "model": { + "name": "model" + }, + "threshold": { + "name": "eşik" + } + }, + "outputs": { + "0": { + "name": "bboxlar", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "Rastgele Kırpılmış Görseller", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_görüntüsü", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "İlk kare görüntüsünden bir video oluşturun, isteğe bağlı olarak son kare görüntüsü ve ses ekleyin.", + "display_name": "Wan 2.7 Görüntüden Videoya", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "Video oluşturmayı yönlendiren ses (ör. dudak senkronizasyonu, ritme uygun hareket). Süre: 2-30 saniye. Sağlanmazsa, model otomatik olarak uyumlu arka plan müziği veya ses efektleri üretir." + }, + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "İlk kare görüntüsü. Çıktı en-boy oranı bu görüntüden alınır." + }, + "last_frame": { + "name": "last_frame", + "tooltip": "Son kare görüntüsü. Model, ilk kareden son kareye geçiş yapan bir video oluşturur." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_negative_prompt": { + "name": "negatif_istek" + }, + "model_prompt": { + "name": "istek" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "İsteğin yapay zeka yardımıyla geliştirilip geliştirilmeyeceği." + }, + "seed": { + "name": "seed", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "watermark", + "tooltip": "Sonuca yapay zeka tarafından oluşturulan bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "Referans materyallerden bir kişi veya nesne içeren video oluşturun. Tek karakterli performansları ve çok karakterli etkileşimleri destekler.", + "display_name": "Wan 2.7 Referanstan Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol et" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_negative_prompt": { + "name": "negatif_istek" + }, + "model_prompt": { + "name": "istek" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "seed", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "watermark", + "tooltip": "Sonuca yapay zeka tarafından oluşturulan bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "Wan 2.7 modeli kullanılarak bir metin istemine dayalı video oluşturur.", + "display_name": "Wan 2.7 Metinden Videoya", + "inputs": { + "audio": { + "name": "ses", + "tooltip": "Video oluşturmayı yönlendirmek için ses (ör. dudak senkronizasyonu, ritme uygun hareket). Süre: 3-30 sn. Sağlanmazsa, model otomatik olarak uygun arka plan müziği veya ses efektleri üretir." + }, + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_negative_prompt": { + "name": "negatif_istem" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "prompt_extend": { + "name": "istem_genişlet", + "tooltip": "İstemin yapay zeka yardımıyla geliştirilip geliştirilmeyeceği." + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulan bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "Bir videoyu kaldığı yerden devam ettirin, isteğe bağlı olarak son kare kontrolüyle.", + "display_name": "Wan 2.7 Video Devamı", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "first_clip": { + "name": "ilk_klip", + "tooltip": "Devam edilecek giriş videosu. Süre: 2-10 sn. Çıktı en-boy oranı bu videodan alınır." + }, + "last_frame": { + "name": "son_kare", + "tooltip": "Son kare görseli. Devam bu kareye doğru geçiş yapacaktır." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_negative_prompt": { + "name": "negatif_istem" + }, + "model_prompt": { + "name": "istem" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "prompt_extend": { + "name": "istem_genişlet", + "tooltip": "İstemin yapay zeka yardımıyla geliştirilip geliştirilmeyeceği." + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulan bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "Bir videoyu metin talimatları, referans görseller veya stil transferi ile düzenleyin.", + "display_name": "Wan 2.7 Video Düzenleme", + "inputs": { + "audio_setting": { + "name": "ses_ayarı", + "tooltip": "'auto': model, isteme göre sesi yeniden oluşturup oluşturmayacağına karar verir. 'origin': giriş videosundaki orijinal sesi korur." + }, + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "video": { + "name": "video", + "tooltip": "Düzenlenecek video." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulan bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index a752e35a13..8755f6c83a 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -1331,6 +1331,7 @@ "updating": "更新中", "upload": "上傳", "uploadAlreadyInProgress": "上傳已在進行中", + "uploadTimedOut": "上傳逾時。請再試一次。", "usageHint": "使用提示", "use": "使用", "user": "使用者", @@ -1641,9 +1642,18 @@ "exportRecording": "匯出錄影", "exportingModel": "正在匯出模型...", "fov": "視野角度", + "hdri": { + "changeFile": "更換 HDRI", + "intensity": "強度", + "label": "HDRI 環境", + "removeFile": "移除 HDRI", + "showAsBackground": "作為背景顯示", + "uploadFile": "上傳 HDRI(.hdr, .exr)" + }, "light": "光源", "lightIntensity": "光源強度", "loadingBackgroundImage": "正在載入背景圖片", + "loadingHDRI": "正在載入 HDRI...", "loadingModel": "正在載入 3D 模型...", "materialMode": "材質模式", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "資料集", "debug": "除錯", "deprecated": "已棄用", + "detection": "偵測", "edit_models": "編輯模型", "flux": "Flux", "gligen": "GLIGEN", @@ -2526,6 +2537,23 @@ "inputsNone": "無輸入", "inputsNoneTooltip": "此節點沒有輸入", "locateNode": "在畫布上定位節點", + "missingMedia": { + "audio": "音訊", + "cancelSelection": "取消選取", + "collapseNodes": "隱藏引用節點", + "confirmSelection": "確認選取", + "expandNodes": "顯示引用節點", + "image": "影像", + "locateNode": "定位節點", + "missingMediaTitle": "缺少輸入", + "or": "或", + "selectedFromLibrary": "已從媒體庫選取", + "uploadFile": "上傳{type}", + "uploaded": "已上傳", + "uploading": "正在上傳...", + "useFromLibrary": "從媒體庫使用", + "video": "影片" + }, "missingModels": { "alreadyExistsInCategory": "此模型已存在於「{category}」中", "assetLoadTimeout": "模型偵測逾時。請嘗試重新載入工作流程。", @@ -3239,6 +3267,7 @@ "termsAgreement": "繼續即表示您同意 Comfy Org 的{terms}與{privacy}。", "totalDueToday": "今日應付總額" }, + "refreshCredits": "刷新點數", "renewsDate": "將於 {date} 續訂", "required": { "pollingFailed": "訂閱啟用失敗", @@ -3411,6 +3440,7 @@ "failedToInitiateCreditPurchase": "啟動點數購買失敗:{error}", "failedToInitiateSubscription": "無法啟用訂閱:{error}", "failedToLoadBackgroundImage": "無法載入背景圖片", + "failedToLoadHDRI": "載入 HDRI 檔案失敗", "failedToLoadModel": "無法載入 3D 模型", "failedToPurchaseCredits": "購買點數失敗:{error}", "failedToQueue": "加入佇列失敗", @@ -3446,6 +3476,7 @@ "pleaseSelectOutputNodes": "請選擇輸出節點", "unableToGetModelFilePath": "無法取得模型檔案路徑", "unauthorizedDomain": "您的網域 {domain} 未被授權使用此服務。請聯絡 {email} 以將您的網域加入白名單。", + "unsupportedHDRIFormat": "不支援的檔案格式。請上傳 .hdr 或 .exr 檔案。", "updateRequested": "已請求更新", "useApiKeyTip": "提示:無法正常登入?請使用 Comfy API 金鑰選項。", "userNotAuthenticated": "使用者未驗證" diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index 839f60360f..b93cfb6ae8 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "image" }, + "keep_aspect": { + "name": "保持比例", + "tooltip": "選擇是否將裁剪內容拉伸以符合輸出尺寸,或以黑色像素填充以保留長寬比。" + }, "output_height": { "name": "output_height", "tooltip": "每個裁切區域調整後的高度。" @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "繪製 BBoxes", + "inputs": { + "bboxes": { + "name": "bboxes" + }, + "image": { + "name": "圖像" + } + }, + "outputs": { + "0": { + "name": "輸出圖像", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "雙 CFG 引導器", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "影像直方圖", + "inputs": { + "image": { + "name": "影像" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "亮度", + "tooltip": null + }, + "2": { + "name": "紅色", + "tooltip": null + }, + "3": { + "name": "綠色", + "tooltip": null + }, + "4": { + "name": "藍色", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "反轉影像", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR 偵測", + "inputs": { + "class_name": { + "name": "類別名稱", + "tooltip": "依類別篩選偵測結果。設為 'all' 以停用篩選。" + }, + "image": { + "name": "圖像" + }, + "max_detections": { + "name": "最大偵測數", + "tooltip": "每張圖像最多回傳的偵測數量,依信心分數由高至低排序。" + }, + "model": { + "name": "模型" + }, + "threshold": { + "name": "閾值" + } + }, + "outputs": { + "0": { + "name": "bboxes", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "隨機裁切影像", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "從首幀圖像產生影片,可選擇加入末幀圖像與音訊。", + "display_name": "Wan 2.7 圖像轉影片", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "用於驅動影片生成的音訊(如對嘴、節奏動作)。時長:2秒至30秒。若未提供,模型會自動產生相符的背景音樂或音效。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "首幀圖像。輸出長寬比將依此圖像決定。" + }, + "last_frame": { + "name": "last_frame", + "tooltip": "末幀圖像。模型將產生從首幀到末幀的過渡影片。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 協助增強提示詞。" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的隨機種子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 產生的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "根據參考素材生成包含人物或物件的影片。支援單一角色表演與多角色互動。", + "display_name": "Wan 2.7 參考生成影片", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的隨機種子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 產生的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "使用 Wan 2.7 模型根據文字提示生成影片。", + "display_name": "Wan 2.7 文字轉影片", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "用於驅動影片生成的音訊(如對嘴、節奏同步動作)。時長:3秒至30秒。若未提供,模型會自動生成匹配的背景音樂或音效。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 協助增強提示詞。" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子值。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "從影片結束處繼續生成影片,可選擇最後一幀控制。", + "display_name": "Wan 2.7 影片續接", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_clip": { + "name": "first_clip", + "tooltip": "要續接的輸入影片。時長:2秒至10秒。輸出長寬比將依此影片決定。" + }, + "last_frame": { + "name": "last_frame", + "tooltip": "最後一幀圖像。續接內容將朝此幀過渡。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 協助增強提示詞。" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子值。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "使用文字指令、參考圖像或風格轉換編輯影片。", + "display_name": "Wan 2.7 影片編輯", + "inputs": { + "audio_setting": { + "name": "audio_setting", + "tooltip": "「auto」:模型根據提示詞決定是否重新生成音訊。「origin」:保留輸入影片的原始音訊。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子值。" + }, + "video": { + "name": "video", + "tooltip": "要編輯的影片。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "WanAnimateToVideo", "inputs": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index a89d039b64..b7dc2c7d85 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -1331,6 +1331,7 @@ "updating": "更新中", "upload": "上传", "uploadAlreadyInProgress": "上传已在进行中", + "uploadTimedOut": "上传超时。请重试。", "usageHint": "使用提示", "use": "使用", "user": "用户", @@ -1641,9 +1642,18 @@ "exportRecording": "导出录制", "exportingModel": "正在导出模型...", "fov": "视场", + "hdri": { + "changeFile": "更换HDRI", + "intensity": "强度", + "label": "HDRI环境", + "removeFile": "移除HDRI", + "showAsBackground": "作为背景显示", + "uploadFile": "上传HDRI(.hdr, .exr)" + }, "light": "灯光", "lightIntensity": "光照强度", "loadingBackgroundImage": "正在加载背景图像", + "loadingHDRI": "正在加载HDRI...", "loadingModel": "正在加载3D模型...", "materialMode": "材质模式", "materialModes": { @@ -2258,6 +2268,7 @@ "dataset": "dataset", "debug": "调试", "deprecated": "已弃用", + "detection": "检测", "edit_models": "编辑模型", "flux": "Flux", "gligen": "GLIGEN", @@ -2526,6 +2537,23 @@ "inputsNone": "无输入", "inputsNoneTooltip": "节点没有输入", "locateNode": "在画布上定位节点", + "missingMedia": { + "audio": "音频", + "cancelSelection": "取消选择", + "collapseNodes": "隐藏引用节点", + "confirmSelection": "确认选择", + "expandNodes": "显示引用节点", + "image": "图像", + "locateNode": "定位节点", + "missingMediaTitle": "缺少输入", + "or": "或", + "selectedFromLibrary": "已从素材库选择", + "uploadFile": "上传{type}", + "uploaded": "已上传", + "uploading": "正在上传...", + "useFromLibrary": "从素材库选择", + "video": "视频" + }, "missingModels": { "alreadyExistsInCategory": "该模型已存在于“{category}”中", "assetLoadTimeout": "模型检测超时。请尝试重新加载工作流。", @@ -3251,6 +3279,7 @@ "termsAgreement": "继续操作即表示您同意 Comfy Org 的{terms}和{privacy}。", "totalDueToday": "今日应付总额" }, + "refreshCredits": "刷新额度", "renewsDate": "将于 {date} 续订", "required": { "pollingFailed": "订阅激活失败", @@ -3423,6 +3452,7 @@ "failedToInitiateCreditPurchase": "发起积分购买失败:{error}", "failedToInitiateSubscription": "订阅启动失败:{error}", "failedToLoadBackgroundImage": "无法加载背景图片", + "failedToLoadHDRI": "HDRI文件加载失败", "failedToLoadModel": "无法加载3D模型", "failedToPurchaseCredits": "购买积分失败:{error}", "failedToQueue": "排队失败", @@ -3458,6 +3488,7 @@ "pleaseSelectOutputNodes": "请选择输出节点", "unableToGetModelFilePath": "无法获取模型文件路径", "unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。", + "unsupportedHDRIFormat": "不支持的文件格式。请上传.hdr或.exr文件。", "updateRequested": "已请求更新", "useApiKeyTip": "提示:无法正常登录?请使用 Comfy API Key 选项。", "userNotAuthenticated": "用户未认证" diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index beb8155431..f29ed73a12 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -2190,6 +2190,10 @@ "image": { "name": "image" }, + "keep_aspect": { + "name": "保持宽高比", + "tooltip": "选择是否拉伸裁剪区域以适应输出尺寸,或用黑色像素填充以保持宽高比。" + }, "output_height": { "name": "output_height", "tooltip": "每个裁剪区域调整后的高度。" @@ -2313,6 +2317,23 @@ } } }, + "DrawBBoxes": { + "display_name": "绘制边界框", + "inputs": { + "bboxes": { + "name": "边界框" + }, + "image": { + "name": "图像" + } + }, + "outputs": { + "0": { + "name": "输出图像", + "tooltip": null + } + } + }, "DualCFGGuider": { "display_name": "双CFG引导器", "inputs": { @@ -4985,6 +5006,36 @@ } } }, + "ImageHistogram": { + "display_name": "图像直方图", + "inputs": { + "image": { + "name": "图像" + } + }, + "outputs": { + "0": { + "name": "RGB", + "tooltip": null + }, + "1": { + "name": "亮度", + "tooltip": null + }, + "2": { + "name": "红色", + "tooltip": null + }, + "3": { + "name": "绿色", + "tooltip": null + }, + "4": { + "name": "蓝色", + "tooltip": null + } + } + }, "ImageInvert": { "display_name": "反转图像", "inputs": { @@ -12226,6 +12277,34 @@ } } }, + "RTDETR_detect": { + "display_name": "RT-DETR 检测", + "inputs": { + "class_name": { + "name": "类别名称", + "tooltip": "按类别筛选检测结果。设置为 'all' 可禁用筛选。" + }, + "image": { + "name": "图像" + }, + "max_detections": { + "name": "最大检测数", + "tooltip": "每张图像返回的最大检测数量,按置信度从高到低排序。" + }, + "model": { + "name": "模型" + }, + "threshold": { + "name": "阈值" + } + }, + "outputs": { + "0": { + "name": "边界框", + "tooltip": null + } + } + }, "RandomCropImages": { "display_name": "裁剪图像(随机)", "inputs": { @@ -15631,6 +15710,10 @@ "1": { "name": "FBX", "tooltip": null + }, + "2": { + "name": "uv_image", + "tooltip": null } } }, @@ -17898,6 +17981,241 @@ } } }, + "Wan2ImageToVideoApi": { + "description": "根据首帧图像生成视频,可选末帧图像和音频。", + "display_name": "Wan 2.7 图像转视频", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "用于驱动视频生成的音频(如对口型、节奏匹配动作)。时长:2秒-30秒。如未提供,模型会自动生成匹配的背景音乐或音效。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "首帧图像。输出的宽高比由该图像决定。" + }, + "last_frame": { + "name": "last_frame", + "tooltip": "末帧图像。模型将生成从首帧到末帧的过渡视频。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 辅助增强提示词。" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2ReferenceVideoApi": { + "description": "根据参考素材生成包含人物或物体的视频。支持单角色表演和多角色互动。", + "display_name": "Wan 2.7 参考生成视频", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2TextToVideoApi": { + "description": "使用 Wan 2.7 模型根据文本提示生成视频。", + "display_name": "Wan 2.7 文本转视频", + "inputs": { + "audio": { + "name": "audio", + "tooltip": "用于驱动视频生成的音频(如对口型、节奏匹配动作)。时长:3秒-30秒。如未提供,模型会自动生成匹配的背景音乐或音效。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 辅助增强提示词。" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoContinuationApi": { + "description": "从视频结束处继续生成视频,可选用最后一帧进行控制。", + "display_name": "Wan 2.7 视频续写", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_clip": { + "name": "first_clip", + "tooltip": "要续写的视频。时长:2秒-10秒。输出宽高比将根据该视频确定。" + }, + "last_frame": { + "name": "last_frame", + "tooltip": "最后一帧图像。续写内容将过渡到该帧。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_negative_prompt": { + "name": "negative_prompt" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "prompt_extend": { + "name": "prompt_extend", + "tooltip": "是否使用 AI 辅助增强提示词。" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "Wan2VideoEditApi": { + "description": "通过文本指令、参考图像或风格迁移编辑视频。", + "display_name": "Wan 2.7 视频编辑", + "inputs": { + "audio_setting": { + "name": "audio_setting", + "tooltip": "“auto”:模型根据提示词决定是否重新生成音频。“origin”:保留输入视频的原始音频。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "video": { + "name": "video", + "tooltip": "要编辑的视频。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "WanAnimateToVideo": { "display_name": "Wan动画转视频", "inputs": { diff --git a/src/platform/assets/components/ActiveMediaAssetCard.test.ts b/src/platform/assets/components/ActiveMediaAssetCard.test.ts index 1ad0849cd1..3094499b1d 100644 --- a/src/platform/assets/components/ActiveMediaAssetCard.test.ts +++ b/src/platform/assets/components/ActiveMediaAssetCard.test.ts @@ -1,4 +1,5 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' @@ -61,13 +62,16 @@ const createJob = (overrides: Partial = {}): JobListItem => ({ ...overrides }) -const mountComponent = (job: JobListItem) => - mount(ActiveJobCard, { +function renderComponent(job: JobListItem) { + const user = userEvent.setup() + const { container } = render(ActiveJobCard, { props: { job }, global: { plugins: [i18n] } }) + return { container, user } +} describe('ActiveJobCard', () => { beforeEach(() => { @@ -78,18 +82,19 @@ describe('ActiveJobCard', () => { }) it('displays percentage and progress bar when job is running', () => { - const wrapper = mountComponent( + const { container } = renderComponent( createJob({ state: 'running', progressTotalPercent: 65 }) ) - expect(wrapper.text()).toContain('65%') - const progressBar = wrapper.find('.bg-blue-500') - expect(progressBar.exists()).toBe(true) - expect(progressBar.attributes('style')).toContain('width: 65%') + expect(container.textContent).toContain('65%') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- progress bar has no ARIA role in happy-dom + const progressBar = container.querySelector('.bg-blue-500') + expect(progressBar).not.toBeNull() + expect(progressBar).toHaveStyle({ width: '65%' }) }) it('displays status text when job is pending', () => { - const wrapper = mountComponent( + const { container } = renderComponent( createJob({ state: 'pending', title: 'In queue...', @@ -97,116 +102,114 @@ describe('ActiveJobCard', () => { }) ) - expect(wrapper.text()).toContain('In queue...') - const progressBar = wrapper.find('.bg-blue-500') - expect(progressBar.exists()).toBe(false) + expect(container.textContent).toContain('In queue...') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- progress bar has no ARIA role in happy-dom + expect(container.querySelector('.bg-blue-500')).toBeNull() }) it('shows spinner for pending state', () => { - const wrapper = mountComponent(createJob({ state: 'pending' })) + const { container } = renderComponent(createJob({ state: 'pending' })) - const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]') - expect(spinner.exists()).toBe(true) - expect(spinner.classes()).toContain('animate-spin') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- spinner icon has no ARIA role in happy-dom + const spinner = container.querySelector('[class*="lucide--loader-circle"]') + expect(spinner).not.toBeNull() + expect(spinner).toHaveClass('animate-spin') }) it('shows error icon for failed state', () => { - const wrapper = mountComponent( + const { container } = renderComponent( createJob({ state: 'failed', title: 'Failed' }) ) - const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]') - expect(errorIcon.exists()).toBe(true) - expect(wrapper.text()).toContain('Failed') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- error icon has no ARIA role in happy-dom + const errorIcon = container.querySelector('[class*="lucide--circle-alert"]') + expect(errorIcon).not.toBeNull() + expect(container.textContent).toContain('Failed') }) it('shows preview image when running with iconImageUrl', () => { - const wrapper = mountComponent( + renderComponent( createJob({ state: 'running', iconImageUrl: 'https://example.com/preview.jpg' }) ) - const img = wrapper.find('img') - expect(img.exists()).toBe(true) - expect(img.attributes('src')).toBe('https://example.com/preview.jpg') + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/preview.jpg') }) it('has proper accessibility attributes', () => { - const wrapper = mountComponent(createJob({ title: 'Generating...' })) + renderComponent(createJob({ title: 'Generating...' })) - const container = wrapper.find('[role="status"]') - expect(container.exists()).toBe(true) - expect(container.attributes('aria-label')).toBe('Active job: Generating...') + const status = screen.getByRole('status', { + name: 'Active job: Generating...' + }) + expect(status).toBeInTheDocument() }) it('shows delete button on hover for failed jobs', async () => { mockCanDeleteJob.value = true - const wrapper = mountComponent( + const { user } = renderComponent( createJob({ state: 'failed', title: 'Failed' }) ) - expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false) + expect( + screen.queryByRole('button', { name: 'Remove job' }) + ).not.toBeInTheDocument() - await wrapper.find('[role="status"]').trigger('mouseenter') + await user.hover(screen.getByRole('status')) - const button = wrapper.findComponent({ name: 'Button' }) - expect(button.exists()).toBe(true) - expect(button.attributes('aria-label')).toBe('Remove job') + expect( + screen.getByRole('button', { name: 'Remove job' }) + ).toBeInTheDocument() }) it('calls runDeleteJob when delete button is clicked on a failed job', async () => { mockCanDeleteJob.value = true - const wrapper = mountComponent( + const { user } = renderComponent( createJob({ state: 'failed', title: 'Failed' }) ) - await wrapper.find('[role="status"]').trigger('mouseenter') - - const button = wrapper.findComponent({ name: 'Button' }) - await button.trigger('click') + await user.hover(screen.getByRole('status')) + await user.click(screen.getByRole('button', { name: 'Remove job' })) expect(mockRunDeleteJob).toHaveBeenCalledOnce() }) it('does not show action button when job cannot be cancelled or deleted', async () => { - const wrapper = mountComponent( + const { user } = renderComponent( createJob({ state: 'running', progressTotalPercent: 50 }) ) - await wrapper.find('[role="status"]').trigger('mouseenter') + await user.hover(screen.getByRole('status', { name: /Active job/ })) - expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false) + expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('shows cancel button on hover for cancellable jobs', async () => { mockCanCancelJob.value = true - const wrapper = mountComponent( + const { user } = renderComponent( createJob({ state: 'running', progressTotalPercent: 50 }) ) - await wrapper.find('[role="status"]').trigger('mouseenter') + await user.hover(screen.getByRole('status', { name: /Active job/ })) - const button = wrapper.findComponent({ name: 'Button' }) - expect(button.exists()).toBe(true) - expect(button.attributes('aria-label')).toBe('Cancel') + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() }) it('calls runCancelJob when cancel button is clicked', async () => { mockCanCancelJob.value = true - const wrapper = mountComponent( + const { user } = renderComponent( createJob({ state: 'running', progressTotalPercent: 50 }) ) - await wrapper.find('[role="status"]').trigger('mouseenter') - - const button = wrapper.findComponent({ name: 'Button' }) - await button.trigger('click') + await user.hover(screen.getByRole('status', { name: /Active job/ })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) expect(mockRunCancelJob).toHaveBeenCalledOnce() }) diff --git a/src/platform/assets/components/AssetBrowserModal.test.ts b/src/platform/assets/components/AssetBrowserModal.test.ts index 6fb0275198..e2d6ed7da7 100644 --- a/src/platform/assets/components/AssetBrowserModal.test.ts +++ b/src/platform/assets/components/AssetBrowserModal.test.ts @@ -1,4 +1,5 @@ -import { flushPromises, mount } from '@vue/test-utils' +import { render, screen, waitFor } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -60,6 +61,7 @@ vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({ emits: ['close'], template: `
+ {{ contentTitle }}
@@ -87,15 +89,27 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
- +
` } @@ -151,6 +165,9 @@ vi.mock('vue-i18n', () => ({ }) })) +const flushPromises = () => + new Promise((resolve) => setTimeout(resolve, 0)) + describe('AssetBrowserModal', () => { const createTestAsset = ( id: string, @@ -173,11 +190,11 @@ describe('AssetBrowserModal', () => { } }) - const createWrapper = (props: Record) => { + function renderModal(props: Record) { const pinia = createPinia() setActivePinia(pinia) - return mount(AssetBrowserModal, { + return render(AssetBrowserModal, { props, global: { plugins: [pinia], @@ -207,14 +224,16 @@ describe('AssetBrowserModal', () => { ] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + renderModal({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) - const gridAssets = assetGrid.props('assets') as AssetItem[] - - expect(gridAssets).toHaveLength(2) - expect(gridAssets[0].id).toBe('asset1') + expect(screen.getByTestId('asset-asset1')).toBeDefined() + expect(screen.getByTestId('asset-asset2')).toBeDefined() + /* eslint-disable testing-library/no-node-access */ + expect( + screen.getByTestId('asset-grid').querySelectorAll('.asset-card') + ).toHaveLength(2) + /* eslint-enable testing-library/no-node-access */ }) it('passes category-filtered assets to AssetFilterBar', async () => { @@ -224,23 +243,22 @@ describe('AssetBrowserModal', () => { ] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ + renderModal({ nodeType: 'CheckpointLoaderSimple', showLeftPanel: true }) await flushPromises() - const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' }) - const filterBarAssets = filterBar.props('assets') as AssetItem[] - - expect(filterBarAssets).toHaveLength(2) + expect(screen.getByTestId('asset-filter-bar').textContent).toContain( + '2 assets' + ) }) }) describe('Data fetching', () => { it('triggers store refresh for node type on mount', async () => { const store = useAssetsStore() - createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + renderModal({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() expect(store.updateModelsForNodeType).toHaveBeenCalledWith( @@ -252,18 +270,17 @@ describe('AssetBrowserModal', () => { const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + renderModal({ nodeType: 'CheckpointLoaderSimple' }) - const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) - const gridAssets = assetGrid.props('assets') as AssetItem[] - - expect(gridAssets).toHaveLength(1) - expect(gridAssets[0].name).toBe('Cached Model') + expect(screen.getByTestId('asset-asset1')).toBeDefined() + expect(screen.getByTestId('asset-asset1').textContent).toContain( + 'Cached Model' + ) }) it('triggers store refresh for asset type (tag) on mount', async () => { const store = useAssetsStore() - createWrapper({ assetType: 'models' }) + renderModal({ assetType: 'models' }) await flushPromises() expect(store.updateModelsForTag).toHaveBeenCalledWith('models') @@ -273,116 +290,133 @@ describe('AssetBrowserModal', () => { const assets = [createTestAsset('asset1', 'Tagged Model', 'models')] mockAssetsByKey.set('tag:models', assets) - const wrapper = createWrapper({ assetType: 'models' }) + renderModal({ assetType: 'models' }) await flushPromises() - const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) - const gridAssets = assetGrid.props('assets') as AssetItem[] - - expect(gridAssets).toHaveLength(1) - expect(gridAssets[0].name).toBe('Tagged Model') + expect(screen.getByTestId('asset-asset1')).toBeDefined() + expect(screen.getByTestId('asset-asset1').textContent).toContain( + 'Tagged Model' + ) }) }) describe('Asset Selection', () => { it('emits asset-select event when asset is selected', async () => { + const user = userEvent.setup() const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + const onAssetSelect = vi.fn() + renderModal({ + nodeType: 'CheckpointLoaderSimple', + 'onAsset-select': onAssetSelect + }) await flushPromises() - const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) - await assetGrid.vm.$emit('asset-select', assets[0]) + await user.click(screen.getByTestId('asset-asset1')) - expect(wrapper.emitted('asset-select')).toEqual([[assets[0]]]) + expect(onAssetSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: assets[0].id, name: assets[0].name }) + ) }) it('executes onSelect callback when provided', async () => { + const user = userEvent.setup() const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] mockAssetsByKey.set('CheckpointLoaderSimple', assets) const onSelect = vi.fn() - const wrapper = createWrapper({ + renderModal({ nodeType: 'CheckpointLoaderSimple', onSelect }) await flushPromises() - const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) - await assetGrid.vm.$emit('asset-select', assets[0]) + await user.click(screen.getByTestId('asset-asset1')) - expect(onSelect).toHaveBeenCalledWith(assets[0]) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: assets[0].id, name: assets[0].name }) + ) }) }) describe('Left Panel Conditional Logic', () => { it('hides left panel by default when showLeftPanel is undefined', async () => { - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + renderModal({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - const leftPanel = wrapper.find('[data-testid="left-panel"]') - expect(leftPanel.exists()).toBe(false) + expect(screen.queryByTestId('left-panel')).toBeNull() }) it('shows left panel when showLeftPanel prop is explicitly true', async () => { - const wrapper = createWrapper({ + renderModal({ nodeType: 'CheckpointLoaderSimple', showLeftPanel: true }) await flushPromises() - const leftPanel = wrapper.find('[data-testid="left-panel"]') - expect(leftPanel.exists()).toBe(true) + expect(screen.getByTestId('left-panel')).toBeDefined() + }) + + it('hides left panel when showLeftPanel is false', async () => { + renderModal({ + nodeType: 'CheckpointLoaderSimple', + showLeftPanel: false + }) + await flushPromises() + + expect(screen.queryByTestId('left-panel')).toBeNull() }) }) describe('Filter Options Reactivity', () => { it('updates filter options when category changes', async () => { + const user = userEvent.setup() const assets = [ createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ + renderModal({ nodeType: 'CheckpointLoaderSimple', showLeftPanel: true }) await flushPromises() - const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' }) - expect(filterBar.props('assets')).toHaveLength(2) + expect(screen.getByTestId('asset-filter-bar').textContent).toContain( + '2 assets' + ) - const leftPanel = wrapper.findComponent({ name: 'LeftSidePanel' }) - await leftPanel.vm.$emit('update:modelValue', 'loras') - await wrapper.vm.$nextTick() + await user.click(screen.getByTestId('nav-item-loras')) - expect(filterBar.props('assets')).toHaveLength(1) + await waitFor(() => { + expect(screen.getByTestId('asset-filter-bar').textContent).toContain( + '1 assets' + ) + }) }) }) describe('Title Management', () => { it('passes custom title to BaseModalLayout when title prop provided', async () => { - const wrapper = createWrapper({ + renderModal({ nodeType: 'CheckpointLoaderSimple', title: 'Custom Title' }) await flushPromises() - const layout = wrapper.findComponent({ name: 'BaseModalLayout' }) - expect(layout.props('contentTitle')).toBe('Custom Title') + expect(screen.getByTestId('modal-title').textContent).toBe('Custom Title') }) it('passes computed contentTitle to BaseModalLayout when no title prop', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] mockAssetsByKey.set('CheckpointLoaderSimple', assets) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + renderModal({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - const layout = wrapper.findComponent({ name: 'BaseModalLayout' }) - expect(layout.props('contentTitle')).toBe( + expect(screen.getByTestId('modal-title').textContent).toBe( 'assetBrowser.allCategory:{"category":"Checkpoints"}' ) }) diff --git a/src/platform/assets/components/AssetFilterBar.test.ts b/src/platform/assets/components/AssetFilterBar.test.ts index 5b19500fff..a1862a4b54 100644 --- a/src/platform/assets/components/AssetFilterBar.test.ts +++ b/src/platform/assets/components/AssetFilterBar.test.ts @@ -1,16 +1,19 @@ -import { mount } from '@vue/test-utils' +/* eslint-disable testing-library/no-container */ +/* eslint-disable testing-library/no-node-access */ +/* eslint-disable testing-library/prefer-user-event */ +import { fireEvent, render } from '@testing-library/vue' import { describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue' -import type { AssetFilterState } from '@/platform/assets/types/filterTypes' import { createAssetWithSpecificBaseModel, createAssetWithSpecificExtension, createAssetWithoutBaseModel } from '@/platform/assets/fixtures/ui-mock-assets' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' -import { createI18n } from 'vue-i18n' +import type { AssetFilterState } from '@/platform/assets/types/filterTypes' const i18n = createI18n({ legacy: false, @@ -67,29 +70,31 @@ vi.mock('@/components/input/SingleSelect.vue', () => ({ // Test factory functions function mountAssetFilterBar(props = {}) { - return mount(AssetFilterBar, { - props, + const onFilterChange = vi.fn() + const { container } = render(AssetFilterBar, { + props: { ...props, onFilterChange }, global: { plugins: [i18n] } }) + return { container, onFilterChange } } // Helper functions to find filters by user-facing attributes -function findFileFormatsFilter( - wrapper: ReturnType -) { - return wrapper.findComponent( +function findFileFormatsFilter(container: Element) { + return container.querySelector( '[data-component-id="asset-filter-file-formats"]' ) } -function findBaseModelsFilter(wrapper: ReturnType) { - return wrapper.findComponent('[data-component-id="asset-filter-base-models"]') +function findBaseModelsFilter(container: Element) { + return container.querySelector( + '[data-component-id="asset-filter-base-models"]' + ) } -function findSortFilter(wrapper: ReturnType) { - return wrapper.findComponent('[data-component-id="asset-filter-sort"]') +function findSortFilter(container: Element) { + return container.querySelector('[data-component-id="asset-filter-sort"]') } describe('AssetFilterBar', () => { @@ -102,49 +107,55 @@ describe('AssetFilterBar', () => { createAssetWithSpecificBaseModel('sd15'), createAssetWithSpecificBaseModel('sdxl') ] - const wrapper = mountAssetFilterBar({ assets }) + const { container, onFilterChange } = mountAssetFilterBar({ assets }) // Update file formats - const fileFormatSelect = findFileFormatsFilter(wrapper) - const fileFormatSelectElement = fileFormatSelect.find('select') - const options = fileFormatSelectElement.findAll('option') - const ckptOption = options.find((o) => o.element.value === 'ckpt')! - const safetensorsOption = options.find( - (o) => o.element.value === 'safetensors' - )! - ckptOption.element.selected = true - safetensorsOption.element.selected = true - await fileFormatSelectElement.trigger('change') + const fileFormatEl = findFileFormatsFilter(container)! + const fileFormatSelectEl = fileFormatEl.querySelector( + 'select' + ) as HTMLSelectElement + const fileFormatOptions = fileFormatEl.querySelectorAll('option') + const ckptOption = Array.from(fileFormatOptions).find( + (o) => (o as HTMLOptionElement).value === 'ckpt' + ) as HTMLOptionElement + const safetensorsOption = Array.from(fileFormatOptions).find( + (o) => (o as HTMLOptionElement).value === 'safetensors' + ) as HTMLOptionElement + ckptOption.selected = true + safetensorsOption.selected = true + await fireEvent.change(fileFormatSelectEl) await nextTick() // Update base models - const baseModelSelect = findBaseModelsFilter(wrapper) - const baseModelSelectElement = baseModelSelect.find('select') - const sdxlOption = baseModelSelectElement - .findAll('option') - .find((o) => o.element.value === 'sdxl') - sdxlOption!.element.selected = true - await baseModelSelectElement.trigger('change') + const baseModelEl = findBaseModelsFilter(container)! + const baseModelSelectEl = baseModelEl.querySelector( + 'select' + ) as HTMLSelectElement + const baseModelOptions = baseModelEl.querySelectorAll('option') + const sdxlOption = Array.from(baseModelOptions).find( + (o) => (o as HTMLOptionElement).value === 'sdxl' + ) as HTMLOptionElement + sdxlOption.selected = true + await fireEvent.change(baseModelSelectEl) await nextTick() // Update sort - const sortSelect = findSortFilter(wrapper) - const sortSelectElement = sortSelect.find('select') - sortSelectElement.element.value = 'name-desc' - await sortSelectElement.trigger('change') + const sortEl = findSortFilter(container)! + const sortSelectEl = sortEl.querySelector('select') as HTMLSelectElement + sortSelectEl.value = 'name-desc' + await fireEvent.change(sortSelectEl) await nextTick() - const emitted = wrapper.emitted('filterChange') - expect(emitted).toBeTruthy() - expect(emitted!.length).toBeGreaterThanOrEqual(3) + expect(onFilterChange).toHaveBeenCalled() + expect(onFilterChange.mock.calls.length).toBeGreaterThanOrEqual(3) // Check final state - const finalState: AssetFilterState = emitted![ - emitted!.length - 1 - ][0] as AssetFilterState + const lastCall = + onFilterChange.mock.calls[onFilterChange.mock.calls.length - 1] + const finalState: AssetFilterState = lastCall[0] expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors']) expect(finalState.baseModels).toEqual(['sdxl']) expect(finalState.sortBy).toBe('name-desc') @@ -157,18 +168,22 @@ describe('AssetFilterBar', () => { createAssetWithSpecificExtension('safetensors'), createAssetWithSpecificBaseModel('sd15') ] - const wrapper = mountAssetFilterBar({ assets }) + const { container, onFilterChange } = mountAssetFilterBar({ assets }) - const fileFormatSelect = findFileFormatsFilter(wrapper) - const fileFormatSelectElement = fileFormatSelect.find('select') - const ckptOption = fileFormatSelectElement.findAll('option')[0] - ckptOption.element.selected = true - await fileFormatSelectElement.trigger('change') + const fileFormatEl = findFileFormatsFilter(container)! + const fileFormatSelectEl = fileFormatEl.querySelector( + 'select' + ) as HTMLSelectElement + const firstOption = fileFormatEl.querySelector( + 'option' + ) as HTMLOptionElement + firstOption.selected = true + await fireEvent.change(fileFormatSelectEl) await nextTick() - const emitted = wrapper.emitted('filterChange') - const filterState = emitted![0][0] as AssetFilterState + expect(onFilterChange).toHaveBeenCalled() + const filterState: AssetFilterState = onFilterChange.mock.calls[0][0] // Type and structure assertions expect(Array.isArray(filterState.fileFormats)).toBe(true) @@ -193,12 +208,15 @@ describe('AssetFilterBar', () => { createAssetWithSpecificExtension('pt') ] - const wrapper = mountAssetFilterBar({ assets }) + const { container } = mountAssetFilterBar({ assets }) - const fileFormatSelect = findFileFormatsFilter(wrapper) - const options = fileFormatSelect.findAll('option') + const fileFormatEl = findFileFormatsFilter(container)! + const options = fileFormatEl.querySelectorAll('option') expect( - options.map((o) => ({ name: o.text(), value: o.element.value })) + Array.from(options).map((o) => ({ + name: o.textContent?.trim(), + value: (o as HTMLOptionElement).value + })) ).toEqual([ { name: '.ckpt', value: 'ckpt' }, { name: '.pt', value: 'pt' }, @@ -213,12 +231,15 @@ describe('AssetFilterBar', () => { createAssetWithSpecificBaseModel('sd35') ] - const wrapper = mountAssetFilterBar({ assets }) + const { container } = mountAssetFilterBar({ assets }) - const baseModelSelect = findBaseModelsFilter(wrapper) - const options = baseModelSelect.findAll('option') + const baseModelEl = findBaseModelsFilter(container)! + const options = baseModelEl.querySelectorAll('option') expect( - options.map((o) => ({ name: o.text(), value: o.element.value })) + Array.from(options).map((o) => ({ + name: o.textContent?.trim(), + value: (o as HTMLOptionElement).value + })) ).toEqual([ { name: 'sd15', value: 'sd15' }, { name: 'sd35', value: 'sd35' }, @@ -230,18 +251,16 @@ describe('AssetFilterBar', () => { describe('Conditional Filter Visibility', () => { it('hides file format filter when no options available', () => { const assets: AssetItem[] = [] // No assets = no file format options - const wrapper = mountAssetFilterBar({ assets }) + const { container } = mountAssetFilterBar({ assets }) - const fileFormatSelect = findFileFormatsFilter(wrapper) - expect(fileFormatSelect.exists()).toBe(false) + expect(findFileFormatsFilter(container)).toBeNull() }) it('hides base model filter when no options available', () => { const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options - const wrapper = mountAssetFilterBar({ assets }) + const { container } = mountAssetFilterBar({ assets }) - const baseModelSelect = findBaseModelsFilter(wrapper) - expect(baseModelSelect.exists()).toBe(false) + expect(findBaseModelsFilter(container)).toBeNull() }) it('shows both filters when options are available', () => { @@ -249,23 +268,17 @@ describe('AssetFilterBar', () => { createAssetWithSpecificExtension('safetensors'), createAssetWithSpecificBaseModel('sd15') ] - const wrapper = mountAssetFilterBar({ assets }) + const { container } = mountAssetFilterBar({ assets }) - const fileFormatSelect = findFileFormatsFilter(wrapper) - const baseModelSelect = findBaseModelsFilter(wrapper) - - expect(fileFormatSelect.exists()).toBe(true) - expect(baseModelSelect.exists()).toBe(true) + expect(findFileFormatsFilter(container)).not.toBeNull() + expect(findBaseModelsFilter(container)).not.toBeNull() }) it('hides both filters when no assets provided', () => { - const wrapper = mountAssetFilterBar() + const { container } = mountAssetFilterBar() - const fileFormatSelect = findFileFormatsFilter(wrapper) - const baseModelSelect = findBaseModelsFilter(wrapper) - - expect(fileFormatSelect.exists()).toBe(false) - expect(baseModelSelect.exists()).toBe(false) + expect(findFileFormatsFilter(container)).toBeNull() + expect(findBaseModelsFilter(container)).toBeNull() }) }) }) diff --git a/src/platform/assets/components/AssetsListItem.test.ts b/src/platform/assets/components/AssetsListItem.test.ts index f274c997eb..ebb469d7df 100644 --- a/src/platform/assets/components/AssetsListItem.test.ts +++ b/src/platform/assets/components/AssetsListItem.test.ts @@ -1,11 +1,13 @@ -import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' + import AssetsListItem from './AssetsListItem.vue' describe('AssetsListItem', () => { it('renders video element with play overlay for video previews', () => { - const wrapper = mount(AssetsListItem, { + const { container } = render(AssetsListItem, { props: { previewUrl: 'https://example.com/preview.mp4', previewAlt: 'clip.mp4', @@ -13,17 +15,22 @@ describe('AssetsListItem', () => { } }) - const video = wrapper.find('video') - expect(video.exists()).toBe(true) - expect(video.attributes('src')).toBe('https://example.com/preview.mp4') - expect(video.attributes('preload')).toBe('metadata') - expect(wrapper.find('img').exists()).toBe(false) - expect(wrapper.find('.bg-black\\/15').exists()).toBe(true) - expect(wrapper.find('.icon-\\[lucide--play\\]').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- no ARIA role for
- {{ asset.name }} + {{ + getAssetFilename(asset) + }} ({ vi.mock('@/services/litegraphService', () => ({ useLitegraphService: () => ({ - addNodeOnGraph: vi.fn().mockReturnValue({ - widgets: [{ name: 'image', value: '', callback: vi.fn() }], - graph: { setDirtyCanvas: vi.fn() } - } as unknown as LGraphNode), + addNodeOnGraph: vi.fn().mockReturnValue( + fromAny({ + widgets: [{ name: 'image', value: '', callback: vi.fn() }], + graph: { setDirtyCanvas: vi.fn() } + }) + ), getCanvasCenter: vi.fn().mockReturnValue([100, 100]) }) })) diff --git a/src/platform/assets/composables/useUploadModelWizard.test.ts b/src/platform/assets/composables/useUploadModelWizard.test.ts new file mode 100644 index 0000000000..3250b466b9 --- /dev/null +++ b/src/platform/assets/composables/useUploadModelWizard.test.ts @@ -0,0 +1,157 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import type { AsyncUploadResponse } from '@/platform/assets/schemas/assetSchema' + +import { useUploadModelWizard } from './useUploadModelWizard' + +vi.mock('@/platform/assets/services/assetService', () => ({ + assetService: { + uploadAssetAsync: vi.fn(), + uploadAssetPreviewImage: vi.fn() + } +})) + +vi.mock('@/platform/assets/importSources/civitaiImportSource', () => ({ + civitaiImportSource: { + name: 'Civitai', + hostnames: ['civitai.com'], + fetchMetadata: vi.fn() + } +})) + +vi.mock('@/platform/assets/importSources/huggingfaceImportSource', () => ({ + huggingfaceImportSource: { + name: 'HuggingFace', + hostnames: ['huggingface.co'], + fetchMetadata: vi.fn() + } +})) + +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: vi.fn(), + addEventListener: vi.fn(), + apiURL: vi.fn((path: string) => path) + } +})) + +vi.mock('@/i18n', () => ({ + st: (_key: string, fallback: string) => fallback, + t: (key: string) => key, + te: () => false, + d: (date: Date) => date.toISOString() +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }) +})) + +describe('useUploadModelWizard', () => { + const modelTypes = ref([{ name: 'Checkpoint', value: 'checkpoints' }]) + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('updates uploadStatus to success when async download completes', async () => { + const { assetService } = + await import('@/platform/assets/services/assetService') + + const asyncResponse: AsyncUploadResponse = { + type: 'async', + task: { + task_id: 'task-123', + status: 'created', + message: 'Download queued' + } + } + vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse) + + const wizard = useUploadModelWizard(modelTypes) + wizard.wizardData.value.url = 'https://civitai.com/models/12345' + wizard.selectedModelType.value = 'checkpoints' + + await wizard.uploadModel() + + expect(wizard.uploadStatus.value).toBe('processing') + + // Simulate WebSocket: download completes + const detail = { + task_id: 'task-123', + asset_id: 'asset-456', + asset_name: 'model.safetensors', + bytes_total: 1000, + bytes_downloaded: 1000, + progress: 100, + status: 'completed' as const + } + const event = new CustomEvent('asset_download', { detail }) + const { api } = await import('@/scripts/api') + const handler = vi + .mocked(api.addEventListener) + .mock.calls.find((c) => c[0] === 'asset_download')?.[1] as + | ((e: CustomEvent) => void) + | undefined + expect(handler).toBeDefined() + handler!(event) + + await nextTick() + + expect(wizard.uploadStatus.value).toBe('success') + }) + + it('updates uploadStatus to error when async download fails', async () => { + const { assetService } = + await import('@/platform/assets/services/assetService') + + const asyncResponse: AsyncUploadResponse = { + type: 'async', + task: { + task_id: 'task-fail', + status: 'created', + message: 'Download queued' + } + } + vi.mocked(assetService.uploadAssetAsync).mockResolvedValue(asyncResponse) + + const wizard = useUploadModelWizard(modelTypes) + wizard.wizardData.value.url = 'https://civitai.com/models/99999' + wizard.selectedModelType.value = 'checkpoints' + + await wizard.uploadModel() + expect(wizard.uploadStatus.value).toBe('processing') + + // Simulate WebSocket: download fails + const { api } = await import('@/scripts/api') + const handler = vi + .mocked(api.addEventListener) + .mock.calls.find((c) => c[0] === 'asset_download')?.[1] as + | ((e: CustomEvent) => void) + | undefined + + const failEvent = new CustomEvent('asset_download', { + detail: { + task_id: 'task-fail', + asset_id: '', + asset_name: 'model.safetensors', + bytes_total: 1000, + bytes_downloaded: 500, + progress: 50, + status: 'failed' as const, + error: 'Network error' + } + }) + + expect(handler).toBeDefined() + handler!(failEvent) + + await nextTick() + + expect(wizard.uploadStatus.value).toBe('error') + expect(wizard.uploadError.value).toBe('Network error') + }) +}) diff --git a/src/platform/assets/composables/useUploadModelWizard.ts b/src/platform/assets/composables/useUploadModelWizard.ts index b82b4b8142..e6dcb2aa98 100644 --- a/src/platform/assets/composables/useUploadModelWizard.ts +++ b/src/platform/assets/composables/useUploadModelWizard.ts @@ -36,6 +36,7 @@ export function useUploadModelWizard(modelTypes: Ref) { const isUploading = ref(false) const uploadStatus = ref<'processing' | 'success' | 'error'>() const uploadError = ref('') + let stopAsyncWatch: (() => void) | undefined const wizardData = ref({ url: '', @@ -203,6 +204,7 @@ export function useUploadModelWizard(modelTypes: Ref) { } async function uploadModel(): Promise { + if (isUploading.value) return false if (!canUploadModel.value) { return false } @@ -247,6 +249,44 @@ export function useUploadModelWizard(modelTypes: Ref) { ) } uploadStatus.value = 'processing' + + stopAsyncWatch?.() + let resolved = false + const stop = watch( + () => + assetDownloadStore.downloadList.find( + (d) => d.taskId === result.task.task_id + )?.status, + async (status) => { + if (status === 'completed') { + resolved = true + uploadStatus.value = 'success' + await refreshModelCaches() + stopAsyncWatch?.() + stopAsyncWatch = undefined + } else if (status === 'failed') { + resolved = true + const download = assetDownloadStore.downloadList.find( + (d) => d.taskId === result.task.task_id + ) + uploadStatus.value = 'error' + uploadError.value = + download?.error || + t('assetBrowser.downloadFailed', { + name: download?.assetName || '' + }) + stopAsyncWatch?.() + stopAsyncWatch = undefined + } + }, + { immediate: true } + ) + if (resolved) { + stop() + stopAsyncWatch = undefined + } else { + stopAsyncWatch = stop + } } else { uploadStatus.value = 'success' await refreshModelCaches() @@ -271,6 +311,8 @@ export function useUploadModelWizard(modelTypes: Ref) { } function resetWizard() { + stopAsyncWatch?.() + stopAsyncWatch = undefined currentStep.value = 1 isFetchingMetadata.value = false isUploading.value = false diff --git a/src/platform/cloud/notification/components/CloudNotificationContent.vue b/src/platform/cloud/notification/components/CloudNotificationContent.vue index 2e3a5ead0f..7f6750708b 100644 --- a/src/platform/cloud/notification/components/CloudNotificationContent.vue +++ b/src/platform/cloud/notification/components/CloudNotificationContent.vue @@ -1,5 +1,8 @@