diff --git a/.agents/checks/adr-compliance.md b/.agents/checks/adr-compliance.md new file mode 100644 index 0000000000..f61d4be0bd --- /dev/null +++ b/.agents/checks/adr-compliance.md @@ -0,0 +1,118 @@ +--- +name: adr-compliance +description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance +severity-default: medium +tools: [Read, Grep, glob] +--- + +Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`. + +## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003) + +These are the primary architectural guardrails. Every entity/litegraph change must be checked against them. + +### Command Pattern (ADR 0003) + +All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends. + +Flag: + +- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands. +- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly. +- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics. +- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers. + +### ECS Architecture (ADR 0008) + +The graph domain model is migrating to ECS. New code must not make the migration harder. + +Flag: + +- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead. +- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects. +- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes. +- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API. + +### Centralized Registries and ECS-Style Access + +All entity data access should move toward centralized query patterns, not instance property access. + +Flag: + +- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`. +- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems. +- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store. + +### Extension Ecosystem Impact + +Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path. + +Flag when changed without migration guidance: + +- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks +- `onConnectInput` / `onConnectOutput` validation hooks +- `onWidgetChanged` handlers +- `node.widgets.find(w => w.name === ...)` patterns +- `node.serialize` overrides +- `graph._version++` direct mutation +- `getNodeById` usage patterns + +## Priority 2: General ADR Compliance + +For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance. + +### How to Apply + +1. Read `docs/adr/README.md` to get the full ADR index +2. For each ADR, read the Decision and Consequences sections +3. Check the diff against each ADR's constraints +4. Only flag ACTUAL violations in changed code, not pre-existing patterns + +### Skip List + +These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions): + +- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again + +## How to Check + +1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores +2. For Priority 1 patterns, use targeted searches: + + ``` + # Direct position mutation + Grep: pattern="\.pos\s*=" path="src/lib/litegraph" + Grep: pattern="\.size\s*=" path="src/lib/litegraph" + + # God object growth (new methods) + Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph" + + # Version mutation + Grep: pattern="_version\+\+" path="src/lib/litegraph" + + # Extension callback changes + Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph" + ``` + +3. For Priority 2, read `docs/adr/` files and check for contradictions + +## Severity Guidelines + +| Issue | Severity | +| -------------------------------------------------------- | -------- | +| Imperative mutation API without command-pattern wrapper | high | +| New god-object method on LGraphNode/LGraphCanvas/LGraph | high | +| Breaking extension callback without migration path | high | +| New circular entity dependency | high | +| Direct spatial mutation bypassing command pattern | medium | +| Mixed data/behavior in component-like structures | medium | +| New OOP inheritance pattern for entities | medium | +| Contradicts accepted ADR direction | medium | +| Contradicts proposed ADR direction without justification | low | + +## Rules + +- Only flag ACTUAL violations in changed code, not pre-existing patterns +- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity +- Proposed ADRs carry less weight than accepted ones — flag as directional guidance +- Reference the specific ADR number in every finding diff --git a/.claude/commands/adr-compliance-audit.md b/.claude/commands/adr-compliance-audit.md new file mode 100644 index 0000000000..06fa52bf6d --- /dev/null +++ b/.claude/commands/adr-compliance-audit.md @@ -0,0 +1,94 @@ +# ADR Compliance Audit + +Audit the current changes (or a specified PR) for compliance with Architecture Decision Records. + +## Step 1: Gather the Diff + +- If a PR number is provided, run: `gh pr diff $PR_NUMBER` +- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes) + +## Step 2: Priority 1 — ECS and Command-Pattern Compliance + +Read these documents for context: + +``` +docs/adr/0003-crdt-based-layout-system.md +docs/adr/0008-entity-component-system.md +docs/architecture/ecs-target-architecture.md +docs/architecture/ecs-migration-plan.md +docs/architecture/appendix-critical-analysis.md +``` + +### Check A: Command Pattern (ADR 0003) + +Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT. + +Flag: + +1. **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command +2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly. +3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }` +4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers. +5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure) + +### Check B: ECS Architecture (ADR 0008) + +Flag: + +1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph` +2. **Mixed data/behavior** — Component-like structures with methods or back-references +3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component +4. **OOP inheritance** — New entity subclasses instead of component composition +5. **Circular entity deps** — New `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas` circular imports +6. **Direct `_version++`** — Mutating private version counter instead of through public API + +### Check C: Extension Ecosystem Impact + +If any of these patterns are changed, flag and require migration guidance: + +- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks +- `onConnectInput` / `onConnectOutput` validation hooks +- `onWidgetChanged` handlers +- `node.widgets.find(w => w.name === ...)` access patterns +- `node.serialize` overrides +- `graph._version++` direct mutation + +Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.) + +## Step 3: Priority 2 — General ADR Compliance + +1. Read `docs/adr/README.md` for the full ADR index +2. For each ADR (except skip list), read the Decision section +3. Check the diff for contradictions +4. Only flag ACTUAL violations in changed code + +**Skip list**: ADR 0004 (Rejected — Fork PrimeVue) + +## Step 4: Generate Report + +``` +## ADR Compliance Audit Report + +### Summary +- Files audited: N +- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N) +- Priority 2 findings: N + +### Priority 1: Command Pattern & ECS +(List each with ADR reference, file, line, description) + +### Priority 1: Extension Ecosystem Impact +(List each changed callback/API with affected custom node repos) + +### Priority 2: General ADR Compliance +(List each with ADR reference, file, line, description) + +### Compliant Patterns +(Note changes that positively align with ADR direction) +``` + +## Severity + +- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path +- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment +- **Note**: Surfaces open architectural question not yet addressed by ADRs diff --git a/.claude/skills/backport-management/SKILL.md b/.claude/skills/backport-management/SKILL.md index 449339c2fe..d2ad90c072 100644 --- a/.claude/skills/backport-management/SKILL.md +++ b/.claude/skills/backport-management/SKILL.md @@ -18,12 +18,20 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br ## System Context -| Item | Value | -| -------------- | ------------------------------------------------- | -| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) | -| Merge strategy | Squash merge (`gh pr merge --squash --admin`) | -| Automation | `pr-backport.yaml` GitHub Action (label-driven) | -| Tracking dir | `~/temp/backport-session/` | +| Item | Value | +| -------------- | --------------------------------------------------------------------------- | +| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) | +| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes | +| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) | +| Tracking dir | `~/temp/backport-session/` | + +## CI Safety Rules + +**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs. + +- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails. +- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass. +- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step. ## Branch Scope Rules @@ -108,11 +116,15 @@ git fetch origin TARGET_BRANCH # Quick smoke check: does the branch build? git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH cd /tmp/verify-TARGET -source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck +source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit git worktree remove /tmp/verify-TARGET --force ``` -If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem. +If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem. + +### 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. ## Continuous Backporting Recommendation diff --git a/.claude/skills/backport-management/reference/execution.md b/.claude/skills/backport-management/reference/execution.md index 0fa8d032c6..44de5f66f0 100644 --- a/.claude/skills/backport-management/reference/execution.md +++ b/.claude/skills/backport-management/reference/execution.md @@ -19,23 +19,44 @@ done # Wait 3 minutes for automation sleep 180 -# Check which got auto-PRs +# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI) gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title ``` -## Step 2: Review & Merge Clean Auto-PRs +> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those. + +## Step 2: Wait for CI & Merge Clean Auto-PRs + +Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures: ```bash -for pr in $AUTO_PRS; do - # Check size - gh pr view $pr --json title,additions,deletions,changedFiles \ - --jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"' - # Admin merge - gh pr merge $pr --squash --admin - sleep 3 +# Wait for CI to complete (~45 minutes for full suite) +sleep 2700 + +# Check which PRs are still open (CI may have failed, or auto-merge succeeded) +STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number') +RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt) + +# For PRs still open, check CI status +for pr in $STILL_OPEN_PRS; do + CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length') + CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length') + if [ "$CI_FAILED" != "0" ]; then + # CI failed — collect details for triage + echo "PR #$pr — CI FAILED:" + gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"' + elif [ "$CI_PENDING" != "0" ]; then + echo "PR #$pr — CI still running ($CI_PENDING checks pending)" + else + # All checks passed but didn't auto-merge (race condition or label issue) + gh pr merge $pr --squash --admin + sleep 3 + fi done ``` +**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below. + ## Step 3: Manual Worktree for Conflicts ```bash @@ -63,6 +84,13 @@ for PR in ${CONFLICT_PRS[@]}; do 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+$') + + # Wait for CI before merging — NEVER admin-merge without CI passing + echo "Waiting for CI on PR #$NEW_PR..." + gh pr checks $NEW_PR --watch --fail-fast || { + echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage" + continue + } gh pr merge $NEW_PR --squash --admin sleep 3 done @@ -82,7 +110,7 @@ After completing all PRs in a wave for a target branch: git fetch origin TARGET_BRANCH git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH cd /tmp/verify-TARGET -source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck +source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit git worktree remove /tmp/verify-TARGET --force ``` @@ -132,7 +160,8 @@ git rebase origin/TARGET_BRANCH # Resolve new conflicts git push --force origin backport-$PR-to-TARGET sleep 20 # Wait for GitHub to recompute merge state -gh pr merge $PR --squash --admin +# Wait for CI after rebase before merging +gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin ``` ## Lessons Learned @@ -146,5 +175,31 @@ gh pr merge $PR --squash --admin 7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these 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` on the target branch after merging a batch. Catching breakage early prevents compounding errors. +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. +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. + +## CI Failure Triage + +When CI fails on a backport PR, present failures to the user using this template: + +```markdown +### PR #XXXX — CI Failed + +- **Failing check:** test / lint / typecheck +- **Error:** (summary of the failure message) +- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch +- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites +``` + +Common failure categories: + +| Category | Example | Resolution | +| --------------------------- | ---------------------------------------- | ----------------------------------------- | +| Test without implementation | Test references function not on branch | Backport the implementation PR first | +| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip | +| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch | +| Flaky test | Passes on retry | Re-run CI, merge if green on retry | +| Type error | Interface changed on main but not branch | May need manual adaptation | + +**Never assume a failure is safe to skip.** Present all failures to the user with analysis. diff --git a/.claude/skills/backport-management/reference/logging.md b/.claude/skills/backport-management/reference/logging.md index ce0cc9f331..4c78ba4b16 100644 --- a/.claude/skills/backport-management/reference/logging.md +++ b/.claude/skills/backport-management/reference/logging.md @@ -5,9 +5,9 @@ Maintain `execution-log.md` with per-branch tables: ```markdown -| PR# | Title | Status | Backport PR | Notes | -| ----- | ----- | --------------------------------- | ----------- | ------- | -| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details | +| PR# | Title | CI Status | Status | Backport PR | Notes | +| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- | +| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details | ``` ## Wave Verification Log @@ -19,6 +19,7 @@ Track verification results per wave: - PRs merged: #A, #B, #C - Typecheck: ✅ Pass / ❌ Fail +- Unit tests: ✅ Pass / ❌ Fail - Issues found: (if any) - Human review needed: (list any non-trivial conflict resolutions) ``` @@ -41,6 +42,11 @@ Track verification results per wave: | PR# | Branch | Conflict Type | Resolution Summary | +## CI Failure Report + +| PR# | Branch | Failing Check | Error Summary | Cause | Resolution | +| --- | ------ | ------------- | ------------- | ----- | ---------- | + ## Automation Performance | Metric | Value | diff --git a/.claude/skills/contain-audit/SKILL.md b/.claude/skills/contain-audit/SKILL.md new file mode 100644 index 0000000000..a2ecda31fb --- /dev/null +++ b/.claude/skills/contain-audit/SKILL.md @@ -0,0 +1,99 @@ +--- +name: contain-audit +description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.' +--- + +# CSS Containment Audit + +Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead. + +## What It Does + +1. Loads a large workflow (245 nodes) in a real browser +2. Walks the DOM tree and scores every element as a containment candidate +3. For each high-scoring candidate, applies `contain: layout style` via JavaScript +4. Measures rendering performance (style recalcs, layouts, task duration) before and after +5. Takes before/after screenshots to detect visual breakage +6. Generates a ranked report with actionable recommendations + +## When to Use + +- After adding new Vue components to the node rendering pipeline +- When investigating rendering performance on large workflows +- Before and after refactoring node DOM structure +- As part of periodic performance audits + +## How to Run + +```bash +# Start the dev server first +pnpm dev & + +# Run the audit (uses the @audit tag, not included in normal CI runs) +pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit + +# View the HTML report +pnpm exec playwright show-report +``` + +## How to Read Results + +The audit outputs a table to the console: + +```text +CSS Containment Audit Results +======================================================= +Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual + 1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK + 2 | .node-body | 12 | 48 | -8% | -3% | OK + 3 | .node-header | 4 | 16 | +1% | 0% | OK +``` + +- **Subtree**: Number of descendant elements (higher = more to skip) +- **Score**: Composite heuristic score (subtree size x sizing constraint bonus) +- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement) +- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually) + +## Candidate Scoring + +An element is a good containment candidate when: + +1. **Large subtree** -- many descendants that the browser can skip recalculating +2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content) +3. **No existing containment** -- `contain` is not already applied +4. **Not a leaf** -- has at least a few child elements + +Elements that should NOT get containment: + +- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset) +- Elements whose height is determined by content and affects sibling layout +- Very small subtrees (overhead of containment context outweighs benefit) + +## Limitations + +- Cannot fully guarantee `contain` safety -- visual review of screenshots is required +- Performance measurements have natural variance; run multiple times for confidence +- Only tests idle and pan scenarios; widget interactions may differ +- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations + +## Example PR + +[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946) + +This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because: + +- **Large subtree** (18+ descendants: header, slots, widgets, content, badges) +- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent) +- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders) + +The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`. + +## Reference + +| Resource | Path | +| ----------------- | ------------------------------------------------------- | +| Audit test | `browser_tests/tests/containAudit.spec.ts` | +| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` | +| Perf tests | `browser_tests/tests/performance.spec.ts` | +| Large workflow | `browser_tests/assets/large-graph-workflow.json` | +| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 | diff --git a/.coderabbit.yaml b/.coderabbit.yaml index d2d42e3cd5..20af82bec6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -28,3 +28,21 @@ reviews: 3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added. Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical. + - name: ADR compliance for entity/litegraph changes + mode: warning + instructions: | + Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands. + + This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs). + + If none of those paths appear in the changed files, pass immediately. + + When applicable, check for: + 1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object. + 2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones. + 3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component. + 4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos. + + Pass if none of these patterns are found in the diff. + + When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status. diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index 51eb593647..6994315fe4 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -87,7 +87,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-report.yaml b/.github/workflows/pr-report.yaml index cea15dbf65..1fecfdc567 100644 --- a/.github/workflows/pr-report.yaml +++ b/.github/workflows/pr-report.yaml @@ -180,7 +180,7 @@ jobs: if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then git fetch origin perf-data --depth=1 mkdir -p temp/perf-history - for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -10); do + for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true done echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines" diff --git a/.github/workflows/pr-update-playwright-expectations.yaml b/.github/workflows/pr-update-playwright-expectations.yaml index 8a5dfc3f86..308c54b74f 100644 --- a/.github/workflows/pr-update-playwright-expectations.yaml +++ b/.github/workflows/pr-update-playwright-expectations.yaml @@ -77,7 +77,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-version-bump.yaml b/.github/workflows/release-version-bump.yaml index f6ed3eb63f..d9bbc74f1e 100644 --- a/.github/workflows/release-version-bump.yaml +++ b/.github/workflows/release-version-bump.yaml @@ -30,6 +30,7 @@ concurrency: jobs: bump-version: + if: github.repository == 'Comfy-Org/ComfyUI_frontend' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/weekly-docs-check.yaml b/.github/workflows/weekly-docs-check.yaml index ef87035677..418923daab 100644 --- a/.github/workflows/weekly-docs-check.yaml +++ b/.github/workflows/weekly-docs-check.yaml @@ -18,6 +18,7 @@ concurrency: jobs: docs-check: + if: github.repository == 'Comfy-Org/ComfyUI_frontend' runs-on: ubuntu-latest timeout-minutes: 45 steps: diff --git a/AGENTS.md b/AGENTS.md index 52cd688e6e..d692cf22fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns. 3. Keep your module mocks contained Do not use global mutable state within the test file Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state -4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html) +4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html) 5. Aim for behavioral coverage of critical and new features ### Playwright / Browser / E2E Tests @@ -231,6 +231,18 @@ See @docs/testing/\*.md for detailed patterns. - Nx: - [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html) +## Architecture Decision Records + +All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules. + +### Entity Architecture Constraints (ADR 0003 + ADR 0008) + +1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects. +2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling. +3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables. +4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions). +5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance. + ## Project Philosophy - Follow good software engineering principles diff --git a/CODEOWNERS b/CODEOWNERS index 5de3e41641..2fb3dc08e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,9 @@ # Manager /src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata +# Model-to-node mappings (cloud team) +/src/platform/assets/mappings/ @deepme987 + # LLM Instructions (blank on purpose) .claude/ .cursor/ diff --git a/apps/desktop-ui/vite.config.mts b/apps/desktop-ui/vite.config.mts index 771f0f85b8..bfb2a22a66 100644 --- a/apps/desktop-ui/vite.config.mts +++ b/apps/desktop-ui/vite.config.mts @@ -1,6 +1,6 @@ import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' -import dotenv from 'dotenv' +import { config as dotenvConfig } from 'dotenv' import path from 'node:path' import { fileURLToPath } from 'node:url' import { FileSystemIconLoader } from 'unplugin-icons/loaders' @@ -11,7 +11,7 @@ import { defineConfig } from 'vite' import { createHtmlPlugin } from 'vite-plugin-html' import vueDevTools from 'vite-plugin-vue-devtools' -dotenv.config() +dotenvConfig() const projectRoot = fileURLToPath(new URL('.', import.meta.url)) diff --git a/apps/website/.gitignore b/apps/website/.gitignore new file mode 100644 index 0000000000..ac5d2bcab8 --- /dev/null +++ b/apps/website/.gitignore @@ -0,0 +1,2 @@ +dist/ +.astro/ diff --git a/apps/website/astro.config.ts b/apps/website/astro.config.ts new file mode 100644 index 0000000000..188b3bb48a --- /dev/null +++ b/apps/website/astro.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'astro/config' +import vue from '@astrojs/vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + site: 'https://comfy.org', + output: 'static', + integrations: [vue()], + vite: { + plugins: [tailwindcss()] + }, + build: { + assetsPrefix: process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : undefined + }, + i18n: { + locales: ['en', 'zh-CN'], + defaultLocale: 'en', + routing: { + prefixDefaultLocale: false + } + } +}) diff --git a/apps/website/package.json b/apps/website/package.json new file mode 100644 index 0000000000..f300242b2b --- /dev/null +++ b/apps/website/package.json @@ -0,0 +1,80 @@ +{ + "name": "@comfyorg/website", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@comfyorg/design-system": "workspace:*", + "@vercel/analytics": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "@astrojs/vue": "catalog:", + "@tailwindcss/vite": "catalog:", + "astro": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:" + }, + "nx": { + "tags": [ + "scope:website", + "type:app" + ], + "targets": { + "dev": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "cwd": "apps/website", + "command": "astro dev" + } + }, + "serve": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "cwd": "apps/website", + "command": "astro dev" + } + }, + "build": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": [ + "^build" + ], + "options": { + "cwd": "apps/website", + "command": "astro build" + }, + "outputs": [ + "{projectRoot}/dist" + ] + }, + "preview": { + "executor": "nx:run-commands", + "continuous": true, + "dependsOn": [ + "build" + ], + "options": { + "cwd": "apps/website", + "command": "astro preview" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "cache": true, + "options": { + "cwd": "apps/website", + "command": "astro check" + } + } + } + } +} diff --git a/apps/website/public/fonts/inter-latin-italic.woff2 b/apps/website/public/fonts/inter-latin-italic.woff2 new file mode 100644 index 0000000000..39eb636735 Binary files /dev/null and b/apps/website/public/fonts/inter-latin-italic.woff2 differ diff --git a/apps/website/public/fonts/inter-latin-normal.woff2 b/apps/website/public/fonts/inter-latin-normal.woff2 new file mode 100644 index 0000000000..b0d0e2e5c6 Binary files /dev/null and b/apps/website/public/fonts/inter-latin-normal.woff2 differ diff --git a/apps/website/src/env.d.ts b/apps/website/src/env.d.ts new file mode 100644 index 0000000000..f964fe0cff --- /dev/null +++ b/apps/website/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/website/src/styles/global.css b/apps/website/src/styles/global.css new file mode 100644 index 0000000000..6581d52c0a --- /dev/null +++ b/apps/website/src/styles/global.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import '@comfyorg/design-system/css/base.css'; diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json new file mode 100644 index 0000000000..34212cdd36 --- /dev/null +++ b/apps/website/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "astro.config.mjs"] +} diff --git a/browser_tests/README.md b/browser_tests/README.md index 8158a16ce6..6240285b34 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -75,7 +75,7 @@ For tests that specifically need to test release functionality, see the example **Always use UI mode for development:** ```bash -pnpm exec playwright test --ui +pnpm test:browser:local --ui ``` UI mode features: @@ -91,29 +91,8 @@ UI mode features: For CI or headless testing: ```bash -pnpm exec playwright test # Run all tests -pnpm exec playwright test widget.spec.ts # Run specific test file -``` - -### Local Development Config - -For debugging, you can try adjusting these settings in `playwright.config.ts`: - -```typescript -export default defineConfig({ - // VERY HELPFUL: Skip screenshot tests locally - grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/ - - retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky. - workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel. - timeout: 30000, // Longer timeout for breakpoints - - use: { - trace: 'on', // Always capture traces (CI uses 'on-first-retry') - video: 'on' // Always record video (CI uses 'retain-on-failure') - }, - -}) +pnpm test:browser:local # Run all tests +pnpm test:browser:local widget.spec.ts # Run specific test file ``` ## Test Structure @@ -385,7 +364,7 @@ export default defineConfig({ Option 2 - Generate local baselines for comparison: ```bash -pnpm exec playwright test --update-snapshots +pnpm test:browser:local --update-snapshots ``` ### Creating New Screenshot Baselines diff --git a/browser_tests/assets/links/duplicate_links_slot_drift.json b/browser_tests/assets/links/duplicate_links_slot_drift.json new file mode 100644 index 0000000000..59f2696241 --- /dev/null +++ b/browser_tests/assets/links/duplicate_links_slot_drift.json @@ -0,0 +1,169 @@ +{ + "id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890", + "revision": 0, + "last_node_id": 2, + "last_link_id": 0, + "nodes": [ + { + "id": 2, + "type": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "pos": [400, 300], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + "widgets_values": [] + } + ], + "links": [], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 120, + "lastLinkId": 276, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Slot Drift Duplicate Links", + "inputNode": { + "id": -10, + "bounding": [0, 300, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [900, 300, 120, 60] + }, + "inputs": [], + "outputs": [], + "widgets": [], + "nodes": [ + { + "id": 120, + "type": "ComfySwitchNode", + "title": "Switch (CFG)", + "pos": [100, 100], + "size": [200, 80], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [{ "name": "value", "type": "FLOAT", "link": null }], + "outputs": [ + { + "name": "FLOAT", + "type": "FLOAT", + "links": [257, 271, 276] + } + ], + "properties": { "Node name for S&R": "ComfySwitchNode" }, + "widgets_values": [] + }, + { + "id": 85, + "type": "KSamplerAdvanced", + "pos": [400, 50], + "size": [270, 262], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { "name": "model", "type": "MODEL", "link": null }, + { "name": "positive", "type": "CONDITIONING", "link": null }, + { "name": "negative", "type": "CONDITIONING", "link": null }, + { "name": "latent_image", "type": "LATENT", "link": null }, + { "name": "steps", "type": "INT", "link": null }, + { "name": "cfg", "type": "FLOAT", "link": 276 } + ], + "outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }], + "properties": { "Node name for S&R": "KSamplerAdvanced" }, + "widgets_values": [ + false, + 0, + "randomize", + 20, + 8, + "euler", + "normal", + 0, + 10000, + false + ] + }, + { + "id": 86, + "type": "KSamplerAdvanced", + "pos": [400, 350], + "size": [270, 262], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { "name": "model", "type": "MODEL", "link": null }, + { "name": "positive", "type": "CONDITIONING", "link": null }, + { "name": "negative", "type": "CONDITIONING", "link": null }, + { "name": "latent_image", "type": "LATENT", "link": null }, + { "name": "steps", "type": "INT", "link": null }, + { "name": "cfg", "type": "FLOAT", "link": 271 } + ], + "outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }], + "properties": { "Node name for S&R": "KSamplerAdvanced" }, + "widgets_values": [ + false, + 0, + "randomize", + 20, + 8, + "euler", + "normal", + 0, + 10000, + false + ] + } + ], + "groups": [], + "links": [ + { + "id": 257, + "origin_id": 120, + "origin_slot": 0, + "target_id": 85, + "target_slot": 5, + "type": "FLOAT" + }, + { + "id": 271, + "origin_id": 120, + "origin_slot": 0, + "target_id": 86, + "target_slot": 5, + "type": "FLOAT" + }, + { + "id": 276, + "origin_id": 120, + "origin_slot": 0, + "target_id": 85, + "target_slot": 5, + "type": "FLOAT" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { "scale": 1, "offset": [0, 0] }, + "frontendVersion": "1.43.2" + }, + "version": 0.4 +} diff --git a/browser_tests/assets/subgraphs/nested-pack-promoted-values.json b/browser_tests/assets/subgraphs/nested-pack-promoted-values.json new file mode 100644 index 0000000000..f4f2665680 --- /dev/null +++ b/browser_tests/assets/subgraphs/nested-pack-promoted-values.json @@ -0,0 +1,817 @@ +{ + "id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65", + "revision": 0, + "last_node_id": 61, + "last_link_id": 70, + "nodes": [ + { + "id": 35, + "type": "MarkdownNote", + "pos": [-424.0076397768001, 199.99406275798367], + "size": [510, 774], + "flags": { + "collapsed": false + }, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Model link", + "properties": {}, + "widgets_values": [ + "## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n" + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 9, + "type": "SaveImage", + "pos": [569.9875743118757, 199.99406275798367], + "size": [780, 660], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 62 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage", + "cnr_id": "comfy-core", + "ver": "0.3.64", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": ["z-image-turbo"] + }, + { + "id": 57, + "type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1", + "pos": [128.01215102992103, 199.99406275798367], + "size": [400, 470], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [62] + } + ], + "properties": { + "proxyWidgets": [ + ["27", "text"], + ["13", "width"], + ["13", "height"], + ["28", "unet_name"], + ["30", "clip_name"], + ["29", "vae_name"], + ["3", "steps"], + ["3", "control_after_generate"] + ], + "cnr_id": "comfy-core", + "ver": "0.3.73", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + } + ], + "links": [[62, 57, 0, 9, 0, "IMAGE"]], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 61, + "lastLinkId": 70, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Image (Z-Image-Turbo)", + "inputNode": { + "id": -10, + "bounding": [-80, 425, 120, 180] + }, + "outputNode": { + "id": -20, + "bounding": [1490, 415, 120, 60] + }, + "inputs": [ + { + "id": "fb178669-e742-4a53-8a69-7df59834dfd8", + "name": "text", + "type": "STRING", + "linkIds": [34], + "label": "prompt", + "pos": [20, 445] + }, + { + "id": "dd780b3c-23e9-46ff-8469-156008f42e5a", + "name": "width", + "type": "INT", + "linkIds": [35], + "pos": [20, 465] + }, + { + "id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc", + "name": "height", + "type": "INT", + "linkIds": [36], + "pos": [20, 485] + }, + { + "id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596", + "name": "unet_name", + "type": "COMBO", + "linkIds": [38], + "pos": [20, 505] + }, + { + "id": "f362d639-d412-4b5d-8490-1e9995dc5f82", + "name": "clip_name", + "type": "COMBO", + "linkIds": [39], + "pos": [20, 525] + }, + { + "id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf", + "name": "vae_name", + "type": "COMBO", + "linkIds": [40], + "pos": [20, 545] + }, + { + "id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892", + "name": "steps", + "type": "INT", + "linkIds": [70], + "pos": [20, 565] + } + ], + "outputs": [ + { + "id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [16], + "localized_name": "IMAGE", + "pos": [1510, 435] + } + ], + "widgets": [], + "nodes": [ + { + "id": 30, + "type": "CLIPLoader", + "pos": [110, 330], + "size": [270, 106], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 39 + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [28] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.3.73", + "models": [ + { + "name": "qwen_3_4b.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"] + }, + { + "id": 29, + "type": "VAELoader", + "pos": [110, 480], + "size": [270, 58], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 40 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [27] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.3.73", + "models": [ + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": ["ae.safetensors"] + }, + { + "id": 33, + "type": "ConditioningZeroOut", + "pos": [640, 620], + "size": [204.134765625, 26], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 32 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [33] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.3.73", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [1220, 160], + "size": [210, 46], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 14 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 27 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [16] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.3.64", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [] + }, + { + "id": 28, + "type": "UNETLoader", + "pos": [110, 200], + "size": [270, 82], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 38 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [26] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.3.73", + "models": [ + { + "name": "z_image_turbo_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": ["z_image_turbo_bf16.safetensors", "default"] + }, + { + "id": 27, + "type": "CLIPTextEncode", + "pos": [430, 200], + "size": [410, 370], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 28 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 34 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [30, 32] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.73", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. " + ] + }, + { + "id": 13, + "type": "EmptySD3LatentImage", + "pos": [110, 630], + "size": [260, 110], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 35 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [17] + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage", + "cnr_id": "comfy-core", + "ver": "0.3.64", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [1024, 1024, 1] + }, + { + "id": 11, + "type": "ModelSamplingAuraFlow", + "pos": [880, 160], + "size": [310, 60], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 26 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [13] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingAuraFlow", + "cnr_id": "comfy-core", + "ver": "0.3.64", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [3] + }, + { + "id": 3, + "type": "KSampler", + "pos": [880, 270], + "size": [315, 262], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 30 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 33 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 17 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 70 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [14] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.3.64", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + "randomize", + 8, + 1, + "res_multistep", + "simple", + 1 + ] + } + ], + "groups": [ + { + "id": 2, + "title": "Step2 - Image size", + "bounding": [100, 560, 290, 200], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Step3 - Prompt", + "bounding": [410, 130, 450, 540], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Step1 - Load models", + "bounding": [100, 130, 290, 413.6], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 32, + "origin_id": 27, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 28, + "origin_slot": 0, + "target_id": 11, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 14, + "origin_id": 3, + "origin_slot": 0, + "target_id": 8, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 27, + "origin_id": 29, + "origin_slot": 0, + "target_id": 8, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 13, + "origin_id": 11, + "origin_slot": 0, + "target_id": 3, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 30, + "origin_id": 27, + "origin_slot": 0, + "target_id": 3, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 33, + "origin_id": 33, + "origin_slot": 0, + "target_id": 3, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 17, + "origin_id": 13, + "origin_slot": 0, + "target_id": 3, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 28, + "origin_id": 30, + "origin_slot": 0, + "target_id": 27, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 16, + "origin_id": 8, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 34, + "origin_id": -10, + "origin_slot": 0, + "target_id": 27, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 35, + "origin_id": -10, + "origin_slot": 1, + "target_id": 13, + "target_slot": 0, + "type": "INT" + }, + { + "id": 36, + "origin_id": -10, + "origin_slot": 2, + "target_id": 13, + "target_slot": 1, + "type": "INT" + }, + { + "id": 38, + "origin_id": -10, + "origin_slot": 3, + "target_id": 28, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 4, + "target_id": 30, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 40, + "origin_id": -10, + "origin_slot": 5, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 70, + "origin_id": -10, + "origin_slot": 6, + "target_id": 3, + "target_slot": 4, + "type": "INT" + } + ], + "extra": { + "workflowRendererVersion": "LG" + } + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 0.6488294314381271, + "offset": [733, 392.7886597938144] + }, + "frontendVersion": "1.43.4", + "workflowRendererVersion": "LG", + "VHS_latentpreview": false, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4 +} diff --git a/browser_tests/assets/subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets.json b/browser_tests/assets/subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets.json new file mode 100644 index 0000000000..f0bf747a43 --- /dev/null +++ b/browser_tests/assets/subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets.json @@ -0,0 +1,599 @@ +{ + "id": "legacy-prefix-test-workflow", + "revision": 0, + "last_node_id": 5, + "last_link_id": 5, + "nodes": [ + { + "id": 5, + "type": "1e38d8ea-45e1-48a5-aa20-966584201867", + "pos": [788, 433.5], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 4 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [5] + } + ], + "properties": { + "proxyWidgets": [["6", "6: 3: string_a"]] + }, + "widgets_values": [""] + }, + { + "id": 2, + "type": "PreviewAny", + "pos": [1335, 429], + "size": [250, 145.5], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [null, null, false] + }, + { + "id": 1, + "type": "PrimitiveStringMultiline", + "pos": [356, 450], + "size": [225, 121.5], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [4] + } + ], + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Outer\n"] + } + ], + "links": [ + [4, 1, 0, 5, 0, "STRING"], + [5, 5, 0, 2, 0, "STRING"] + ], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "1e38d8ea-45e1-48a5-aa20-966584201867", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 6, + "lastLinkId": 9, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Outer Subgraph", + "inputNode": { + "id": -10, + "bounding": [351, 432.5, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [1315, 432.5, 120, 60] + }, + "inputs": [ + { + "id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4", + "name": "string_a", + "type": "STRING", + "linkIds": [1], + "localized_name": "string_a", + "pos": [451, 452.5] + } + ], + "outputs": [ + { + "id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d", + "name": "STRING", + "type": "STRING", + "linkIds": [9], + "localized_name": "STRING", + "pos": [1335, 452.5] + } + ], + "widgets": [], + "nodes": [ + { + "id": 3, + "type": "StringConcatenate", + "pos": [815, 373], + "size": [400, 200], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 1 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 2 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [7] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + }, + { + "id": 6, + "type": "9be42452-056b-4c99-9f9f-7381d11c4454", + "pos": [955, 775], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 7 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [9] + } + ], + "properties": { + "proxyWidgets": [["-1", "string_a"]] + }, + "widgets_values": [""] + }, + { + "id": 4, + "type": "PrimitiveStringMultiline", + "pos": [313, 685], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [2] + } + ], + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 1\n"] + } + ], + "groups": [], + "links": [ + { + "id": 2, + "origin_id": 4, + "origin_slot": 0, + "target_id": 3, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1, + "origin_id": -10, + "origin_slot": 0, + "target_id": 3, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 7, + "origin_id": 3, + "origin_slot": 0, + "target_id": 6, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 6, + "origin_id": 6, + "origin_slot": 0, + "target_id": -20, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 9, + "origin_id": 6, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + } + ], + "extra": {} + }, + { + "id": "9be42452-056b-4c99-9f9f-7381d11c4454", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 9, + "lastLinkId": 12, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Inner Subgraph", + "inputNode": { + "id": -10, + "bounding": [680, 774, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [1320, 774, 120, 60] + }, + "inputs": [ + { + "id": "01c05c51-86b5-4bad-b32f-9c911683a13d", + "name": "string_a", + "type": "STRING", + "linkIds": [4], + "localized_name": "string_a", + "pos": [780, 794] + } + ], + "outputs": [ + { + "id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686", + "name": "STRING", + "type": "STRING", + "linkIds": [12], + "localized_name": "STRING", + "pos": [1340, 794] + } + ], + "widgets": [], + "nodes": [ + { + "id": 5, + "type": "StringConcatenate", + "pos": [860, 719], + "size": [400, 200], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 4 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 7 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [11] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + }, + { + "id": 6, + "type": "PrimitiveStringMultiline", + "pos": [401, 973], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [7] + } + ], + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 2\n"] + }, + { + "id": 9, + "type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce", + "pos": [1046, 985], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 11 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [12] + } + ], + "properties": { + "proxyWidgets": [["-1", "string_a"]] + }, + "widgets_values": [""] + } + ], + "groups": [], + "links": [ + { + "id": 4, + "origin_id": -10, + "origin_slot": 0, + "target_id": 5, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 7, + "origin_id": 6, + "origin_slot": 0, + "target_id": 5, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 11, + "origin_id": 5, + "origin_slot": 0, + "target_id": 9, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 9, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 12, + "origin_id": 9, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + } + ], + "extra": {} + }, + { + "id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 8, + "lastLinkId": 10, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Innermost Subgraph", + "inputNode": { + "id": -10, + "bounding": [262, 1222, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [1330, 1222, 120, 60] + }, + "inputs": [ + { + "id": "934a8baa-d79c-428c-8ec9-814ad437d7c7", + "name": "string_a", + "type": "STRING", + "linkIds": [9], + "localized_name": "string_a", + "pos": [362, 1242] + } + ], + "outputs": [ + { + "id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879", + "name": "STRING", + "type": "STRING", + "linkIds": [10], + "localized_name": "STRING", + "pos": [1350, 1242] + } + ], + "widgets": [], + "nodes": [ + { + "id": 7, + "type": "StringConcatenate", + "pos": [870, 1038], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 9 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [10] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + }, + { + "id": 8, + "type": "PrimitiveStringMultiline", + "pos": [442, 1296], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [8] + } + ], + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 3\n"] + } + ], + "groups": [], + "links": [ + { + "id": 8, + "origin_id": 8, + "origin_slot": 0, + "target_id": 7, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 9, + "origin_id": -10, + "origin_slot": 0, + "target_id": 7, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 7, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [-7, 144] + }, + "frontendVersion": "1.38.13" + }, + "version": 0.4 +} diff --git a/browser_tests/assets/subgraphs/nested-subgraph-stale-proxy-widgets.json b/browser_tests/assets/subgraphs/nested-subgraph-stale-proxy-widgets.json new file mode 100644 index 0000000000..83a4142ce1 --- /dev/null +++ b/browser_tests/assets/subgraphs/nested-subgraph-stale-proxy-widgets.json @@ -0,0 +1,555 @@ +{ + "id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d", + "revision": 0, + "last_node_id": 11, + "last_link_id": 16, + "nodes": [ + { + "id": 9, + "type": "SaveImage", + "pos": [1451.0058559453123, 189.0019842294924], + "size": [400, 200], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 13 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": ["ComfyUI"] + }, + { + "id": 4, + "type": "CheckpointLoaderSimple", + "pos": [25.988896564209426, 473.9973077158204], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [11] + }, + { + "name": "CLIP", + "type": "CLIP", + "slot_index": 1, + "links": [10] + }, + { + "name": "VAE", + "type": "VAE", + "slot_index": 2, + "links": [12] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"] + }, + { + "id": 10, + "type": "d14ff4cf-e5cb-4c84-941f-7c2457476424", + "pos": [711.776576770508, 420.55569028417983], + "size": [400, 293], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 10 + }, + { + "name": "model", + "type": "MODEL", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [13] + } + ], + "properties": { + "proxyWidgets": [ + ["7", "text"], + ["6", "text"], + ["3", "seed"] + ] + }, + "widgets_values": [] + } + ], + "links": [ + [10, 4, 1, 10, 0, "CLIP"], + [11, 4, 0, 10, 1, "MODEL"], + [12, 4, 2, 10, 2, "VAE"], + [13, 10, 0, 9, 0, "IMAGE"] + ], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "d14ff4cf-e5cb-4c84-941f-7c2457476424", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 16, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "New Subgraph", + "inputNode": { + "id": -10, + "bounding": [233, 404.5, 120, 100] + }, + "outputNode": { + "id": -20, + "bounding": [1494, 424.5, 120, 60] + }, + "inputs": [ + { + "id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b", + "name": "clip", + "type": "CLIP", + "linkIds": [14], + "localized_name": "clip", + "pos": [333, 424.5] + }, + { + "id": "b4040cb7-0457-416e-ad6e-14890b871dd2", + "name": "model", + "type": "MODEL", + "linkIds": [1], + "localized_name": "model", + "pos": [333, 444.5] + }, + { + "id": "e61199fa-9113-4532-a3d9-879095969171", + "name": "vae", + "type": "VAE", + "linkIds": [8], + "localized_name": "vae", + "pos": [333, 464.5] + } + ], + "outputs": [ + { + "id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [9], + "localized_name": "IMAGE", + "pos": [1514, 444.5] + } + ], + "widgets": [], + "nodes": [ + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [473.007643669922, 609.0214689174805], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [2] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [512, 512, 1] + }, + { + "id": 3, + "type": "KSampler", + "pos": [862.990643669922, 185.9853293300783], + "size": [400, 317], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 16 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 15 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [7] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 156680208700286, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [1209.0062878349609, 188.00400724755877], + "size": [400, 200], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [9] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 11, + "type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6", + "pos": [485.5190761650391, 283.9247189174806], + "size": [400, 237], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 14 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [15] + }, + { + "localized_name": "CONDITIONING_1", + "name": "CONDITIONING_1", + "type": "CONDITIONING", + "links": [16] + } + ], + "properties": { + "proxyWidgets": [ + ["7", "text"], + ["6", "text"] + ] + }, + "widgets_values": [] + } + ], + "groups": [], + "links": [ + { + "id": 2, + "origin_id": 5, + "origin_slot": 0, + "target_id": 3, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 3, + "origin_slot": 0, + "target_id": 8, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 1, + "origin_id": -10, + "origin_slot": 1, + "target_id": 3, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 8, + "origin_id": -10, + "origin_slot": 2, + "target_id": 8, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 9, + "origin_id": 8, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 0, + "target_id": 11, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 15, + "origin_id": 11, + "origin_slot": 0, + "target_id": 3, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 16, + "origin_id": 11, + "origin_slot": 1, + "target_id": 3, + "target_slot": 1, + "type": "CONDITIONING" + } + ], + "extra": {} + }, + { + "id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 16, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "New Subgraph", + "inputNode": { + "id": -10, + "bounding": [233.01228575000005, 332.7902770140076, 120, 60] + }, + "outputNode": { + "id": -20, + "bounding": [ + 898.2956109453125, 322.7902770140076, 138.31666564941406, 80 + ] + }, + "inputs": [ + { + "id": "e5074a9c-3b33-4998-b569-0638817e81e7", + "name": "clip", + "type": "CLIP", + "linkIds": [5, 3], + "localized_name": "clip", + "pos": [55, 20] + } + ], + "outputs": [ + { + "id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15", + "name": "CONDITIONING", + "type": "CONDITIONING", + "linkIds": [6], + "localized_name": "CONDITIONING", + "pos": [20, 20] + }, + { + "id": "1e02089f-6491-45fa-aa0a-24458100f8ae", + "name": "CONDITIONING_1", + "type": "CONDITIONING", + "linkIds": [4], + "localized_name": "CONDITIONING_1", + "pos": [20, 40] + } + ], + "widgets": [], + "nodes": [ + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [413.01228575000005, 388.98593823266606], + "size": [425, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 5 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [6] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": ["text, watermark"] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [414.99053247091683, 185.9946096918335], + "size": [423, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 3 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [4] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "beautiful scenery nature glass bottle landscape, , purple galaxy bottle," + ] + } + ], + "groups": [], + "links": [ + { + "id": 5, + "origin_id": -10, + "origin_slot": 0, + "target_id": 7, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 3, + "origin_id": -10, + "origin_slot": 0, + "target_id": 6, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 6, + "origin_id": 7, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 4, + "origin_id": 6, + "origin_slot": 0, + "target_id": -20, + "target_slot": 1, + "type": "CONDITIONING" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 0.6830134553650709, + "offset": [-203.70966200000038, 259.92420099999975] + }, + "frontendVersion": "1.43.2" + }, + "version": 0.4 +} diff --git a/browser_tests/assets/subgraphs/test-values-input-subgraph.json b/browser_tests/assets/subgraphs/test-values-input-subgraph.json new file mode 100644 index 0000000000..bc1218fa46 --- /dev/null +++ b/browser_tests/assets/subgraphs/test-values-input-subgraph.json @@ -0,0 +1,407 @@ +{ + "id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7", + "revision": 0, + "last_node_id": 19, + "last_link_id": 24, + "nodes": [ + { + "id": 14, + "type": "CLIPLoader", + "pos": [143.16716182216328, 290.16372862874033], + "size": [270, 117.3125], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [21] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [null, "stable_diffusion", "default"] + }, + { + "id": 18, + "type": "PreviewImage", + "pos": [1305.1455526601603, 472.17095792625025], + "size": [225, 48], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 19, + "type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597", + "pos": [794.198171390827, 452.45433419677147], + "size": [225, 172], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "label": "renamed_clip", + "name": "clip", + "type": "CLIP", + "link": 21 + }, + { + "label": "renamed_seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 22 + }, + { + "label": "renamed_vae", + "name": "vae", + "type": "VAE", + "link": 23 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [24] + } + ], + "title": "Input Test Subgraph", + "properties": { + "proxyWidgets": [ + ["12", "seed"], + ["15", "text"] + ] + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "PrimitiveInt", + "pos": [155.04048166054417, 773.3816055422594], + "size": [270, 82], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "INT", + "type": "INT", + "links": [22] + } + ], + "title": "Seed Int", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [0, "randomize"] + }, + { + "id": 17, + "type": "VAELoader", + "pos": [163.6043676075426, 543.9624492717659], + "size": [270, 82.65625], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [23] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": ["pixel_space"] + } + ], + "links": [ + [21, 14, 0, 19, 0, "CLIP"], + [22, 13, 0, 19, 1, "INT"], + [23, 17, 0, 19, 2, "VAE"], + [24, 19, 0, 18, 0, "IMAGE"] + ], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 19, + "lastLinkId": 24, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Input Test Subgraph", + "inputNode": { + "id": -10, + "bounding": [ + 358.8694807105848, 439.23932667242485, 123.14453125, + 99.99999999999994 + ] + }, + "outputNode": { + "id": -20, + "bounding": [1408.5510580294986, 463.2512895126797, 120, 60] + }, + "inputs": [ + { + "id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c", + "name": "clip", + "type": "CLIP", + "linkIds": [16], + "localized_name": "clip", + "label": "renamed_clip", + "pos": [462.0140119605848, 459.23932667242485] + }, + { + "id": "2e4600ea-e1b1-42ca-b43a-e066fd080774", + "name": "seed", + "type": "INT", + "linkIds": [15], + "localized_name": "seed", + "label": "renamed_seed", + "pos": [462.0140119605848, 479.23932667242485] + }, + { + "id": "86ed2da7-db02-454a-9362-70a3fa3e91bf", + "name": "vae", + "type": "VAE", + "linkIds": [19], + "localized_name": "vae", + "label": "renamed_vae", + "pos": [462.0140119605848, 499.23932667242485] + } + ], + "outputs": [ + { + "id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [20], + "localized_name": "IMAGE", + "pos": [1428.5510580294986, 483.2512895126797] + } + ], + "widgets": [], + "nodes": [ + { + "id": 12, + "type": "KSampler", + "pos": [769.2424728654022, 512.726159169824], + "size": [270, 262], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": null + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 17 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": null + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": null + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 15 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [18] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1] + }, + { + "id": 16, + "type": "VAEDecode", + "pos": [1208.5510580294986, 469.21581253470083], + "size": [140, 46], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 18 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 19 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [20] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 15, + "type": "CLIPTextEncode", + "pos": [681.4596332342014, 243.17567172890932], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 16 + }, + { + "label": "renamed_from_sidepanel", + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [17] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [""] + } + ], + "groups": [], + "links": [ + { + "id": 17, + "origin_id": 15, + "origin_slot": 0, + "target_id": 12, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 18, + "origin_id": 12, + "origin_slot": 0, + "target_id": 16, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 0, + "target_id": 15, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 15, + "origin_id": -10, + "origin_slot": 1, + "target_id": 12, + "target_slot": 4, + "type": "INT" + }, + { + "id": 19, + "origin_id": -10, + "origin_slot": 2, + "target_id": 16, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 20, + "origin_id": 16, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 0.6727925600199565, + "offset": [446.69747171876463, 99.95078257277316] + } + }, + "version": 0.4 +} diff --git a/browser_tests/assets/widgets/painter_widget.json b/browser_tests/assets/widgets/painter_widget.json new file mode 100644 index 0000000000..84a49f619a --- /dev/null +++ b/browser_tests/assets/widgets/painter_widget.json @@ -0,0 +1,48 @@ +{ + "last_node_id": 1, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "Painter", + "pos": [50, 50], + "size": [450, 550], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "Painter" + }, + "widgets_values": ["", 512, 512, "#000000"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 17a93645cb..b9a9806b55 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -5,7 +5,7 @@ import type { Page } from '@playwright/test' import { test as base, expect } from '@playwright/test' -import dotenv from 'dotenv' +import { config as dotenvConfig } from 'dotenv' import { TestIds } from './selectors' import { NodeBadgeMode } from '../../src/types/nodeSource' @@ -19,10 +19,12 @@ import { ContextMenu } from './components/ContextMenu' import { SettingDialog } from './components/SettingDialog' import { BottomPanel } from './components/BottomPanel' import { + AssetsSidebarTab, NodeLibrarySidebarTab, WorkflowsSidebarTab } from './components/SidebarTab' import { Topbar } from './components/Topbar' +import { AssetsHelper } from './helpers/AssetsHelper' import { CanvasHelper } from './helpers/CanvasHelper' import { PerformanceHelper } from './helpers/PerformanceHelper' import { QueueHelper } from './helpers/QueueHelper' @@ -40,7 +42,7 @@ import { WorkflowHelper } from './helpers/WorkflowHelper' import type { NodeReference } from './utils/litegraphUtils' import type { WorkspaceStore } from '../types/globals' -dotenv.config() +dotenvConfig() class ComfyPropertiesPanel { readonly root: Locator @@ -55,6 +57,7 @@ class ComfyPropertiesPanel { } class ComfyMenu { + private _assetsTab: AssetsSidebarTab | null = null private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null private _topbar: Topbar | null = null @@ -78,6 +81,11 @@ class ComfyMenu { return this._nodeLibraryTab } + get assetsTab() { + this._assetsTab ??= new AssetsSidebarTab(this.page) + return this._assetsTab + } + get workflowsTab() { this._workflowsTab ??= new WorkflowsSidebarTab(this.page) return this._workflowsTab @@ -192,6 +200,7 @@ export class ComfyPage { public readonly command: CommandHelper public readonly bottomPanel: BottomPanel public readonly perf: PerformanceHelper + public readonly assets: AssetsHelper public readonly queue: QueueHelper /** Worker index to test user ID */ @@ -238,6 +247,7 @@ export class ComfyPage { this.command = new CommandHelper(page) this.bottomPanel = new BottomPanel(page) this.perf = new PerformanceHelper(page) + this.assets = new AssetsHelper(page) this.queue = new QueueHelper(page) } @@ -452,12 +462,13 @@ export const comfyPageFixture = base.extend<{ await comfyPage.setup() - const isPerf = testInfo.tags.includes('@perf') - if (isPerf) await comfyPage.perf.init() + const needsPerf = + testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit') + if (needsPerf) await comfyPage.perf.init() await use(comfyPage) - if (isPerf) await comfyPage.perf.dispose() + if (needsPerf) await comfyPage.perf.dispose() }, comfyMouse: async ({ comfyPage }, use) => { const comfyMouse = new ComfyMouse(comfyPage) diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index 02da0b0434..119c41ed58 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -168,3 +168,32 @@ export class WorkflowsSidebarTab extends SidebarTab { .click() } } + +export class AssetsSidebarTab extends SidebarTab { + constructor(public override readonly page: Page) { + super(page, 'assets') + } + + get generatedTab() { + return this.page.getByRole('tab', { name: 'Generated' }) + } + + get importedTab() { + return this.page.getByRole('tab', { name: 'Imported' }) + } + + get emptyStateMessage() { + return this.page.getByText( + 'Upload files or generate content to see them here' + ) + } + + emptyStateTitle(title: string) { + return this.page.getByText(title) + } + + override async open() { + await super.open() + await this.generatedTab.waitFor({ state: 'visible' }) + } +} diff --git a/browser_tests/fixtures/helpers/AssetsHelper.ts b/browser_tests/fixtures/helpers/AssetsHelper.ts new file mode 100644 index 0000000000..a9d8e69a6e --- /dev/null +++ b/browser_tests/fixtures/helpers/AssetsHelper.ts @@ -0,0 +1,147 @@ +import type { Page, Route } from '@playwright/test' + +import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes' + +const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/ +const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/ + +function parseLimit(url: URL, total: number): number { + const value = Number(url.searchParams.get('limit')) + if (!Number.isInteger(value) || value <= 0) { + return total + } + return value +} + +function parseOffset(url: URL): number { + const value = Number(url.searchParams.get('offset')) + if (!Number.isInteger(value) || value < 0) { + return 0 + } + return value +} + +function getExecutionDuration(job: RawJobListItem): number { + const start = job.execution_start_time ?? 0 + const end = job.execution_end_time ?? 0 + return end - start +} + +export class AssetsHelper { + private jobsRouteHandler: ((route: Route) => Promise) | null = null + private inputFilesRouteHandler: ((route: Route) => Promise) | null = + null + private generatedJobs: RawJobListItem[] = [] + private importedFiles: string[] = [] + + constructor(private readonly page: Page) {} + + async mockOutputHistory(jobs: RawJobListItem[]): Promise { + this.generatedJobs = [...jobs] + + if (this.jobsRouteHandler) { + return + } + + this.jobsRouteHandler = async (route: Route) => { + const url = new URL(route.request().url()) + const statuses = url.searchParams + .get('status') + ?.split(',') + .map((status) => status.trim()) + .filter(Boolean) + const workflowId = url.searchParams.get('workflow_id') + const sortBy = url.searchParams.get('sort_by') + const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1 + + let filteredJobs = [...this.generatedJobs] + + if (statuses?.length) { + filteredJobs = filteredJobs.filter((job) => + statuses.includes(job.status) + ) + } + + if (workflowId) { + filteredJobs = filteredJobs.filter( + (job) => job.workflow_id === workflowId + ) + } + + filteredJobs.sort((left, right) => { + const leftValue = + sortBy === 'execution_duration' + ? getExecutionDuration(left) + : left.create_time + const rightValue = + sortBy === 'execution_duration' + ? getExecutionDuration(right) + : right.create_time + + return (leftValue - rightValue) * sortOrder + }) + + const offset = parseOffset(url) + const total = filteredJobs.length + const limit = parseLimit(url, total) + const visibleJobs = filteredJobs.slice(offset, offset + limit) + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + jobs: visibleJobs, + pagination: { + offset, + limit, + total, + has_more: offset + visibleJobs.length < total + } + }) + }) + } + + await this.page.route(jobsListRoutePattern, this.jobsRouteHandler) + } + + async mockInputFiles(files: string[]): Promise { + this.importedFiles = [...files] + + if (this.inputFilesRouteHandler) { + return + } + + this.inputFilesRouteHandler = async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(this.importedFiles) + }) + } + + await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler) + } + + async mockEmptyState(): Promise { + await this.mockOutputHistory([]) + await this.mockInputFiles([]) + } + + async clearMocks(): Promise { + this.generatedJobs = [] + this.importedFiles = [] + + if (this.jobsRouteHandler) { + await this.page.unroute(jobsListRoutePattern, this.jobsRouteHandler) + this.jobsRouteHandler = null + } + + if (this.inputFilesRouteHandler) { + await this.page.unroute( + inputFilesRoutePattern, + this.inputFilesRouteHandler + ) + this.inputFilesRouteHandler = null + } + } +} diff --git a/browser_tests/fixtures/helpers/CanvasHelper.ts b/browser_tests/fixtures/helpers/CanvasHelper.ts index dfd730c8ea..92f3f8a984 100644 --- a/browser_tests/fixtures/helpers/CanvasHelper.ts +++ b/browser_tests/fixtures/helpers/CanvasHelper.ts @@ -91,6 +91,12 @@ export class CanvasHelper { await this.page.mouse.move(10, 10) } + async isReadOnly(): Promise { + return this.page.evaluate(() => { + return window.app!.canvas.state.readOnly + }) + } + async getScale(): Promise { return this.page.evaluate(() => { return window.app!.canvas.ds.scale diff --git a/browser_tests/fixtures/helpers/DragDropHelper.ts b/browser_tests/fixtures/helpers/DragDropHelper.ts index 75c1e0e71f..2f17c98aae 100644 --- a/browser_tests/fixtures/helpers/DragDropHelper.ts +++ b/browser_tests/fixtures/helpers/DragDropHelper.ts @@ -25,13 +25,15 @@ export class DragDropHelper { url?: string dropPosition?: Position waitForUpload?: boolean + preserveNativePropagation?: boolean } = {} ): Promise { const { dropPosition = { x: 100, y: 100 }, fileName, url, - waitForUpload = false + waitForUpload = false, + preserveNativePropagation = false } = options if (!fileName && !url) @@ -43,7 +45,8 @@ export class DragDropHelper { fileType?: string buffer?: Uint8Array | number[] url?: string - } = { dropPosition } + preserveNativePropagation: boolean + } = { dropPosition, preserveNativePropagation } if (fileName) { const filePath = this.assetPath(fileName) @@ -115,15 +118,17 @@ export class DragDropHelper { ) } - Object.defineProperty(dropEvent, 'preventDefault', { - value: () => {}, - writable: false - }) + if (!params.preserveNativePropagation) { + Object.defineProperty(dropEvent, 'preventDefault', { + value: () => {}, + writable: false + }) - Object.defineProperty(dropEvent, 'stopPropagation', { - value: () => {}, - writable: false - }) + Object.defineProperty(dropEvent, 'stopPropagation', { + value: () => {}, + writable: false + }) + } targetElement.dispatchEvent(dragOverEvent) targetElement.dispatchEvent(dropEvent) @@ -154,7 +159,10 @@ export class DragDropHelper { async dragAndDropURL( url: string, - options: { dropPosition?: Position } = {} + options: { + dropPosition?: Position + preserveNativePropagation?: boolean + } = {} ): Promise { return this.dragAndDropExternalResource({ url, ...options }) } diff --git a/browser_tests/fixtures/helpers/PerformanceHelper.ts b/browser_tests/fixtures/helpers/PerformanceHelper.ts index 92e47ecc25..d779dcfb04 100644 --- a/browser_tests/fixtures/helpers/PerformanceHelper.ts +++ b/browser_tests/fixtures/helpers/PerformanceHelper.ts @@ -23,6 +23,7 @@ export interface PerfMeasurement { layoutDurationMs: number taskDurationMs: number heapDeltaBytes: number + heapUsedBytes: number domNodes: number jsHeapTotalBytes: number scriptDurationMs: number @@ -190,6 +191,7 @@ export class PerformanceHelper { layoutDurationMs: delta('LayoutDuration') * 1000, taskDurationMs: delta('TaskDuration') * 1000, heapDeltaBytes: delta('JSHeapUsedSize'), + heapUsedBytes: after.JSHeapUsedSize, domNodes: delta('Nodes'), jsHeapTotalBytes: delta('JSHeapTotalSize'), scriptDurationMs: delta('ScriptDuration') * 1000, diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 0fecdbeefe..688ea605d9 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -20,7 +20,12 @@ export const TestIds = { main: 'graph-canvas', contextMenu: 'canvas-context-menu', toggleMinimapButton: 'toggle-minimap-button', - toggleLinkVisibilityButton: 'toggle-link-visibility-button' + toggleLinkVisibilityButton: 'toggle-link-visibility-button', + zoomControlsButton: 'zoom-controls-button', + zoomInAction: 'zoom-in-action', + zoomOutAction: 'zoom-out-action', + zoomToFitAction: 'zoom-to-fit-action', + zoomPercentageInput: 'zoom-percentage-input' }, dialogs: { settings: 'settings-dialog', @@ -28,10 +33,17 @@ export const TestIds = { settingsTabAbout: 'settings-tab-about', confirm: 'confirm-dialog', errorOverlay: 'error-overlay', + errorOverlaySeeErrors: 'error-overlay-see-errors', + errorOverlayDismiss: 'error-overlay-dismiss', + errorOverlayMessages: 'error-overlay-messages', runtimeErrorPanel: 'runtime-error-panel', missingNodeCard: 'missing-node-card', + errorCardFindOnGithub: 'error-card-find-on-github', + errorCardCopy: 'error-card-copy', about: 'about-panel', - whatsNewSection: 'whats-new-section' + whatsNewSection: 'whats-new-section', + missingNodePacksGroup: 'error-group-missing-node', + missingModelsGroup: 'error-group-missing-model' }, keybindings: { presetMenu: 'keybinding-preset-menu' @@ -57,6 +69,8 @@ export const TestIds = { colorRed: 'red' }, widgets: { + container: 'node-widgets', + widget: 'node-widget', decrement: 'decrement', increment: 'increment', domWidgetTextarea: 'dom-widget-textarea', @@ -76,6 +90,10 @@ export const TestIds = { }, user: { currentUserIndicator: 'current-user-indicator' + }, + errors: { + imageLoadError: 'error-loading-image', + videoLoadError: 'error-loading-video' } } as const @@ -101,3 +119,4 @@ export type TestIdValue = (id: string) => string > | (typeof TestIds.user)[keyof typeof TestIds.user] + | (typeof TestIds.errors)[keyof typeof TestIds.errors] diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index b1295e5a89..17e0d16e1c 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -281,6 +281,14 @@ export class NodeReference { getType(): Promise { return this.getProperty('type') } + async centerOnNode(): Promise { + await this.comfyPage.page.evaluate((id) => { + const node = window.app!.canvas.graph!.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found`) + window.app!.canvas.centerOnNode(node) + }, this.id) + await this.comfyPage.nextFrame() + } async getPosition(): Promise { const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas( await this.getProperty<[number, number]>('pos') diff --git a/browser_tests/globalSetup.ts b/browser_tests/globalSetup.ts index b43b77c6a6..70ba5ed828 100644 --- a/browser_tests/globalSetup.ts +++ b/browser_tests/globalSetup.ts @@ -1,11 +1,10 @@ -import type { FullConfig } from '@playwright/test' -import dotenv from 'dotenv' +import { config as dotenvConfig } from 'dotenv' import { backupPath } from './utils/backupUtils' -dotenv.config() +dotenvConfig() -export default function globalSetup(_config: FullConfig) { +export default function globalSetup() { if (!process.env.CI) { if (process.env.TEST_COMFYUI_DIR) { backupPath([process.env.TEST_COMFYUI_DIR, 'user']) diff --git a/browser_tests/globalTeardown.ts b/browser_tests/globalTeardown.ts index 38dc26a3d2..69660eef9f 100644 --- a/browser_tests/globalTeardown.ts +++ b/browser_tests/globalTeardown.ts @@ -1,12 +1,11 @@ -import type { FullConfig } from '@playwright/test' -import dotenv from 'dotenv' +import { config as dotenvConfig } from 'dotenv' import { writePerfReport } from './helpers/perfReporter' import { restorePath } from './utils/backupUtils' -dotenv.config() +dotenvConfig() -export default function globalTeardown(_config: FullConfig) { +export default function globalTeardown() { writePerfReport() if (!process.env.CI && process.env.TEST_COMFYUI_DIR) { diff --git a/browser_tests/helpers/subgraphTestUtils.ts b/browser_tests/helpers/subgraphTestUtils.ts index 0eae24651d..451ae60a30 100644 --- a/browser_tests/helpers/subgraphTestUtils.ts +++ b/browser_tests/helpers/subgraphTestUtils.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test' + import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph' import { isSubgraph } from '../../src/utils/typeGuardUtil' @@ -14,3 +16,30 @@ export function assertSubgraph( ) } } + +/** + * Returns the widget-input slot Y position and the node title height + * for the promoted "text" input on the SubgraphNode. + * + * The slot Y should be at the widget row, not the header. A value near + * zero or negative indicates the slot is positioned at the header (the bug). + */ +export function getTextSlotPosition(page: Page, nodeId: string) { + return page.evaluate((id) => { + const node = window.app!.canvas.graph!.getNodeById(id) + if (!node) return null + + const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT + + for (const input of node.inputs) { + if (!input.widget || input.type !== 'STRING') continue + return { + hasPos: !!input.pos, + posY: input.pos?.[1] ?? null, + widgetName: input.widget.name, + titleHeight + } + } + return null + }, nodeId) +} diff --git a/browser_tests/tests/appModeWidgetRename.spec.ts b/browser_tests/tests/appModeWidgetRename.spec.ts index e9c884e2f9..55c5ba3574 100644 --- a/browser_tests/tests/appModeWidgetRename.spec.ts +++ b/browser_tests/tests/appModeWidgetRename.spec.ts @@ -30,10 +30,18 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) { await appMode.enterBuilder() await appMode.goToInputs() + // Reset zoom to 1 and center on the subgraph node so click coords are accurate + await comfyPage.canvasOps.setScale(1) + await subgraphNode.centerOnNode() + // Click the promoted seed widget on the canvas to select it const seedWidgetRef = await subgraphNode.getWidget(0) const seedPos = await seedWidgetRef.getPosition() - await page.mouse.click(seedPos.x, seedPos.y) + const titleHeight = await page.evaluate( + () => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number + ) + + await page.mouse.click(seedPos.x, seedPos.y + titleHeight) await comfyPage.nextFrame() // Select an output node @@ -48,9 +56,15 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) { ) ) const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId) - const saveImagePos = await saveImageRef.getPosition() - // Click left edge — the right side is hidden by the builder panel - await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10) + await saveImageRef.centerOnNode() + + // Node is centered on screen, so click the canvas center + const canvasBox = await page.locator('#graph-canvas').boundingBox() + if (!canvasBox) throw new Error('Canvas not found') + await page.mouse.click( + canvasBox.x + canvasBox.width / 2, + canvasBox.y + canvasBox.height / 2 + ) await comfyPage.nextFrame() return subgraphNode @@ -80,6 +94,10 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => { } }) await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.settings.setSetting( + 'Comfy.AppBuilder.VueNodeSwitchDismissed', + true + ) }) test('Rename from builder input-select sidebar', async ({ comfyPage }) => { diff --git a/browser_tests/tests/changeTrackerLoadGuard.spec.ts b/browser_tests/tests/changeTrackerLoadGuard.spec.ts new file mode 100644 index 0000000000..07cbbdc3ce --- /dev/null +++ b/browser_tests/tests/changeTrackerLoadGuard.spec.ts @@ -0,0 +1,66 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../fixtures/ComfyPage' +import type { WorkspaceStore } from '../types/globals' + +test.describe( + 'Change Tracker - isLoadingGraph guard', + { tag: '@workflow' }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.setupWorkflowsDirectory({}) + }) + + test('Prevents checkState from corrupting workflow state during tab switch', async ({ + comfyPage + }) => { + // Tab 0: default workflow (7 nodes) + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7) + + // Save tab 0 so it has a unique name for tab switching + await comfyPage.menu.topbar.saveWorkflow('workflow-a') + + // Register an extension that forces checkState during graph loading. + // This simulates the bug scenario where a user clicks during graph loading + // which triggers a checkState call on the wrong graph, corrupting the activeState. + await comfyPage.page.evaluate(() => { + window.app!.registerExtension({ + name: 'TestCheckStateDuringLoad', + afterConfigureGraph() { + const workflow = (window.app!.extensionManager as WorkspaceStore) + .workflow.activeWorkflow + if (!workflow) throw new Error('No workflow found') + // Bypass the guard to reproduce the corruption bug: + // ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false + + // Simulate the user clicking during graph loading + workflow.changeTracker.checkState() + } + }) + }) + + // Create tab 1: blank workflow (0 nodes) + await comfyPage.menu.topbar.triggerTopbarCommand(['New']) + await comfyPage.nextFrame() + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0) + + // Switch back to tab 0 (workflow-a). + const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a') + await tab0.click() + await comfyPage.nextFrame() + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7) + + // switch to blank tab and back to verify no corruption + const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow') + await tab1.click() + await comfyPage.nextFrame() + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0) + + // switch again and verify no corruption + await tab0.click() + await comfyPage.nextFrame() + expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7) + }) + } +) diff --git a/browser_tests/tests/colorPalette.spec.ts b/browser_tests/tests/colorPalette.spec.ts index 31aa14d7dc..968baab73a 100644 --- a/browser_tests/tests/colorPalette.spec.ts +++ b/browser_tests/tests/colorPalette.spec.ts @@ -38,16 +38,13 @@ const customColorPalettes = { CLEAR_BACKGROUND_COLOR: '#222222', NODE_TITLE_COLOR: 'rgba(255,255,255,.75)', NODE_SELECTED_TITLE_COLOR: '#FFF', - NODE_TEXT_SIZE: 14, NODE_TEXT_COLOR: '#b8b8b8', - NODE_SUBTEXT_SIZE: 12, NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)', NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)', NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)', NODE_DEFAULT_SHAPE: 'box', NODE_BOX_OUTLINE_COLOR: '#236692', DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)', - DEFAULT_GROUP_FONT: 24, WIDGET_BGCOLOR: '#242424', WIDGET_OUTLINE_COLOR: '#333', WIDGET_TEXT_COLOR: '#a3a3a8', @@ -102,16 +99,13 @@ const customColorPalettes = { CLEAR_BACKGROUND_COLOR: '#000', NODE_TITLE_COLOR: 'rgba(255,255,255,.75)', NODE_SELECTED_TITLE_COLOR: '#FFF', - NODE_TEXT_SIZE: 14, NODE_TEXT_COLOR: '#b8b8b8', - NODE_SUBTEXT_SIZE: 12, NODE_DEFAULT_COLOR: 'rgba(0,0,0,.8)', NODE_DEFAULT_BGCOLOR: 'rgba(22,22,22,.8)', NODE_DEFAULT_BOXCOLOR: 'rgba(255,255,255,.75)', NODE_DEFAULT_SHAPE: 'box', NODE_BOX_OUTLINE_COLOR: '#236692', DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0)', - DEFAULT_GROUP_FONT: 24, WIDGET_BGCOLOR: '#242424', WIDGET_OUTLINE_COLOR: '#333', WIDGET_TEXT_COLOR: '#a3a3a8', diff --git a/browser_tests/tests/containAudit.spec.ts b/browser_tests/tests/containAudit.spec.ts new file mode 100644 index 0000000000..52008664ac --- /dev/null +++ b/browser_tests/tests/containAudit.spec.ts @@ -0,0 +1,276 @@ +import { expect } from '@playwright/test' + +import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +interface ContainCandidate { + selector: string + testId: string | null + tagName: string + className: string + subtreeSize: number + hasFixedWidth: boolean + isFlexChild: boolean + hasExplicitDimensions: boolean + alreadyContained: boolean + score: number +} + +interface AuditResult { + candidate: ContainCandidate + baseline: Pick + withContain: Pick< + PerfMeasurement, + 'styleRecalcs' | 'layouts' | 'taskDurationMs' + > + deltaRecalcsPct: number + deltaLayoutsPct: number + visuallyBroken: boolean +} + +function formatPctDelta(value: number): string { + const sign = value >= 0 ? '+' : '' + return `${sign}${value.toFixed(1)}%` +} + +function pctChange(baseline: number, measured: number): number { + if (baseline === 0) return 0 + return ((measured - baseline) / baseline) * 100 +} + +const STABILIZATION_FRAMES = 60 +const SETTLE_FRAMES = 10 + +test.describe('CSS Containment Audit', { tag: ['@audit'] }, () => { + test('scan large graph for containment candidates', async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('large-graph-workflow') + + for (let i = 0; i < STABILIZATION_FRAMES; i++) { + await comfyPage.nextFrame() + } + + // Walk the DOM and find candidates + const candidates = await comfyPage.page.evaluate((): ContainCandidate[] => { + const results: ContainCandidate[] = [] + + const graphContainer = + document.querySelector('.graph-canvas-container') ?? + document.querySelector('[class*="comfy-vue-node"]')?.parentElement ?? + document.querySelector('.lg-node')?.parentElement + + const root = graphContainer ?? document.body + const allElements = root.querySelectorAll('*') + + allElements.forEach((el) => { + if (!(el instanceof HTMLElement)) return + + const subtreeSize = el.querySelectorAll('*').length + if (subtreeSize < 5) return + + const computed = getComputedStyle(el) + + const containValue = computed.contain || 'none' + const alreadyContained = + containValue.includes('layout') || containValue.includes('strict') + + const hasFixedWidth = + computed.width !== 'auto' && + !computed.width.includes('%') && + computed.width !== '0px' + + const isFlexChild = + el.parentElement !== null && + getComputedStyle(el.parentElement).display.includes('flex') && + (computed.flexGrow !== '0' || computed.flexShrink !== '1') + + const hasExplicitDimensions = + hasFixedWidth || + (computed.minWidth !== '0px' && computed.minWidth !== 'auto') || + (computed.maxWidth !== 'none' && computed.maxWidth !== '0px') + + let score = subtreeSize + if (hasExplicitDimensions) score *= 2 + if (isFlexChild) score *= 1.5 + if (alreadyContained) score = 0 + + let selector = el.tagName.toLowerCase() + const testId = el.getAttribute('data-testid') + if (testId) { + selector = `[data-testid="${testId}"]` + } else if (el.id) { + selector = `#${el.id}` + } else if (el.parentElement) { + // Use nth-child to disambiguate instead of fragile first-class fallback + // (e.g. Tailwind utilities like .flex, .relative are shared across many elements) + const children = Array.from(el.parentElement.children) + const index = children.indexOf(el) + 1 + const parentTestId = el.parentElement.getAttribute('data-testid') + if (parentTestId) { + selector = `[data-testid="${parentTestId}"] > :nth-child(${index})` + } else if (el.parentElement.id) { + selector = `#${el.parentElement.id} > :nth-child(${index})` + } else { + const tag = el.tagName.toLowerCase() + selector = `${tag}:nth-child(${index})` + } + } + + results.push({ + selector, + testId, + tagName: el.tagName.toLowerCase(), + className: + typeof el.className === 'string' ? el.className.slice(0, 80) : '', + subtreeSize, + hasFixedWidth, + isFlexChild, + hasExplicitDimensions, + alreadyContained, + score + }) + }) + + results.sort((a, b) => b.score - a.score) + return results.slice(0, 20) + }) + + console.log(`\nFound ${candidates.length} containment candidates\n`) + + // Deduplicate candidates by selector (keep highest score) + const seen = new Set() + const uniqueCandidates = candidates.filter((c) => { + if (seen.has(c.selector)) return false + seen.add(c.selector) + return true + }) + + // Measure baseline performance (idle) + await comfyPage.perf.startMeasuring() + for (let i = 0; i < STABILIZATION_FRAMES; i++) { + await comfyPage.nextFrame() + } + const baseline = await comfyPage.perf.stopMeasuring('baseline-idle') + + // Take a baseline screenshot for visual comparison + const baselineScreenshot = await comfyPage.page.screenshot() + + // For each candidate, apply contain and measure + const results: AuditResult[] = [] + + const testCandidates = uniqueCandidates + .filter((c) => !c.alreadyContained && c.score > 0) + .slice(0, 10) + + for (const candidate of testCandidates) { + const applied = await comfyPage.page.evaluate((sel: string) => { + const elements = document.querySelectorAll(sel) + let count = 0 + elements.forEach((el) => { + if (el instanceof HTMLElement) { + el.style.contain = 'layout style' + count++ + } + }) + return count + }, candidate.selector) + + if (applied === 0) continue + + for (let i = 0; i < SETTLE_FRAMES; i++) { + await comfyPage.nextFrame() + } + + // Measure with containment + await comfyPage.perf.startMeasuring() + for (let i = 0; i < STABILIZATION_FRAMES; i++) { + await comfyPage.nextFrame() + } + const withContain = await comfyPage.perf.stopMeasuring( + `contain-${candidate.selector}` + ) + + // Take screenshot with containment applied to detect visual breakage. + // Note: PNG byte comparison can produce false positives from subpixel + // rendering and anti-aliasing. Treat "DIFF" as "needs manual review". + const containScreenshot = await comfyPage.page.screenshot() + const visuallyBroken = !baselineScreenshot.equals(containScreenshot) + + // Remove containment + await comfyPage.page.evaluate((sel: string) => { + document.querySelectorAll(sel).forEach((el) => { + if (el instanceof HTMLElement) { + el.style.contain = '' + } + }) + }, candidate.selector) + + for (let i = 0; i < SETTLE_FRAMES; i++) { + await comfyPage.nextFrame() + } + + results.push({ + candidate, + baseline: { + styleRecalcs: baseline.styleRecalcs, + layouts: baseline.layouts, + taskDurationMs: baseline.taskDurationMs + }, + withContain: { + styleRecalcs: withContain.styleRecalcs, + layouts: withContain.layouts, + taskDurationMs: withContain.taskDurationMs + }, + deltaRecalcsPct: pctChange( + baseline.styleRecalcs, + withContain.styleRecalcs + ), + deltaLayoutsPct: pctChange(baseline.layouts, withContain.layouts), + visuallyBroken + }) + } + + // Print the report + const divider = '='.repeat(100) + const thinDivider = '-'.repeat(100) + console.log('\n') + console.log('CSS Containment Audit Results') + console.log(divider) + console.log( + 'Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual' + ) + console.log(thinDivider) + + results + .sort((a, b) => a.deltaRecalcsPct - b.deltaRecalcsPct) + .forEach((r, i) => { + const sel = r.candidate.selector.padEnd(42) + const sub = String(r.candidate.subtreeSize).padStart(7) + const score = String(Math.round(r.candidate.score)).padStart(5) + const dr = formatPctDelta(r.deltaRecalcsPct) + const dl = formatPctDelta(r.deltaLayoutsPct) + const vis = r.visuallyBroken ? 'DIFF' : 'OK' + console.log( + ` ${String(i + 1).padStart(2)} | ${sel} | ${sub} | ${score} | ${dr.padStart(10)} | ${dl.padStart(10)} | ${vis}` + ) + }) + + console.log(divider) + console.log( + `\nBaseline: ${baseline.styleRecalcs} style recalcs, ${baseline.layouts} layouts, ${baseline.taskDurationMs.toFixed(1)}ms task duration\n` + ) + + const alreadyContained = uniqueCandidates.filter((c) => c.alreadyContained) + if (alreadyContained.length > 0) { + console.log('Already contained elements:') + alreadyContained.forEach((c) => { + console.log(` ${c.selector} (subtree: ${c.subtreeSize})`) + }) + } + + expect(results.length).toBeGreaterThan(0) + }) + + // Pan interaction perf measurement removed — covered by PR #10001 (performance.spec.ts). + // The containment fix itself is tracked in PR #9946. +}) diff --git a/browser_tests/tests/defaultKeybindings.spec.ts b/browser_tests/tests/defaultKeybindings.spec.ts new file mode 100644 index 0000000000..59ecf85f3b --- /dev/null +++ b/browser_tests/tests/defaultKeybindings.spec.ts @@ -0,0 +1,318 @@ +import { expect } from '@playwright/test' + +import type { ComfyPage } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +async function pressKeyAndExpectRequest( + comfyPage: ComfyPage, + key: string, + urlPattern: string, + method: string = 'POST' +) { + const requestPromise = comfyPage.page.waitForRequest( + (req) => req.url().includes(urlPattern) && req.method() === method, + { timeout: 5000 } + ) + await comfyPage.page.keyboard.press(key) + return requestPromise +} + +test.describe('Default Keybindings', { tag: '@keyboard' }, () => { + test.describe('Sidebar Toggle Shortcuts', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.canvas.click({ position: { x: 400, y: 400 } }) + await comfyPage.nextFrame() + }) + + const sidebarTabs = [ + { key: 'KeyW', tabId: 'workflows', label: 'workflows' }, + { key: 'KeyN', tabId: 'node-library', label: 'node library' }, + { key: 'KeyM', tabId: 'model-library', label: 'model library' }, + { key: 'KeyA', tabId: 'assets', label: 'assets' } + ] as const + + for (const { key, tabId, label } of sidebarTabs) { + test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => { + const selectedButton = comfyPage.page.locator( + `.${tabId}-tab-button.side-bar-button-selected` + ) + + await expect(selectedButton).not.toBeVisible() + + await comfyPage.canvas.press(key) + await comfyPage.nextFrame() + await expect(selectedButton).toBeVisible() + + await comfyPage.canvas.press(key) + await comfyPage.nextFrame() + await expect(selectedButton).not.toBeVisible() + }) + } + }) + + test.describe('Canvas View Controls', () => { + test("'Alt+=' zooms in", async ({ comfyPage }) => { + const initialScale = await comfyPage.canvasOps.getScale() + + await comfyPage.canvas.press('Alt+Equal') + await comfyPage.nextFrame() + + const newScale = await comfyPage.canvasOps.getScale() + expect(newScale).toBeGreaterThan(initialScale) + }) + + test("'Alt+-' zooms out", async ({ comfyPage }) => { + const initialScale = await comfyPage.canvasOps.getScale() + + await comfyPage.canvas.press('Alt+Minus') + await comfyPage.nextFrame() + + const newScale = await comfyPage.canvasOps.getScale() + expect(newScale).toBeLessThan(initialScale) + }) + + test("'.' fits view to nodes", async ({ comfyPage }) => { + // Set scale very small so fit-view will zoom back to fit nodes + await comfyPage.canvasOps.setScale(0.1) + const scaleBefore = await comfyPage.canvasOps.getScale() + expect(scaleBefore).toBeCloseTo(0.1, 1) + + // Click canvas to ensure focus is within graph-canvas-container + await comfyPage.canvas.click({ position: { x: 400, y: 400 } }) + await comfyPage.nextFrame() + + await comfyPage.canvas.press('Period') + await comfyPage.nextFrame() + + const scaleAfter = await comfyPage.canvasOps.getScale() + expect(scaleAfter).toBeGreaterThan(scaleBefore) + }) + + test("'h' locks canvas", async ({ comfyPage }) => { + expect(await comfyPage.canvasOps.isReadOnly()).toBe(false) + + await comfyPage.canvas.press('KeyH') + await comfyPage.nextFrame() + + expect(await comfyPage.canvasOps.isReadOnly()).toBe(true) + }) + + test("'v' unlocks canvas", async ({ comfyPage }) => { + // Lock first + await comfyPage.command.executeCommand('Comfy.Canvas.Lock') + await comfyPage.nextFrame() + expect(await comfyPage.canvasOps.isReadOnly()).toBe(true) + + await comfyPage.canvas.press('KeyV') + await comfyPage.nextFrame() + + expect(await comfyPage.canvasOps.isReadOnly()).toBe(false) + }) + }) + + test.describe('Node State Toggles', () => { + test("'Alt+c' collapses and expands selected nodes", async ({ + comfyPage + }) => { + const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + expect(nodes.length).toBeGreaterThan(0) + const node = nodes[0] + + await node.click('title') + await comfyPage.nextFrame() + + expect(await node.isCollapsed()).toBe(false) + + await comfyPage.canvas.press('Alt+KeyC') + await comfyPage.nextFrame() + expect(await node.isCollapsed()).toBe(true) + + await comfyPage.canvas.press('Alt+KeyC') + await comfyPage.nextFrame() + expect(await node.isCollapsed()).toBe(false) + }) + + test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => { + const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + expect(nodes.length).toBeGreaterThan(0) + const node = nodes[0] + + await node.click('title') + await comfyPage.nextFrame() + + // Normal mode is ALWAYS (0) + const getMode = () => + comfyPage.page.evaluate((nodeId) => { + return window.app!.canvas.graph!.getNodeById(nodeId)!.mode + }, node.id) + + expect(await getMode()).toBe(0) + + await comfyPage.canvas.press('Control+KeyM') + await comfyPage.nextFrame() + // NEVER (2) = muted + expect(await getMode()).toBe(2) + + await comfyPage.canvas.press('Control+KeyM') + await comfyPage.nextFrame() + expect(await getMode()).toBe(0) + }) + }) + + test.describe('Mode and Panel Toggles', () => { + test("'Alt+m' toggles app mode", async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + + // Set up linearData so app mode has something to show + await comfyPage.appMode.enterAppModeWithInputs([]) + await expect(comfyPage.appMode.linearWidgets).toBeVisible() + + // Toggle off with Alt+m + await comfyPage.page.keyboard.press('Alt+KeyM') + await comfyPage.nextFrame() + await expect(comfyPage.appMode.linearWidgets).not.toBeVisible() + + // Toggle on again + await comfyPage.page.keyboard.press('Alt+KeyM') + await comfyPage.nextFrame() + await expect(comfyPage.appMode.linearWidgets).toBeVisible() + }) + + test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true) + await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true) + await comfyPage.workflow.loadWorkflow('default') + + const minimap = comfyPage.page.locator('.litegraph-minimap') + await expect(minimap).toBeVisible() + + await comfyPage.page.keyboard.press('Alt+Shift+KeyM') + await comfyPage.nextFrame() + await expect(minimap).not.toBeVisible() + + await comfyPage.page.keyboard.press('Alt+Shift+KeyM') + await comfyPage.nextFrame() + await expect(minimap).toBeVisible() + }) + + test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + + await expect(comfyPage.bottomPanel.root).not.toBeVisible() + + await comfyPage.page.keyboard.press('Control+Backquote') + await comfyPage.nextFrame() + await expect(comfyPage.bottomPanel.root).toBeVisible() + + await comfyPage.page.keyboard.press('Control+Backquote') + await comfyPage.nextFrame() + await expect(comfyPage.bottomPanel.root).not.toBeVisible() + }) + }) + + test.describe('Queue and Execution', () => { + test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => { + const request = await pressKeyAndExpectRequest( + comfyPage, + 'Control+Enter', + '/prompt', + 'POST' + ) + expect(request.url()).toContain('/prompt') + }) + + test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => { + const request = await pressKeyAndExpectRequest( + comfyPage, + 'Control+Shift+Enter', + '/prompt', + 'POST' + ) + const body = request.postDataJSON() + expect(body.front).toBe(true) + }) + + test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => { + const request = await pressKeyAndExpectRequest( + comfyPage, + 'Control+Alt+Enter', + '/interrupt', + 'POST' + ) + expect(request.url()).toContain('/interrupt') + }) + }) + + test.describe('File Operations', () => { + test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => { + // On a new unsaved workflow, Ctrl+s triggers Save As dialog. + // The dialog appearing proves the keybinding was intercepted by the app. + await comfyPage.page.keyboard.press('Control+s') + await comfyPage.nextFrame() + + // The Save As dialog should appear (p-dialog overlay) + const dialogOverlay = comfyPage.page.locator('.p-dialog-mask') + await expect(dialogOverlay).toBeVisible({ timeout: 3000 }) + + // Dismiss the dialog + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + }) + + test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => { + // Ctrl+o calls app.ui.loadFile() which clicks a hidden file input. + // Detect the file input click via an event listener. + await comfyPage.page.evaluate(() => { + window.TestCommand = false + const fileInputs = + document.querySelectorAll('input[type="file"]') + for (const input of fileInputs) { + input.addEventListener('click', () => { + window.TestCommand = true + }) + } + }) + + await comfyPage.page.keyboard.press('Control+o') + await comfyPage.nextFrame() + + expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true) + }) + }) + + test.describe('Graph Operations', () => { + test("'Ctrl+Shift+e' converts selection to subgraph", async ({ + comfyPage + }) => { + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + expect(initialCount).toBeGreaterThan(1) + + // Select all nodes + await comfyPage.canvas.press('Control+a') + await comfyPage.nextFrame() + + await comfyPage.page.keyboard.press('Control+Shift+KeyE') + await comfyPage.nextFrame() + + // After conversion, node count should decrease + // (multiple nodes replaced by single subgraph node) + await expect + .poll(() => comfyPage.nodeOps.getGraphNodesCount(), { + timeout: 5000 + }) + .toBeLessThan(initialCount) + }) + + test("'r' refreshes node definitions", async ({ comfyPage }) => { + const request = await pressKeyAndExpectRequest( + comfyPage, + 'KeyR', + '/object_info', + 'GET' + ) + expect(request.url()).toContain('/object_info') + }) + }) +}) diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index 03111e0896..9a2220f8cd 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -28,8 +28,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => { ) await expect(errorOverlay).toBeVisible() - const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/) - await expect(missingNodesTitle).toBeVisible() + const messages = errorOverlay.getByTestId( + TestIds.dialogs.errorOverlayMessages + ) + await expect(messages).toBeVisible() + await expect(messages).toHaveText(/missing.*installed/i) }) test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({ @@ -42,11 +45,16 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => { ) await expect(errorOverlay).toBeVisible() - const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/) - await expect(missingNodesTitle).toBeVisible() + const messages = errorOverlay.getByTestId( + TestIds.dialogs.errorOverlayMessages + ) + await expect(messages).toBeVisible() + await expect(messages).toHaveText(/missing.*installed/i) // Click "See Errors" to open the errors tab and verify subgraph node content - await errorOverlay.getByRole('button', { name: 'See Errors' }).click() + await errorOverlay + .getByTestId(TestIds.dialogs.errorOverlaySeeErrors) + .click() await expect(errorOverlay).not.toBeVisible() const missingNodeCard = comfyPage.page.getByTestId( @@ -75,7 +83,9 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => { await expect(errorOverlay).toBeVisible() // Click "See Errors" to open the right side panel errors tab - await errorOverlay.getByRole('button', { name: 'See Errors' }).click() + await errorOverlay + .getByTestId(TestIds.dialogs.errorOverlaySeeErrors) + .click() await expect(errorOverlay).not.toBeVisible() // Verify MissingNodeCard is rendered in the errors tab @@ -98,7 +108,7 @@ test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => { await expect(errorOverlay).toBeVisible() // Dismiss the error overlay - await errorOverlay.getByRole('button', { name: 'Dismiss' }).click() + await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click() await expect(errorOverlay).not.toBeVisible() // Make a change to the graph by moving a node @@ -165,17 +175,19 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => { TestIds.dialogs.errorOverlay ) await expect(errorOverlay).toBeVisible() - await errorOverlay.getByRole('button', { name: 'See Errors' }).click() + await errorOverlay + .getByTestId(TestIds.dialogs.errorOverlaySeeErrors) + .click() await expect(errorOverlay).not.toBeVisible() // Verify Find on GitHub button is present in the error card - const findOnGithubButton = comfyPage.page.getByRole('button', { - name: 'Find on GitHub' - }) + const findOnGithubButton = comfyPage.page.getByTestId( + TestIds.dialogs.errorCardFindOnGithub + ) await expect(findOnGithubButton).toBeVisible() // Verify Copy button is present in the error card - const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' }) + const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy) await expect(copyButton).toBeVisible() }) }) @@ -204,8 +216,11 @@ test.describe('Missing models in Error Tab', () => { ) await expect(errorOverlay).toBeVisible() - const missingModelsTitle = comfyPage.page.getByText(/Missing Models/) - await expect(missingModelsTitle).toBeVisible() + const messages = errorOverlay.getByTestId( + TestIds.dialogs.errorOverlayMessages + ) + await expect(messages).toBeVisible() + await expect(messages).toHaveText(/required model.*missing/i) }) test('Should show missing models from node properties', async ({ @@ -220,8 +235,11 @@ test.describe('Missing models in Error Tab', () => { ) await expect(errorOverlay).toBeVisible() - const missingModelsTitle = comfyPage.page.getByText(/Missing Models/) - await expect(missingModelsTitle).toBeVisible() + const messages = errorOverlay.getByTestId( + TestIds.dialogs.errorOverlayMessages + ) + await expect(messages).toBeVisible() + await expect(messages).toHaveText(/required model.*missing/i) }) test('Should not show missing models when widget values have changed', async ({ @@ -231,13 +249,12 @@ test.describe('Missing models in Error Tab', () => { 'missing/model_metadata_widget_mismatch' ) - const missingModelsTitle = comfyPage.page.getByText(/Missing Models/) - await expect(missingModelsTitle).not.toBeVisible() - - const errorOverlay = comfyPage.page.getByTestId( - TestIds.dialogs.errorOverlay - ) - await expect(errorOverlay).not.toBeVisible() + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + ).not.toBeVisible() + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages) + ).not.toBeVisible() }) // Flaky test after parallelization diff --git a/browser_tests/tests/errorOverlaySeeErrors.spec.ts b/browser_tests/tests/errorOverlaySeeErrors.spec.ts index 53937229d6..0f7a4ed2a3 100644 --- a/browser_tests/tests/errorOverlaySeeErrors.spec.ts +++ b/browser_tests/tests/errorOverlaySeeErrors.spec.ts @@ -4,6 +4,7 @@ import { comfyPageFixture as test, comfyExpect as expect } from '../fixtures/ComfyPage' +import { TestIds } from '../fixtures/selectors' test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { @@ -29,14 +30,14 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { await triggerExecutionError(comfyPage) await expect( - comfyPage.page.locator('[data-testid="error-overlay"]') + comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) ).toBeVisible() }) test('Error overlay shows error message', async ({ comfyPage }) => { await triggerExecutionError(comfyPage) - const overlay = comfyPage.page.locator('[data-testid="error-overlay"]') + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) await expect(overlay).toBeVisible() await expect(overlay).toHaveText(/\S/) }) @@ -44,10 +45,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { test('"See Errors" opens right side panel', async ({ comfyPage }) => { await triggerExecutionError(comfyPage) - const overlay = comfyPage.page.locator('[data-testid="error-overlay"]') + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) await expect(overlay).toBeVisible() - await overlay.getByRole('button', { name: /See Errors/i }).click() + await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click() await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible() }) @@ -55,10 +56,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { test('"See Errors" dismisses the overlay', async ({ comfyPage }) => { await triggerExecutionError(comfyPage) - const overlay = comfyPage.page.locator('[data-testid="error-overlay"]') + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) await expect(overlay).toBeVisible() - await overlay.getByRole('button', { name: /See Errors/i }).click() + await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click() await expect(overlay).not.toBeVisible() }) @@ -68,10 +69,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { }) => { await triggerExecutionError(comfyPage) - const overlay = comfyPage.page.locator('[data-testid="error-overlay"]') + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) await expect(overlay).toBeVisible() - await overlay.getByRole('button', { name: /Dismiss/i }).click() + await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click() await expect(overlay).not.toBeVisible() await expect( @@ -82,7 +83,7 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => { test('Close button (X) dismisses overlay', async ({ comfyPage }) => { await triggerExecutionError(comfyPage) - const overlay = comfyPage.page.locator('[data-testid="error-overlay"]') + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) await expect(overlay).toBeVisible() await overlay.getByRole('button', { name: /close/i }).click() diff --git a/browser_tests/tests/execution.spec.ts b/browser_tests/tests/execution.spec.ts index fc38e2df27..c5624f797f 100644 --- a/browser_tests/tests/execution.spec.ts +++ b/browser_tests/tests/execution.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { TestIds } from '../fixtures/selectors' test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') @@ -24,16 +25,14 @@ test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => { await comfyPage.page.keyboard.press('Escape') await comfyPage.command.executeCommand('Comfy.QueuePrompt') - await expect( - comfyPage.page.locator('[data-testid="error-overlay"]') - ).toBeVisible() - await comfyPage.page - .locator('[data-testid="error-overlay"]') - .getByRole('button', { name: 'Dismiss' }) + const errorOverlay = comfyPage.page.getByTestId( + TestIds.dialogs.errorOverlay + ) + await expect(errorOverlay).toBeVisible() + await errorOverlay + .getByTestId(TestIds.dialogs.errorOverlayDismiss) .click() - await comfyPage.page - .locator('[data-testid="error-overlay"]') - .waitFor({ state: 'hidden' }) + await errorOverlay.waitFor({ state: 'hidden' }) await expect(comfyPage.canvas).toHaveScreenshot( 'execution-error-unconnected-slot.png' ) diff --git a/browser_tests/tests/execution.spec.ts-snapshots/execution-error-unconnected-slot-chromium-linux.png b/browser_tests/tests/execution.spec.ts-snapshots/execution-error-unconnected-slot-chromium-linux.png index 1fd5c120d9..78556e0f29 100644 Binary files a/browser_tests/tests/execution.spec.ts-snapshots/execution-error-unconnected-slot-chromium-linux.png and b/browser_tests/tests/execution.spec.ts-snapshots/execution-error-unconnected-slot-chromium-linux.png differ diff --git a/browser_tests/tests/graph.spec.ts b/browser_tests/tests/graph.spec.ts index 9ddd1e48c6..830e050a9e 100644 --- a/browser_tests/tests/graph.spec.ts +++ b/browser_tests/tests/graph.spec.ts @@ -23,4 +23,85 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => { await comfyPage.workflow.loadWorkflow('links/bad_link') await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2) }) + + // Regression: duplicate links with shifted target_slot (widget-to-input + // conversion) caused the wrong link to survive during deduplication. + // Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links). + // Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5). + // Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and + // removed 276, breaking the cfg connection on KSamplerAdvanced 85. + // Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291 + test('Deduplicates links without breaking connections on slot-drift workflow', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift') + + const result = await comfyPage.page.evaluate(() => { + const graph = window.app!.graph! + + const subgraph = graph.subgraphs.values().next().value + if (!subgraph) return { error: 'No subgraph found' } + + // Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86 + const switchCfg = subgraph.getNodeById(120) + const ksampler85 = subgraph.getNodeById(85) + const ksampler86 = subgraph.getNodeById(86) + if (!switchCfg || !ksampler85 || !ksampler86) + return { error: 'Required nodes not found' } + + // Find cfg inputs by name (slot indices shift due to widget-to-input) + const cfgInput85 = ksampler85.inputs.find( + (i: { name: string }) => i.name === 'cfg' + ) + const cfgInput86 = ksampler86.inputs.find( + (i: { name: string }) => i.name === 'cfg' + ) + const cfg85Linked = cfgInput85?.link != null + const cfg86Linked = cfgInput86?.link != null + + // Verify the surviving links exist in the subgraph link map + const cfg85LinkValid = + cfg85Linked && subgraph.links.has(cfgInput85!.link!) + const cfg86LinkValid = + cfg86Linked && subgraph.links.has(cfgInput86!.link!) + + // Switch(CFG) output should have exactly 2 links (one to each KSampler) + const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0 + + // Count links from Switch(CFG) to node 85 cfg (should be 1, not 2) + let cfgLinkToNode85Count = 0 + for (const link of subgraph.links.values()) { + if (link.origin_id === 120 && link.target_id === 85) + cfgLinkToNode85Count++ + } + + return { + cfg85Linked, + cfg86Linked, + cfg85LinkValid, + cfg86LinkValid, + cfg85LinkId: cfgInput85?.link ?? null, + cfg86LinkId: cfgInput86?.link ?? null, + switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])], + switchOutputLinkCount, + cfgLinkToNode85Count + } + }) + + expect(result).not.toHaveProperty('error') + // Both KSamplerAdvanced nodes must have their cfg input connected + expect(result.cfg85Linked).toBe(true) + expect(result.cfg86Linked).toBe(true) + // Links must exist in the subgraph link map + expect(result.cfg85LinkValid).toBe(true) + expect(result.cfg86LinkValid).toBe(true) + // Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced) + expect(result.switchOutputLinkCount).toBe(2) + // Only 1 link from Switch(CFG) to node 85 (duplicate removed) + expect(result.cfgLinkToNode85Count).toBe(1) + // Output link IDs must match the input link IDs (source/target integrity) + expect(result.switchOutputLinkIds).toEqual( + expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId]) + ) + }) }) diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index 73459065f8..3286e5f7fa 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -10,6 +10,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage' import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions' import { TestIds } from '../fixtures/selectors' import type { NodeReference } from '../fixtures/utils/litegraphUtils' +import type { WorkspaceStore } from '../types/globals' test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') @@ -720,6 +721,19 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => { await expect(comfyPage.canvas).toHaveScreenshot('string_input.png') }) + test('Creates initial workflow tab when persistence is disabled', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false) + await comfyPage.setup() + + const openCount = await comfyPage.page.evaluate(() => { + return (window.app!.extensionManager as WorkspaceStore).workflow + .openWorkflows.length + }) + expect(openCount).toBeGreaterThanOrEqual(1) + }) + test('Restore workflow on reload (switch workflow)', async ({ comfyPage }) => { @@ -764,13 +778,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => { ) }) + const generateUniqueFilename = (extension = '') => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}` + test.describe('Restore all open workflows on reload', () => { let workflowA: string let workflowB: string - const generateUniqueFilename = (extension = '') => - `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}` - test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') @@ -829,6 +843,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => { }) }) + test.describe('Restore workflow tabs after browser restart', () => { + let workflowA: string + let workflowB: string + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + + workflowA = generateUniqueFilename() + await comfyPage.menu.topbar.saveWorkflow(workflowA) + workflowB = generateUniqueFilename() + await comfyPage.menu.topbar.triggerTopbarCommand(['New']) + await comfyPage.menu.topbar.saveWorkflow(workflowB) + + // Wait for localStorage fallback pointers to be written + await comfyPage.page.waitForFunction(() => { + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i) + if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) { + return true + } + } + return false + }) + + // Simulate browser restart: clear sessionStorage (lost on close) + // but keep localStorage (survives browser restart) + await comfyPage.page.evaluate(() => { + sessionStorage.clear() + }) + await comfyPage.setup({ clearStorage: false }) + }) + + test('Restores topbar workflow tabs after browser restart', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Workflow.WorkflowTabsPosition', + 'Topbar' + ) + // Wait for both restored tabs to render (localStorage fallback is async) + await expect( + comfyPage.page.locator('.workflow-tabs .workflow-label', { + hasText: workflowA + }) + ).toBeVisible() + + const tabs = await comfyPage.menu.topbar.getTabNames() + const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName() + + expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB])) + expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB)) + expect(activeWorkflowName).toEqual(workflowB) + }) + + test('Restores sidebar workflows after browser restart', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Workflow.WorkflowTabsPosition', + 'Sidebar' + ) + await comfyPage.menu.workflowsTab.open() + const openWorkflows = + await comfyPage.menu.workflowsTab.getOpenedWorkflowNames() + const activeWorkflowName = + await comfyPage.menu.workflowsTab.getActiveWorkflowName() + expect(openWorkflows).toEqual( + expect.arrayContaining([workflowA, workflowB]) + ) + expect(openWorkflows.indexOf(workflowA)).toBeLessThan( + openWorkflows.indexOf(workflowB) + ) + expect(activeWorkflowName).toEqual(workflowB) + }) + }) + test('Auto fit view after loading workflow', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.EnableWorkflowViewRestore', diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/group-fit-to-contents-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/group-fit-to-contents-chromium-linux.png index ebbb915df5..aa342e0c86 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/group-fit-to-contents-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/group-fit-to-contents-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/group-selected-nodes-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/group-selected-nodes-chromium-linux.png index ad57a5dd12..e0641d9d51 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/group-selected-nodes-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/group-selected-nodes-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/pinned-all-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/pinned-all-chromium-linux.png index f00ea27303..4819dc3c7d 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/pinned-all-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/pinned-all-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/selected-all-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/selected-all-chromium-linux.png index 0339957f6f..9567acc164 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/selected-all-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/selected-all-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/unpinned-all-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/unpinned-all-chromium-linux.png index 0339957f6f..9567acc164 100644 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/unpinned-all-chromium-linux.png and b/browser_tests/tests/interaction.spec.ts-snapshots/unpinned-all-chromium-linux.png differ diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts b/browser_tests/tests/loadWorkflowInMedia.spec.ts index c6011f5a65..0e95cfbbbb 100644 --- a/browser_tests/tests/loadWorkflowInMedia.spec.ts +++ b/browser_tests/tests/loadWorkflowInMedia.spec.ts @@ -67,5 +67,44 @@ test.describe( ) }) }) + + test.fixme('Load workflow from URL dropped onto Vue node', async ({ + comfyPage + }) => { + const fakeUrl = 'https://example.com/workflow.png' + await comfyPage.page.route(fakeUrl, (route) => + route.fulfill({ + path: comfyPage.assetPath('workflowInMedia/workflow_itxt.png') + }) + ) + + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + + const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount() + + const node = comfyPage.vueNodes.getNodeByTitle('KSampler') + const box = await node.boundingBox() + expect(box).not.toBeNull() + + const dropPosition = { + x: box!.x + box!.width / 2, + y: box!.y + box!.height / 2 + } + + await comfyPage.dragDrop.dragAndDropURL(fakeUrl, { + dropPosition, + preserveNativePropagation: true + }) + + await comfyPage.page.waitForFunction( + (prevCount) => window.app!.graph.nodes.length !== prevCount, + initialNodeCount, + { timeout: 10000 } + ) + + const newNodeCount = await comfyPage.nodeOps.getGraphNodesCount() + expect(newNodeCount).not.toBe(initialNodeCount) + }) } ) diff --git a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-default-workflow-mobile-chrome-linux.png b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-default-workflow-mobile-chrome-linux.png index b1e97176db..590786c2e1 100644 Binary files a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-default-workflow-mobile-chrome-linux.png and b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-default-workflow-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-empty-canvas-mobile-chrome-linux.png b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-empty-canvas-mobile-chrome-linux.png index df544db556..8bf970cdc1 100644 Binary files a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-empty-canvas-mobile-chrome-linux.png and b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-empty-canvas-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-graph-canvas-toolbar-mobile-chrome-linux.png b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-graph-canvas-toolbar-mobile-chrome-linux.png index df1b76c5dc..111aa4fcf6 100644 Binary files a/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-graph-canvas-toolbar-mobile-chrome-linux.png and b/browser_tests/tests/mobileBaseline.spec.ts-snapshots/mobile-graph-canvas-toolbar-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/painter.spec.ts b/browser_tests/tests/painter.spec.ts new file mode 100644 index 0000000000..974cadb05e --- /dev/null +++ b/browser_tests/tests/painter.spec.ts @@ -0,0 +1,92 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Painter', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.workflow.loadWorkflow('widgets/painter_widget') + await comfyPage.vueNodes.waitForNodes() + }) + + test( + 'Renders canvas and controls', + { tag: ['@smoke', '@screenshot'] }, + async ({ comfyPage }) => { + const node = comfyPage.vueNodes.getNodeLocator('1') + await expect(node).toBeVisible() + + const painterWidget = node.locator('.widget-expands') + await expect(painterWidget).toBeVisible() + + await expect(painterWidget.locator('canvas')).toBeVisible() + await expect(painterWidget.getByText('Brush')).toBeVisible() + await expect(painterWidget.getByText('Eraser')).toBeVisible() + await expect(painterWidget.getByText('Clear')).toBeVisible() + await expect( + painterWidget.locator('input[type="color"]').first() + ).toBeVisible() + + await expect(node).toHaveScreenshot('painter-default-state.png') + } + ) + + test( + 'Drawing a stroke changes the canvas', + { tag: ['@smoke', '@screenshot'] }, + async ({ comfyPage }) => { + const node = comfyPage.vueNodes.getNodeLocator('1') + const canvas = node.locator('.widget-expands canvas') + await expect(canvas).toBeVisible() + + const isEmptyBefore = await canvas.evaluate((el) => { + const ctx = (el as HTMLCanvasElement).getContext('2d') + if (!ctx) return true + const data = ctx.getImageData( + 0, + 0, + (el as HTMLCanvasElement).width, + (el as HTMLCanvasElement).height + ) + return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true)) + }) + expect(isEmptyBefore).toBe(true) + + const box = await canvas.boundingBox() + if (!box) throw new Error('Canvas bounding box not found') + + await comfyPage.page.mouse.move( + box.x + box.width * 0.3, + box.y + box.height * 0.5 + ) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move( + box.x + box.width * 0.7, + box.y + box.height * 0.5, + { steps: 10 } + ) + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + + await expect(async () => { + const hasContent = await canvas.evaluate((el) => { + const ctx = (el as HTMLCanvasElement).getContext('2d') + if (!ctx) return false + const data = ctx.getImageData( + 0, + 0, + (el as HTMLCanvasElement).width, + (el as HTMLCanvasElement).height + ) + for (let i = 3; i < data.data.length; i += 4) { + if (data.data[i] > 0) return true + } + return false + }) + expect(hasContent).toBe(true) + }).toPass() + + await expect(node).toHaveScreenshot('painter-after-stroke.png') + } + ) +}) diff --git a/browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png b/browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png new file mode 100644 index 0000000000..4eebab0466 Binary files /dev/null and b/browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png differ diff --git a/browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png b/browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png new file mode 100644 index 0000000000..68155b0bac Binary files /dev/null and b/browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png differ diff --git a/browser_tests/tests/performance.spec.ts b/browser_tests/tests/performance.spec.ts index cd6a6afa22..fcf74a6351 100644 --- a/browser_tests/tests/performance.spec.ts +++ b/browser_tests/tests/performance.spec.ts @@ -154,6 +154,38 @@ test.describe('Performance', { tag: ['@perf'] }, () => { ) }) + test('large graph zoom interaction', async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('large-graph-workflow') + + const canvas = comfyPage.canvas + const box = await canvas.boundingBox() + if (!box) throw new Error('Canvas bounding box not available') + + // Position mouse at center so wheel events hit the canvas + const centerX = box.x + box.width / 2 + const centerY = box.y + box.height / 2 + await comfyPage.page.mouse.move(centerX, centerY) + + await comfyPage.perf.startMeasuring() + + // Zoom in 30 steps then out 30 steps — each step triggers + // ResizeObserver for all ~245 node elements due to CSS scale change. + for (let i = 0; i < 30; i++) { + await comfyPage.page.mouse.wheel(0, -100) + await comfyPage.nextFrame() + } + for (let i = 0; i < 30; i++) { + await comfyPage.page.mouse.wheel(0, 100) + await comfyPage.nextFrame() + } + + const m = await comfyPage.perf.stopMeasuring('large-graph-zoom') + recordMeasurement(m) + console.log( + `Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms` + ) + }) + test('subgraph DOM widget clipping during node selection', async ({ comfyPage }) => { diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-group-group-added-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-group-group-added-chromium-linux.png index c44638ebb2..c3137ac979 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-group-group-added-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/add-group-group-added-chromium-linux.png differ diff --git a/browser_tests/tests/sidebar/assets.spec.ts b/browser_tests/tests/sidebar/assets.spec.ts new file mode 100644 index 0000000000..5f7653ec8a --- /dev/null +++ b/browser_tests/tests/sidebar/assets.spec.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Assets sidebar', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockEmptyState() + await comfyPage.setup() + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('Shows empty-state copy for generated and imported tabs', async ({ + comfyPage + }) => { + const tab = comfyPage.menu.assetsTab + + await tab.open() + + await expect(tab.emptyStateTitle('No generated files found')).toBeVisible() + await expect(tab.emptyStateMessage).toBeVisible() + + await tab.importedTab.click() + + await expect(tab.emptyStateTitle('No imported files found')).toBeVisible() + await expect(tab.emptyStateMessage).toBeVisible() + }) +}) diff --git a/browser_tests/tests/sidebar/sidebarSplitterWidth.spec.ts b/browser_tests/tests/sidebar/sidebarSplitterWidth.spec.ts new file mode 100644 index 0000000000..1617187d8c --- /dev/null +++ b/browser_tests/tests/sidebar/sidebarSplitterWidth.spec.ts @@ -0,0 +1,122 @@ +import { expect } from '@playwright/test' + +import type { ComfyPage } from '../../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +test.describe('Sidebar splitter width independence', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.settings.setSetting('Comfy.Sidebar.UnifiedWidth', true) + await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false) + }) + + async function dismissToasts(comfyPage: ComfyPage) { + const buttons = await comfyPage.page.locator('.p-toast-close-button').all() + for (const btn of buttons) { + await btn.click({ timeout: 2000 }).catch(() => {}) + } + // Brief wait for animations + await comfyPage.nextFrame() + } + + async function dragGutter(comfyPage: ComfyPage, deltaX: number) { + const gutter = comfyPage.page + .locator('.p-splitter-gutter:not(.hidden)') + .first() + await expect(gutter).toBeVisible() + const box = await gutter.boundingBox() + expect(box).not.toBeNull() + const centerX = box!.x + box!.width / 2 + const centerY = box!.y + box!.height / 2 + await comfyPage.page.mouse.move(centerX, centerY) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(centerX + deltaX, centerY, { steps: 10 }) + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + } + + async function openSidebarAt( + comfyPage: ComfyPage, + location: 'left' | 'right' + ) { + await comfyPage.settings.setSetting('Comfy.Sidebar.Location', location) + await comfyPage.nextFrame() + await dismissToasts(comfyPage) + await comfyPage.menu.nodeLibraryTab.open() + } + + test('left and right sidebars use separate localStorage keys', async ({ + comfyPage + }) => { + // Open sidebar on the left and resize it + await openSidebarAt(comfyPage, 'left') + await dragGutter(comfyPage, 100) + + // Read the sidebar panel width after resize + const leftSidebar = comfyPage.page.locator('.side-bar-panel').first() + const leftWidth = (await leftSidebar.boundingBox())!.width + + // Close sidebar, switch to right, open again + await comfyPage.menu.nodeLibraryTab.close() + await openSidebarAt(comfyPage, 'right') + + // Right sidebar should use its default width, not the left's resized width + const rightSidebar = comfyPage.page.locator('.side-bar-panel').first() + await expect(rightSidebar).toBeVisible() + const rightWidth = (await rightSidebar.boundingBox())!.width + + // The right sidebar should NOT match the left's resized width. + // We dragged the left sidebar 100px wider, so there should be a noticeable + // difference between the left (resized) and right (default) widths. + expect(Math.abs(rightWidth - leftWidth)).toBeGreaterThan(50) + }) + + test('localStorage keys include sidebar location', async ({ comfyPage }) => { + // Open sidebar on the left and resize + await openSidebarAt(comfyPage, 'left') + await dragGutter(comfyPage, 50) + + // Left-only sidebar should use the legacy key (no location suffix) + const leftKey = await comfyPage.page.evaluate(() => + localStorage.getItem('unified-sidebar') + ) + expect(leftKey).not.toBeNull() + + // Switch to right and resize + await comfyPage.menu.nodeLibraryTab.close() + await openSidebarAt(comfyPage, 'right') + await dragGutter(comfyPage, -50) + + // Right sidebar should use a different key with location suffix + const rightKey = await comfyPage.page.evaluate(() => + localStorage.getItem('unified-sidebar-right') + ) + expect(rightKey).not.toBeNull() + + // Both keys should exist independently + const leftKeyStillExists = await comfyPage.page.evaluate(() => + localStorage.getItem('unified-sidebar') + ) + expect(leftKeyStillExists).not.toBeNull() + }) + + test('normalized panel sizes sum to approximately 100%', async ({ + comfyPage + }) => { + await openSidebarAt(comfyPage, 'left') + await dragGutter(comfyPage, 80) + + // Check that saved sizes sum to ~100% + const sizes = await comfyPage.page.evaluate(() => { + const raw = localStorage.getItem('unified-sidebar') + return raw ? JSON.parse(raw) : null + }) + + expect(sizes).not.toBeNull() + expect(Array.isArray(sizes)).toBe(true) + + const sum = (sizes as number[]).reduce((a, b) => a + b, 0) + expect(sum).toBeGreaterThan(99) + expect(sum).toBeLessThanOrEqual(101) + }) +}) diff --git a/browser_tests/tests/sidebar/workflows.spec.ts b/browser_tests/tests/sidebar/workflows.spec.ts index 43e15348b3..4af7b5dda2 100644 --- a/browser_tests/tests/sidebar/workflows.spec.ts +++ b/browser_tests/tests/sidebar/workflows.spec.ts @@ -247,7 +247,7 @@ test.describe('Workflows sidebar', () => { await expect(errorOverlay).toBeVisible() // Dismiss the error overlay - await errorOverlay.getByRole('button', { name: 'Dismiss' }).click() + await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click() await expect(errorOverlay).not.toBeVisible() // Load blank workflow diff --git a/browser_tests/tests/subgraph-promoted-slot-position.spec.ts b/browser_tests/tests/subgraph-promoted-slot-position.spec.ts new file mode 100644 index 0000000000..94272a8864 --- /dev/null +++ b/browser_tests/tests/subgraph-promoted-slot-position.spec.ts @@ -0,0 +1,86 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { getTextSlotPosition } from '../helpers/subgraphTestUtils' + +test.describe( + 'Subgraph promoted widget-input slot position', + { tag: '@subgraph' }, + () => { + test('Promoted text widget slot is positioned at widget row, not header', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-promoted-text-widget' + ) + + // Render a few frames so arrange() runs + await comfyPage.nextFrame() + await comfyPage.nextFrame() + + const result = await getTextSlotPosition(comfyPage.page, '11') + expect(result).not.toBeNull() + expect(result!.hasPos).toBe(true) + + // The slot Y position should be well below the title area. + // If it's near 0 or negative, the slot is stuck at the header (the bug). + expect(result!.posY).toBeGreaterThan(result!.titleHeight) + }) + + test('Slot position remains correct after renaming subgraph input label', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-promoted-text-widget' + ) + await comfyPage.nextFrame() + await comfyPage.nextFrame() + + // Verify initial position is correct + const before = await getTextSlotPosition(comfyPage.page, '11') + expect(before).not.toBeNull() + expect(before!.hasPos).toBe(true) + expect(before!.posY).toBeGreaterThan(before!.titleHeight) + + // Navigate into subgraph and rename the text input + const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11') + await subgraphNode.navigateIntoSubgraph() + + const initialLabel = await comfyPage.page.evaluate(() => { + const graph = window.app!.canvas.graph + if (!graph || !('inputNode' in graph)) return null + const textInput = graph.inputs?.find( + (i: { type: string }) => i.type === 'STRING' + ) + return textInput?.label || textInput?.name || null + }) + + if (!initialLabel) + throw new Error('Could not find STRING input in subgraph') + + await comfyPage.subgraph.rightClickInputSlot(initialLabel) + await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot') + await comfyPage.nextFrame() + + const dialog = '.graphdialog input' + await comfyPage.page.waitForSelector(dialog, { state: 'visible' }) + await comfyPage.page.fill(dialog, '') + await comfyPage.page.fill(dialog, 'my_custom_prompt') + await comfyPage.page.keyboard.press('Enter') + await comfyPage.page.waitForSelector(dialog, { state: 'hidden' }) + + // Navigate back to parent graph + await comfyPage.subgraph.exitViaBreadcrumb() + + // Verify slot position is still at the widget row after rename + const after = await getTextSlotPosition(comfyPage.page, '11') + expect(after).not.toBeNull() + expect(after!.hasPos).toBe(true) + expect(after!.posY).toBeGreaterThan(after!.titleHeight) + + // widget.name is the stable identity key — it does NOT change on rename. + // The display label is on input.label, read via PromotedWidgetView.label. + expect(after!.widgetName).not.toBe('my_custom_prompt') + }) + } +) diff --git a/browser_tests/tests/subgraph-promoted-widget-dom.spec.ts b/browser_tests/tests/subgraph-promoted-widget-dom.spec.ts new file mode 100644 index 0000000000..c713986261 --- /dev/null +++ b/browser_tests/tests/subgraph-promoted-widget-dom.spec.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { getPromotedWidgetNames } from '../helpers/promotedWidgets' + +test.describe( + 'Subgraph promoted widget DOM position', + { tag: '@subgraph' }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') + }) + + test('Promoted seed widget renders in node body, not header', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('default') + + // Convert KSampler (id 3) to subgraph — seed is auto-promoted. + const ksampler = await comfyPage.nodeOps.getNodeRefById('3') + await ksampler.click('title') + const subgraphNode = await ksampler.convertToSubgraph() + await comfyPage.nextFrame() + + // Enable Vue nodes now that the subgraph has been created + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + const subgraphNodeId = String(subgraphNode.id) + const promotedNames = await getPromotedWidgetNames( + comfyPage, + subgraphNodeId + ) + expect(promotedNames).toContain('seed') + + // Wait for Vue nodes to render + await comfyPage.vueNodes.waitForNodes() + + const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId) + await expect(nodeLocator).toBeVisible() + + // The seed widget should be visible inside the node body + const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first() + await expect(seedWidget).toBeVisible() + + // Verify widget is inside the node body, not the header + const headerBox = await nodeLocator + .locator('[data-testid^="node-header-"]') + .boundingBox() + const widgetBox = await seedWidget.boundingBox() + expect(headerBox).not.toBeNull() + expect(widgetBox).not.toBeNull() + + // Widget top should be below the header bottom + expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height) + }) + } +) diff --git a/browser_tests/tests/subgraphInputSlotRename.spec.ts b/browser_tests/tests/subgraphInputSlotRename.spec.ts new file mode 100644 index 0000000000..b3df05a471 --- /dev/null +++ b/browser_tests/tests/subgraphInputSlotRename.spec.ts @@ -0,0 +1,117 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' + +const WORKFLOW = 'subgraphs/test-values-input-subgraph' +const RENAMED_LABEL = 'my_seed' + +/** + * Regression test for subgraph input slot rename propagation. + * + * Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must + * update the promoted widget label shown on the parent SubgraphNode and + * keep the widget positioned in the node body (not the header). + * + * See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195 + */ +test.describe( + 'Subgraph input slot rename propagation', + { tag: ['@subgraph', '@widget'] }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + }) + + test('Renaming a subgraph input slot updates the widget label on the parent node', async ({ + comfyPage + }) => { + const { page } = comfyPage + + // 1. Load workflow with subgraph containing a promoted seed widget input + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const sgNode = comfyPage.vueNodes.getNodeLocator('19') + await expect(sgNode).toBeVisible() + + // 2. Verify the seed widget is visible on the parent node + const seedWidget = sgNode.getByLabel('seed', { exact: true }) + await expect(seedWidget).toBeVisible() + + // Verify widget is in the node body, not the header + const headerBox = await sgNode + .locator('[data-testid^="node-header-"]') + .boundingBox() + const widgetBox = await seedWidget.boundingBox() + expect(headerBox).not.toBeNull() + expect(widgetBox).not.toBeNull() + expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height) + + // 3. Enter the subgraph and rename the seed slot. + // The subgraph IO rename uses canvas.prompt() which requires the + // litegraph context menu, so temporarily disable Vue nodes. + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false) + await comfyPage.nextFrame() + + const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19') + await sgNodeRef.navigateIntoSubgraph() + + // Find the seed SubgraphInput slot + const seedSlotName = await page.evaluate(() => { + const graph = window.app!.canvas.graph + if (!graph) return null + const inputs = ( + graph as { inputs?: Array<{ name: string; type: string }> } + ).inputs + return inputs?.find((i) => i.name.includes('seed'))?.name ?? null + }) + expect(seedSlotName).not.toBeNull() + + // 4. Right-click the seed input slot and rename it + await comfyPage.subgraph.rightClickInputSlot(seedSlotName!) + await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot') + await comfyPage.nextFrame() + + const dialog = '.graphdialog input' + await page.waitForSelector(dialog, { state: 'visible' }) + await page.fill(dialog, '') + await page.fill(dialog, RENAMED_LABEL) + await page.keyboard.press('Enter') + await page.waitForSelector(dialog, { state: 'hidden' }) + + // 5. Navigate back to parent graph and re-enable Vue nodes + await comfyPage.subgraph.exitViaBreadcrumb() + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + + // 6. Verify the widget label updated to the renamed value + const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19') + await expect(sgNodeAfter).toBeVisible() + + const updatedLabel = await page.evaluate(() => { + const node = window.app!.canvas.graph!.getNodeById('19') + if (!node) return null + const w = node.widgets?.find((w: { name: string }) => + w.name.includes('seed') + ) + return w?.label || w?.name || null + }) + expect(updatedLabel).toBe(RENAMED_LABEL) + + // 7. Verify the widget is still in the body, not the header + const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true }) + await expect(seedWidgetAfter).toBeVisible() + + const headerAfter = await sgNodeAfter + .locator('[data-testid^="node-header-"]') + .boundingBox() + const widgetAfter = await seedWidgetAfter.boundingBox() + expect(headerAfter).not.toBeNull() + expect(widgetAfter).not.toBeNull() + expect(widgetAfter!.y).toBeGreaterThan( + headerAfter!.y + headerAfter!.height + ) + }) + } +) diff --git a/browser_tests/tests/subgraphLegacyPrefixedProxyWidgets.spec.ts b/browser_tests/tests/subgraphLegacyPrefixedProxyWidgets.spec.ts new file mode 100644 index 0000000000..1f6598c457 --- /dev/null +++ b/browser_tests/tests/subgraphLegacyPrefixedProxyWidgets.spec.ts @@ -0,0 +1,99 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' +import { TestIds } from '../fixtures/selectors' + +/** + * Regression test for legacy-prefixed proxyWidget normalization. + * + * Older serialized workflows stored proxyWidget entries with prefixed widget + * names like "6: 3: string_a" instead of plain "string_a". This caused + * resolution failures during configure, resulting in missing promoted widgets. + * + * The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array + * has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization + * the promoted widget should render with the clean name "string_a". + * + * See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573 + */ +test.describe( + 'Legacy prefixed proxyWidget normalization', + { tag: ['@subgraph', '@widget'] }, + () => { + const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets' + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + }) + + test('Loads without console warnings about failed widget resolution', async ({ + comfyPage + }) => { + const warnings: string[] = [] + comfyPage.page.on('console', (msg) => { + const text = msg.text() + if ( + text.includes('Failed to resolve legacy -1') || + text.includes('No link found') || + text.includes('No inner link found') + ) { + warnings.push(text) + } + }) + + await comfyPage.workflow.loadWorkflow(WORKFLOW) + + expect(warnings).toEqual([]) + }) + + test('Promoted widget renders with normalized name, not legacy prefix', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('5') + await expect(outerNode).toBeVisible() + + // The promoted widget should render with the clean name "string_a", + // not the legacy-prefixed "6: 3: string_a". + const promotedWidget = outerNode + .getByLabel('string_a', { exact: true }) + .first() + await expect(promotedWidget).toBeVisible() + }) + + test('No legacy-prefixed or disconnected widgets remain on the node', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('5') + await expect(outerNode).toBeVisible() + + // Both widget rows should be valid "string_a" widgets — no stale + // "Disconnected" placeholders from unresolved legacy entries. + const widgetRows = outerNode.getByTestId(TestIds.widgets.widget) + await expect(widgetRows).toHaveCount(2) + + for (const row of await widgetRows.all()) { + await expect(row.getByLabel('string_a', { exact: true })).toBeVisible() + } + }) + + test('Promoted widget value is editable as a text input', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('5') + const textarea = outerNode + .getByRole('textbox', { name: 'string_a' }) + .first() + await expect(textarea).toBeVisible() + }) + } +) diff --git a/browser_tests/tests/subgraphLifecycle.spec.ts b/browser_tests/tests/subgraphLifecycle.spec.ts index c8bbe28eb2..50aa7f4a79 100644 --- a/browser_tests/tests/subgraphLifecycle.spec.ts +++ b/browser_tests/tests/subgraphLifecycle.spec.ts @@ -142,12 +142,12 @@ test.describe( }) }) - test.describe('Placeholder Behavior After Promoted Source Removal', () => { + test.describe('Cleanup Behavior After Promoted Source Removal', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') }) - test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({ + test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow( @@ -182,8 +182,8 @@ test.describe( }) }) .toEqual({ - proxyWidgetCount: initialWidgets.length, - firstWidgetType: 'button' + proxyWidgetCount: 0, + firstWidgetType: undefined }) }) diff --git a/browser_tests/tests/subgraphNestedPackValues.spec.ts b/browser_tests/tests/subgraphNestedPackValues.spec.ts new file mode 100644 index 0000000000..114b8d47d1 --- /dev/null +++ b/browser_tests/tests/subgraphNestedPackValues.spec.ts @@ -0,0 +1,195 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' + +/** + * Regression test for PR #10532: + * Packing all nodes inside a subgraph into a nested subgraph was causing + * the parent subgraph node's promoted widget values to go blank. + * + * Root cause: SubgraphNode had two sets of PromotedWidgetView references — + * node.widgets (rebuilt from the promotion store) vs input._widget (cached + * at promotion time). After repointing, input._widget still pointed to + * removed node IDs, causing missing-node failures and blank values on the + * next checkState cycle. + */ +test.describe( + 'Nested subgraph pack preserves promoted widget values', + { tag: ['@subgraph', '@widget'] }, + () => { + const WORKFLOW = 'subgraphs/nested-pack-promoted-values' + const HOST_NODE_ID = '57' + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + }) + + test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID) + await expect(nodeLocator).toBeVisible() + + // 1. Verify initial promoted widget values via Vue node DOM + const widthWidget = nodeLocator + .getByLabel('width', { exact: true }) + .first() + const heightWidget = nodeLocator + .getByLabel('height', { exact: true }) + .first() + const stepsWidget = nodeLocator + .getByLabel('steps', { exact: true }) + .first() + const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' }) + + const widthControls = + comfyPage.vueNodes.getInputNumberControls(widthWidget) + const heightControls = + comfyPage.vueNodes.getInputNumberControls(heightWidget) + const stepsControls = + comfyPage.vueNodes.getInputNumberControls(stepsWidget) + + await expect(async () => { + await expect(widthControls.input).toHaveValue('1024') + await expect(heightControls.input).toHaveValue('1024') + await expect(stepsControls.input).toHaveValue('8') + await expect(textWidget).toHaveValue(/Latina female/) + }).toPass({ timeout: 5000 }) + + // 2. Enter the subgraph via Vue node button + await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID) + expect(await comfyPage.subgraph.isInSubgraph()).toBe(true) + + // 3. Disable Vue nodes for canvas operations (select all + convert) + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false) + await comfyPage.nextFrame() + + // 4. Select all interior nodes and convert to nested subgraph + await comfyPage.canvas.click() + await comfyPage.canvas.press('Control+a') + await comfyPage.nextFrame() + + await comfyPage.page.evaluate(() => { + const canvas = window.app!.canvas + canvas.graph!.convertToSubgraph(canvas.selectedItems) + }) + await comfyPage.nextFrame() + + // 5. Navigate back to root graph and trigger a checkState cycle + await comfyPage.subgraph.exitViaBreadcrumb() + await comfyPage.canvas.click() + await comfyPage.nextFrame() + + // 6. Re-enable Vue nodes and verify values are preserved + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.vueNodes.waitForNodes() + + const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID) + await expect(nodeAfter).toBeVisible() + + const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first() + const heightAfter = nodeAfter + .getByLabel('height', { exact: true }) + .first() + const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first() + const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' }) + + const widthControlsAfter = + comfyPage.vueNodes.getInputNumberControls(widthAfter) + const heightControlsAfter = + comfyPage.vueNodes.getInputNumberControls(heightAfter) + const stepsControlsAfter = + comfyPage.vueNodes.getInputNumberControls(stepsAfter) + + await expect(async () => { + await expect(widthControlsAfter.input).toHaveValue('1024') + await expect(heightControlsAfter.input).toHaveValue('1024') + await expect(stepsControlsAfter.input).toHaveValue('8') + await expect(textAfter).toHaveValue(/Latina female/) + }).toPass({ timeout: 5000 }) + }) + + test('proxyWidgets entries resolve to valid interior nodes after packing', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + // Verify the host node is visible + const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID) + await expect(nodeLocator).toBeVisible() + + // Enter the subgraph via Vue node button, then disable for canvas ops + await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID) + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false) + await comfyPage.nextFrame() + + await comfyPage.canvas.click() + await comfyPage.canvas.press('Control+a') + await comfyPage.nextFrame() + + await comfyPage.page.evaluate(() => { + const canvas = window.app!.canvas + canvas.graph!.convertToSubgraph(canvas.selectedItems) + }) + await comfyPage.nextFrame() + + await comfyPage.subgraph.exitViaBreadcrumb() + await comfyPage.canvas.click() + await comfyPage.nextFrame() + + // Verify all proxyWidgets entries resolve + await expect(async () => { + const result = await comfyPage.page.evaluate((hostId) => { + const graph = window.app!.graph! + const hostNode = graph.getNodeById(hostId) + if ( + !hostNode || + typeof hostNode.isSubgraphNode !== 'function' || + !hostNode.isSubgraphNode() + ) { + return { error: 'Host node not found or not a subgraph node' } + } + + const proxyWidgets = hostNode.properties?.proxyWidgets ?? [] + const entries = (proxyWidgets as unknown[]) + .filter( + (e): e is [string, string] => + Array.isArray(e) && + e.length >= 2 && + typeof e[0] === 'string' && + typeof e[1] === 'string' && + !e[1].startsWith('$$') + ) + .map(([nodeId, widgetName]) => { + const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId)) + return { + nodeId, + widgetName, + resolved: interiorNode !== null && interiorNode !== undefined + } + }) + + return { entries, count: entries.length } + }, HOST_NODE_ID) + + expect(result).not.toHaveProperty('error') + const { entries, count } = result as { + entries: { nodeId: string; widgetName: string; resolved: boolean }[] + count: number + } + expect(count).toBeGreaterThan(0) + for (const entry of entries) { + expect( + entry.resolved, + `Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve` + ).toBe(true) + } + }).toPass({ timeout: 5000 }) + }) + } +) diff --git a/browser_tests/tests/subgraphNestedStaleProxyWidgets.spec.ts b/browser_tests/tests/subgraphNestedStaleProxyWidgets.spec.ts new file mode 100644 index 0000000000..8f1164e0a2 --- /dev/null +++ b/browser_tests/tests/subgraphNestedStaleProxyWidgets.spec.ts @@ -0,0 +1,51 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '../fixtures/ComfyPage' +import { TestIds } from '../fixtures/selectors' + +const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets' + +/** + * Regression test for nested subgraph packing leaving stale proxyWidgets + * on the outer SubgraphNode. + * + * When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are + * packed into a nested subgraph (node 11), the outer SubgraphNode (id 10) + * must drop the now-stale ["7","text"] and ["6","text"] proxy entries. + * Only ["3","seed"] (KSampler) should remain. + * + * Stale entries render as "Disconnected" placeholder widgets (type "button"). + * + * See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390 + */ +test.describe( + 'Nested subgraph stale proxyWidgets', + { tag: ['@subgraph', '@widget'] }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + }) + + test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('10') + await expect(outerNode).toBeVisible() + + const widgets = outerNode.getByTestId(TestIds.widgets.widget) + + // Only the KSampler seed widget should be present — no stale + // "Disconnected" placeholders from the packed CLIPTextEncode nodes. + await expect(widgets).toHaveCount(1) + await expect(widgets.first()).toBeVisible() + + // Verify the seed widget is present via its label + const seedWidget = outerNode.getByLabel('seed', { exact: true }) + await expect(seedWidget).toBeVisible() + }) + } +) diff --git a/browser_tests/tests/subgraphSlotAlignment.spec.ts b/browser_tests/tests/subgraphSlotAlignment.spec.ts new file mode 100644 index 0000000000..19c0fd4767 --- /dev/null +++ b/browser_tests/tests/subgraphSlotAlignment.spec.ts @@ -0,0 +1,132 @@ +import { readFileSync } from 'fs' +import { resolve } from 'path' + +import { expect } from '@playwright/test' + +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +interface SlotMeasurement { + key: string + offsetX: number + offsetY: number +} + +interface NodeSlotData { + nodeId: string + isSubgraph: boolean + nodeW: number + nodeH: number + slots: SlotMeasurement[] +} + +/** + * Regression test for link misalignment on SubgraphNodes when loading + * workflows with workflowRendererVersion: "LG". + * + * Root cause: ensureCorrectLayoutScale scales nodes by 1.2x for LG workflows, + * and fitView() updates lgCanvas.ds immediately. The Vue TransformPane's CSS + * transform lags by a frame, causing clientPosToCanvasPos to produce wrong + * slot offsets. The fix uses DOM-relative measurement instead. + */ +test.describe( + 'Subgraph slot alignment after LG layout scale', + { tag: ['@subgraph', '@canvas'] }, + () => { + test('slot positions stay within node bounds after loading LG workflow', async ({ + comfyPage + }) => { + const SLOT_BOUNDS_MARGIN = 20 + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + const workflowPath = resolve( + import.meta.dirname, + '../assets/subgraphs/basic-subgraph.json' + ) + const workflow = JSON.parse( + readFileSync(workflowPath, 'utf-8') + ) as ComfyWorkflowJSON + workflow.extra = { + ...workflow.extra, + workflowRendererVersion: 'LG' + } + + await comfyPage.page.evaluate( + (wf) => + window.app!.loadGraphData(wf as ComfyWorkflowJSON, true, true, null, { + openSource: 'template' + }), + workflow + ) + await comfyPage.nextFrame() + + // Wait for slot elements to appear in DOM + await comfyPage.page.locator('[data-slot-key]').first().waitFor() + + const result: NodeSlotData[] = await comfyPage.page.evaluate(() => { + const nodes = window.app!.graph._nodes + const slotData: NodeSlotData[] = [] + + for (const node of nodes) { + const nodeId = String(node.id) + const nodeEl = document.querySelector( + `[data-node-id="${nodeId}"]` + ) as HTMLElement | null + if (!nodeEl) continue + + const slotEls = nodeEl.querySelectorAll('[data-slot-key]') + if (slotEls.length === 0) continue + + const slots: SlotMeasurement[] = [] + + const nodeRect = nodeEl.getBoundingClientRect() + for (const slotEl of slotEls) { + const slotRect = slotEl.getBoundingClientRect() + const slotKey = (slotEl as HTMLElement).dataset.slotKey ?? 'unknown' + slots.push({ + key: slotKey, + offsetX: slotRect.left + slotRect.width / 2 - nodeRect.left, + offsetY: slotRect.top + slotRect.height / 2 - nodeRect.top + }) + } + + slotData.push({ + nodeId, + isSubgraph: !!node.isSubgraphNode?.(), + nodeW: nodeRect.width, + nodeH: nodeRect.height, + slots + }) + } + + return slotData + }) + + const subgraphNodes = result.filter((n) => n.isSubgraph) + expect(subgraphNodes.length).toBeGreaterThan(0) + + for (const node of subgraphNodes) { + for (const slot of node.slots) { + expect( + slot.offsetX, + `Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}` + ).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN) + expect( + slot.offsetX, + `Slot ${slot.key} on node ${node.nodeId}: X offset ${slot.offsetX} outside node width ${node.nodeW}` + ).toBeLessThanOrEqual(node.nodeW + SLOT_BOUNDS_MARGIN) + + expect( + slot.offsetY, + `Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}` + ).toBeGreaterThanOrEqual(-SLOT_BOUNDS_MARGIN) + expect( + slot.offsetY, + `Slot ${slot.key} on node ${node.nodeId}: Y offset ${slot.offsetY} outside node height ${node.nodeH}` + ).toBeLessThanOrEqual(node.nodeH + SLOT_BOUNDS_MARGIN) + } + } + }) + } +) diff --git a/browser_tests/tests/templates.spec.ts b/browser_tests/tests/templates.spec.ts index 9ae07c79fb..516f368a5f 100644 --- a/browser_tests/tests/templates.spec.ts +++ b/browser_tests/tests/templates.spec.ts @@ -206,6 +206,31 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => { await expect(nav).toBeVisible() // Nav should be visible at tablet size }) + test( + 'select components in filter bar render correctly', + { tag: '@screenshot' }, + async ({ comfyPage }) => { + await comfyPage.command.executeCommand('Comfy.BrowseTemplates') + await expect(comfyPage.templates.content).toBeVisible() + + // Wait for filter bar select components to render + const dialog = comfyPage.page.getByRole('dialog') + const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ }) + await expect(sortBySelect).toBeVisible() + + // Screenshot the filter bar containing MultiSelect and SingleSelect + const filterBar = sortBySelect.locator( + 'xpath=ancestor::div[contains(@class, "justify-between")]' + ) + await expect(filterBar).toHaveScreenshot( + 'template-filter-bar-select-components.png', + { + mask: [comfyPage.page.locator('.p-toast')] + } + ) + } + ) + test( 'template cards descriptions adjust height dynamically', { tag: '@screenshot' }, diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-filter-bar-select-components-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-filter-bar-select-components-chromium-linux.png new file mode 100644 index 0000000000..4a39a259b3 Binary files /dev/null and b/browser_tests/tests/templates.spec.ts-snapshots/template-filter-bar-select-components-chromium-linux.png differ diff --git a/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png index d79612d792..a2663677a8 100644 Binary files a/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png and b/browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png index a3f52332dc..0d3fc54d59 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png index c061e7722d..136e8f0e64 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png index cf6d1d1071..3c99789544 100644 Binary files a/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png and b/browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png index 0622a2c7aa..51010e094e 100644 Binary files a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts index 42f8557beb..dc2521c9af 100644 --- a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts @@ -47,6 +47,46 @@ test.describe('Vue Node Moving', () => { } ) + test('should not move node when pointer moves less than drag threshold', async ({ + comfyPage + }) => { + const headerPos = await getLoadCheckpointHeaderPos(comfyPage) + + // Move only 2px — below the 3px drag threshold in useNodePointerInteractions + await comfyPage.page.mouse.move(headerPos.x, headerPos.y) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, { + steps: 5 + }) + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + + const afterPos = await getLoadCheckpointHeaderPos(comfyPage) + expect(afterPos.x).toBeCloseTo(headerPos.x, 0) + expect(afterPos.y).toBeCloseTo(headerPos.y, 0) + + // The small movement should have selected the node, not dragged it + expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1) + }) + + test('should move node when pointer moves beyond drag threshold', async ({ + comfyPage + }) => { + const headerPos = await getLoadCheckpointHeaderPos(comfyPage) + + // Move 50px — well beyond the 3px drag threshold + await comfyPage.page.mouse.move(headerPos.x, headerPos.y) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, { + steps: 20 + }) + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + + const afterPos = await getLoadCheckpointHeaderPos(comfyPage) + await expectPosChanged(headerPos, afterPos) + }) + test( '@mobile should allow moving nodes by dragging on touch devices', { tag: '@screenshot' }, diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png index 2e56766c95..2245c78769 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-touch-mobile-chrome-linux.png b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-touch-mobile-chrome-linux.png index 201b6747da..1b96c374a2 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-touch-mobile-chrome-linux.png and b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-touch-mobile-chrome-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png index de216ff685..3873f31172 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png index e2106277ce..c3b66e44f0 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png index 831e98152a..63e25e5b59 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png index a5a4cdcb04..95890502d4 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png index 6507cdc37d..73d4ec760a 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png b/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png index 5d5937971b..acc124ad77 100644 Binary files a/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png and b/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts index 077e56a4aa..0998c3eacb 100644 --- a/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/load/uploadWidgets.spec.ts @@ -2,6 +2,7 @@ import { comfyExpect as expect, comfyPageFixture as test } from '../../../../fixtures/ComfyPage' +import { TestIds } from '../../../../fixtures/selectors' test.describe('Vue Upload Widgets', () => { test.beforeEach(async ({ comfyPage }) => { @@ -19,10 +20,14 @@ test.describe('Vue Upload Widgets', () => { ).not.toBeVisible() await expect - .poll(() => comfyPage.page.getByText('Error loading image').count()) + .poll(() => + comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count() + ) .toBeGreaterThan(0) await expect - .poll(() => comfyPage.page.getByText('Error loading video').count()) + .poll(() => + comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count() + ) .toBeGreaterThan(0) }) }) diff --git a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts index 8a81772a88..c984171e18 100644 --- a/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/text/multilineStringWidget.spec.ts @@ -46,4 +46,16 @@ test.describe('Vue Multiline String Widget', () => { await expect(textarea).toHaveValue('Keep me around') }) + test('should use native context menu when focused', async ({ comfyPage }) => { + const textarea = getFirstMultilineStringWidget(comfyPage) + const vueContextMenu = comfyPage.page.locator('.p-contextmenu') + + await textarea.focus() + await textarea.click({ button: 'right' }) + await expect(vueContextMenu).not.toBeVisible() + await textarea.blur() + + await textarea.click({ button: 'right' }) + await expect(vueContextMenu).toBeVisible() + }) }) diff --git a/browser_tests/tests/zoomControls.spec.ts b/browser_tests/tests/zoomControls.spec.ts new file mode 100644 index 0000000000..21c9077663 --- /dev/null +++ b/browser_tests/tests/zoomControls.spec.ts @@ -0,0 +1,138 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { TestIds } from '../fixtures/selectors' + +test.describe('Zoom Controls', { tag: '@canvas' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true) + await comfyPage.workflow.loadWorkflow('default') + await comfyPage.page.waitForFunction(() => window.app && window.app.canvas) + }) + + test('Default zoom is 100% and node has a size', async ({ comfyPage }) => { + const nodeSize = await comfyPage.page.evaluate( + () => window.app!.graph.nodes[0].size + ) + expect(nodeSize[0]).toBeGreaterThan(0) + expect(nodeSize[1]).toBeGreaterThan(0) + + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await expect(zoomButton).toContainText('100%') + + const scale = await comfyPage.canvasOps.getScale() + expect(scale).toBeCloseTo(1.0, 1) + }) + + test('Zoom to fit reduces percentage', async ({ comfyPage }) => { + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await zoomButton.click() + await comfyPage.nextFrame() + + const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction) + await expect(zoomToFit).toBeVisible() + await zoomToFit.click() + + await expect + .poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 }) + .toBeLessThan(1.0) + + await expect(zoomButton).not.toContainText('100%') + }) + + test('Zoom out reduces percentage', async ({ comfyPage }) => { + const initialScale = await comfyPage.canvasOps.getScale() + + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await zoomButton.click() + await comfyPage.nextFrame() + + const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction) + await zoomOut.click() + await comfyPage.nextFrame() + + const newScale = await comfyPage.canvasOps.getScale() + expect(newScale).toBeLessThan(initialScale) + }) + + test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => { + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await zoomButton.click() + await comfyPage.nextFrame() + + const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction) + for (let i = 0; i < 30; i++) { + await zoomOut.click() + } + await comfyPage.nextFrame() + + await expect + .poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 }) + .toBeCloseTo(0.1, 1) + + await expect(zoomButton).toContainText('10%') + }) + + test('Manual percentage entry allows zoom in and zoom out', async ({ + comfyPage + }) => { + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await zoomButton.click() + await comfyPage.nextFrame() + + const input = comfyPage.page + .getByTestId(TestIds.canvas.zoomPercentageInput) + .locator('input') + await input.focus() + await comfyPage.page.keyboard.press('Control+a') + await input.pressSequentially('100') + await input.press('Enter') + await comfyPage.nextFrame() + + await expect + .poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 }) + .toBeCloseTo(1.0, 1) + + const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction) + await zoomIn.click() + await comfyPage.nextFrame() + + const scaleAfterZoomIn = await comfyPage.canvasOps.getScale() + expect(scaleAfterZoomIn).toBeGreaterThan(1.0) + + const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction) + await zoomOut.click() + await comfyPage.nextFrame() + + const scaleAfterZoomOut = await comfyPage.canvasOps.getScale() + expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn) + }) + + test('Clicking zoom button toggles zoom controls visibility', async ({ + comfyPage + }) => { + const zoomButton = comfyPage.page.getByTestId( + TestIds.canvas.zoomControlsButton + ) + await zoomButton.click() + await comfyPage.nextFrame() + + const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction) + await expect(zoomToFit).toBeVisible() + + await zoomButton.click() + await comfyPage.nextFrame() + + await expect(zoomToFit).not.toBeVisible() + }) +}) diff --git a/docs/adr/0008-entity-component-system.md b/docs/adr/0008-entity-component-system.md new file mode 100644 index 0000000000..31965e242a --- /dev/null +++ b/docs/adr/0008-entity-component-system.md @@ -0,0 +1,239 @@ +# 8. Entity Component System + +Date: 2026-03-23 + +## Status + +Proposed + +## Context + +The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on. + +This coupling makes it difficult to: + +- Add cross-cutting concerns (undo/redo, serialization, multiplayer CRDT sync, rendering optimization) without modifying every class +- Test individual aspects of an entity in isolation +- Evolve rendering, serialization, and execution logic independently +- Implement the CRDT-based layout system proposed in [ADR 0003](0003-crdt-based-layout-system.md) + +An Entity Component System (ECS) separates **identity** (entities), **data** (components), and **behavior** (systems), enabling each concern to evolve independently. + +### Current pain points + +- **God objects**: `LGraphNode` (~2000+ lines) mixes position, rendering, connectivity, execution, serialization, and input handling +- **Circular dependencies**: `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, requiring careful import ordering and barrel exports +- **Tight rendering coupling**: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types) +- **No unified entity model**: Each entity kind uses different ID types, ownership patterns, and lifecycle management + +## Decision + +Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise. + +### Entity Taxonomy + +Six entity kinds, each with a branded ID type: + +| Entity Kind | Current Class(es) | Current ID | Branded ID | +| ----------- | ------------------------------------------------- | --------------------------- | ----------------- | +| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` | +| Link | `LLink` | `LinkId = number` | `LinkEntityId` | +| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` | +| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` | +| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` | +| Group | `LGraphGroup` | `number` | `GroupEntityId` | + +Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale. + +### Branded ID Design + +Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types: + +```ts +type NodeEntityId = number & { readonly __brand: 'NodeEntityId' } +type LinkEntityId = number & { readonly __brand: 'LinkEntityId' } +type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' } +type SlotEntityId = number & { readonly __brand: 'SlotEntityId' } +type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' } +type GroupEntityId = number & { readonly __brand: 'GroupEntityId' } + +// Scope identifier, not an entity ID +type GraphId = string & { readonly __brand: 'GraphId' } +``` + +Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`). + +### Component Decomposition + +Components are plain data objects — no methods, no back-references to parent entities. Systems query components to implement behavior. + +#### Shared Components + +- **Position** — `{ pos: Point }` — used by Node, Reroute, Group +- **Dimensions** — `{ size: Size, bounding: Rectangle }` — used by Node, Group +- **Visual** — rendering properties specific to each entity kind (separate interfaces, shared naming convention) + +#### Node + +| Component | Data (from `LGraphNode`) | +| ----------------- | --------------------------------------------------- | +| `Position` | `pos` | +| `Dimensions` | `size`, `_bounding` | +| `NodeVisual` | `color`, `bgcolor`, `boxcolor`, `title` | +| `NodeType` | `type`, `category`, `nodeData`, `description` | +| `Connectivity` | slot entity refs (replaces `inputs[]`, `outputs[]`) | +| `Execution` | `order`, `mode`, `flags` | +| `Properties` | `properties`, `properties_info` | +| `WidgetContainer` | widget entity refs (replaces `widgets[]`) | + +#### Link + +| Component | Data (from `LLink`) | +| --------------- | -------------------------------------------------------------- | +| `LinkEndpoints` | `origin_id`, `origin_slot`, `target_id`, `target_slot`, `type` | +| `LinkVisual` | `color`, `path`, `_pos` (center point) | +| `LinkState` | `_dragging`, `data` | + +#### Subgraph (Node Components) + +A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md). + +| Component | Data | +| ------------------- | ------------------------------------------------------------------------ | +| `SubgraphStructure` | `graphId`, typed interface (input/output names, types, slot entity refs) | +| `SubgraphMeta` | `name`, `description` | + +#### Widget + +| Component | Data (from `BaseWidget`) | +| ---------------- | ----------------------------------------------------------- | +| `WidgetIdentity` | `name`, `type` (widget type string), parent node entity ref | +| `WidgetValue` | `value`, `options`, `serialize` flags | +| `WidgetLayout` | `computedHeight`, layout size constraints | + +#### Slot + +| Component | Data (from `SlotBase` / `INodeInputSlot` / `INodeOutputSlot`) | +| ---------------- | ----------------------------------------------------------------------------------- | +| `SlotIdentity` | `name`, `type` (slot type), direction (`input` or `output`), parent node ref, index | +| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator | +| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` | + +#### Reroute + +| Component | Data (from `Reroute`) | +| --------------- | --------------------------------- | +| `Position` | `pos` (shared) | +| `RerouteLinks` | `parentId`, input/output link IDs | +| `RerouteVisual` | `color`, badge config | + +#### Group + +| Component | Data (from `LGraphGroup`) | +| --------------- | ----------------------------------- | +| `Position` | `pos` (shared) | +| `Dimensions` | `size`, `bounding` | +| `GroupMeta` | `title`, `font`, `font_size` | +| `GroupVisual` | `color` | +| `GroupChildren` | child entity refs (nodes, reroutes) | + +### World + +A central registry (the "World") maps entity IDs to their component sets. One +World exists per workflow instance, containing all entities across all nesting +levels. Each entity carries a `graphScope` identifier linking it to its +containing graph. The World also maintains a scope registry mapping each +`graphId` to its parent (or null for the root graph). + +The "single source of truth" claim in this ADR is scoped to one workflow +instance. In a future linked-subgraph model, shared definitions can be loaded +into multiple workflow instances, but mutable runtime components +(`WidgetValue`, execution state, selection, transient layout caches) remain +instance-scoped unless explicitly declared shareable. + +### Subgraph recursion model + +The ECS model preserves recursive nesting without inheritance. A subgraph node +stores `SubgraphStructure.childGraphId`, and the scope registry stores +`childGraphId -> parentGraphId`. This forms a DAG that can represent arbitrary +subgraph depth. + +Queries such as "all nodes at depth N" run by traversing the scope registry +from the root, materializing graph IDs at depth `N`, and then filtering entity +queries by `graphScope`. + +### Systems (future work) + +Systems are pure functions that query the World for entities with specific component combinations. Initial candidates: + +- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components +- **SerializationSystem** — queries all components to produce/consume workflow JSON +- **ExecutionSystem** — queries `Execution` + `Connectivity` to determine run order +- **LayoutSystem** — queries `Position` + `Dimensions` + structural components for auto-layout +- **SelectionSystem** — queries `Position` for point entities and `Position` + `Dimensions` for box hit-testing + +System design is deferred to a future ADR. + +### Migration Strategy + +1. **Define types** — branded IDs, component interfaces, World type in a new `src/ecs/` directory +2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible) +3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties +4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility +5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated + +### Relationship to ADR 0003 (Command Pattern / CRDT) + +[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers: + +- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone. +- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World. +- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands. + +The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based. + +For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md). + +### Alternatives Considered + +- **Refactoring classes in place**: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects. +- **Full rewrite**: Higher risk, blocks feature work during migration. The incremental approach avoids this. +- **Using an existing ECS library** (e.g., bitecs, miniplex): Adds a dependency for a domain that is specific to this project. The graph domain's component shapes don't align well with the dense numeric arrays favored by game-oriented ECS libraries. A lightweight, purpose-built approach is preferred. + +## Consequences + +### Positive + +- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes +- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic +- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds +- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection +- Aligns with the CRDT layout system direction from ADR 0003 + +### Negative + +- Additional indirection: reading a node's position requires a World lookup instead of `node.pos` +- Learning curve for contributors unfamiliar with ECS patterns +- Migration period where both OOP and ECS patterns coexist, increasing cognitive load +- Widgets and Slots need synthetic IDs, adding ID management complexity + +### Render-Loop Performance Implications and Mitigations + +Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free. + +Planned mitigations for the ECS render path: + +1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call. +2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities. +3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time. +4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget). +5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows). + +The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets. + +## Notes + +- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data. +- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md). +- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`. +- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint. diff --git a/docs/adr/README.md b/docs/adr/README.md index c2ae857d5b..a5c1e62511 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ An Architecture Decision Record captures an important architectural decision mad | [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 | | [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 | | [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 | +| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 | ## Creating a New ADR diff --git a/docs/architecture/appendix-critical-analysis.md b/docs/architecture/appendix-critical-analysis.md new file mode 100644 index 0000000000..014b6a48e5 --- /dev/null +++ b/docs/architecture/appendix-critical-analysis.md @@ -0,0 +1,138 @@ +# Appendix: A Critical Analysis of the Architecture Documents + +_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._ + +--- + +## I. On the Accuracy of Self-Diagnosis + +Verification snapshot: code references were checked against commit +`e51982ee1`. + +The architecture documents present themselves as a clinical intake — a patient arriving with a detailed account of its own suffering. One is naturally suspicious of such thoroughness; the neurotic who describes his symptoms too precisely is often defending against a deeper, unnamed wound. And yet, upon examination, we find the self-report to be remarkably honest. + +The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines — the ego of the system, attempting to mediate between the inner world of data and the outer world of the user, and collapsing under the weight of that mediation. `LGraphNode` at 4,285 lines and `LGraph` at 3,114 confirm that these are not exaggerations born of self-pity but accurate measurements of genuine hypertrophy. + +Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated. + +The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs. + +This level of factual accuracy — 28 out of 30 sampled citation checks +(93.3%) — is, one might say, the work of a consciousness that has genuinely +confronted its shadow material rather than merely projecting it. + +## II. On the Errors: Small Falsifications of Memory + +No self-report is without its distortions. The unconscious edits memory, not out of malice, but because the psyche organizes experience around meaning rather than fact. + +Five such distortions were identified: + +**The Misnamed Method.** The documents claim `toJSON()` exists at `LGraphNode.ts:1033`. In truth, line 1033 holds `toString()`. This is a telling substitution — the psyche conflates the act of converting oneself to a string representation (how one _appears_) with the act of serializing oneself for transmission (how one _persists_). These are different operations, but the patient experiences them as the same anxiety. + +**The Renamed Function.** `execute()` is cited at line 1418. The actual method is `doExecute()` at line 1411. The prefix "do" carries weight — it is the difference between the intention and the act, between the persona and the behavior. The documents elide this distinction, preferring the cleaner, more archetypal name. + +**The Understated Magnitude.** The documents claim `LGraphNode` has ~539 method/property definitions. A systematic count yields approximately 848. The psyche has minimized the extent of the fragmentation — a common defense. One does not wish to know the full measure of one's own complexity. + +**The Compressed History.** `LGraph.configure()` is described as ~180 lines. It spans approximately 247. The method has grown since it was last measured, as living things do, but the documents preserve an earlier, smaller self-image. Time has passed; the patient has not updated its intake form. + +**The Phantom Method.** The proto-ECS analysis references `resolveDeepest()` on the `PromotedWidgetViewManager`. This method does not exist. The class uses `reconcile()` and `getOrCreate()` — less evocative names for what is, symbolically, the same operation: reaching through layers of abstraction to find the authentic, concrete thing beneath. The documents have invented a name that better captures the _meaning_ of the operation than the names the code actually uses. This is poetry, not documentation. + +These errors are minor in isolation. Collectively, they suggest a pattern familiar to the analyst: the documents describe the system not quite as it _is_, but as it _understands itself to be_. The gap between these is small — but it is precisely in such gaps that the interesting material lives. + +## III. On the Dream of the World: The ECS Target as Individuation Fantasy + +The target architecture documents read as a vision of wholeness. Where the current system is fragmented — god-objects carrying too many responsibilities, circular dependencies binding parent to child in mutual entanglement, scattered side effects erupting unpredictably — the ECS future promises integration. A single World. Pure systems. Branded identities. Unidirectional flow. + +This is the individuation dream: the fragmented psyche imagines itself unified, each complex (component) named and contained, each archetypal function (system) operating in its proper domain, the Self (World) holding all of it in coherent relation. + +It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality. + +### The Line-Count Comparisons + +The lifecycle scenarios compare current implementations against projected ECS equivalents: + +| Operation | Current | Projected ECS | +| ------------- | ---------- | ------------- | +| Node removal | ~107 lines | ~30 lines | +| Pack subgraph | ~200 lines | ~50 lines | +| Copy/paste | ~300 lines | ~60 lines | + +These ratios — roughly 4:1 — are the ratios of a daydream. They may prove accurate. But they are estimates for code that does not yet exist, and the unconscious is generous with its projections of future ease. Real implementations accumulate weight as they encounter the particularities that theory elides: validation callbacks, error recovery, extension hooks, the sheer cussedness of edge cases that only reveal themselves in production. + +The documents would benefit from acknowledging this uncertainty explicitly. "We expect" is more honest than "it will be." + +### The Vanishing Callbacks + +The current system maintains an elaborate network of lifecycle callbacks: `onConnectInput()`, `onConnectOutput()`, `onConnectionsChange()`, `onRemoved()`, `onAdded()`. These are the system's relationships — its contracts with the extensions that depend upon it. + +The ECS scenarios show these callbacks disappearing. "No callbacks — systems query World after deserialization." This is presented as simplification, and structurally it is. But psychologically, it is the most dangerous moment in any transformation: the point at which the individuating self believes it can shed its relationships without consequence. + +Extensions rely on these callbacks. They are the public API through which the outer world interacts with the system's inner life. The documents do not discuss how this API would be preserved, adapted, or replaced. This is not a minor omission — it is the repression of the system's most anxiety-producing constraint. + +### The Atomicity Wish + +The ECS scenarios describe operations as "atomic" — pack subgraph, unpack subgraph, node removal, all happening as unified state transitions with no intermediate inconsistency. + +This is the wish for a moment of transformation without vulnerability. In reality, unless the World implements transactional semantics, a failure mid-operation would leave the same inconsistent state the current system risks. The existing `beforeChange()`/`afterChange()` pattern, for all its scattered invocations, at least provides undo snapshots. The documents do not discuss what replaces this guarantee. + +The desire for atomicity is healthy. The assumption that it comes free with the architecture is not. + +### The CRDT Question + +The `LayoutStore` is correctly identified as "the most architecturally advanced extraction." It wraps Y.js CRDTs — a technology chosen for collaborative editing, as noted in ADR 0003. + +But the documents do not address the tension between Y.js and a pure ECS World. Would the World contain Y.js documents? Would it replace them? Would the Position component be a CRDT, a plain object, or a proxy that reads from one? This is not an implementation detail — it is a fundamental architectural question about whether the system's two most sophisticated subsystems (collaboration and ECS) can coexist or must be reconciled. + +The silence on this point is the silence of a psyche that has not yet confronted a genuine dilemma. + +## IV. On the Keying Strategies: Identity and Its Discontents + +The proto-ECS analysis catalogs five different keying strategies across five stores and presents this multiplicity as pathological. There is truth in this — the absence of a unified identity system does create real confusion and real bugs. + +But one must be careful not to mistake diversity for disorder. Some of these composite keys — `"${nodeId}:${widgetName}"`, for instance — reflect a genuine structural reality: a widget is identified by its relationship to a node and its name within that node. A branded `WidgetEntityId` would replace this composite key with a synthetic integer, gaining cross-kind safety but losing the self-documenting quality of the composite. + +The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain. + +## V. On the Subgraph: The Child Who Contains the Parent + +The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system. + +A Subgraph is a Graph that lives inside a Node that lives inside a Graph. It is the child that contains the parent's structure — the recursive self-reference that gives the system its power and its pathology simultaneously. The barrel export comment at `litegraph.ts:15` is a symptom, yes, but it is also an honest acknowledgment of a genuine structural paradox. + +The ECS target resolves this by flattening: "Entities are just IDs. No inheritance hierarchy." This is a valid architectural choice. But it is worth noting that the current circular structure _accurately models the domain_. A subgraph _is_ a graph. The inheritance relationship is not arbitrary — it reflects a real isomorphism. + +The ECS approach replaces structural modeling with data modeling. This eliminates the circular dependency but requires the system to reconstruct the "a subgraph is a graph" relationship through component composition rather than inheritance. The documents assume this is straightforward. It may not be — the recursive case (subgraphs containing subgraphs) will test whether flat entity composition can express what hierarchical inheritance expresses naturally. + +## VI. On the Migration Bridge: The Transitional Object + +The migration bridge described in the target architecture is perhaps the most psychologically astute element of the entire proposal. It acknowledges that transformation cannot happen all at once — that the old structures must coexist with the new until the new have proven themselves capable of bearing the load. + +The three-phase sequence (bridge reads from class and writes to World; new features build on World directly; bridge removed) is the sequence of every successful therapeutic process: first, the new understanding runs alongside the old patterns; then, new behavior begins to emerge from the new understanding; finally, the old patterns are released because they are no longer needed, not because they have been forcibly suppressed. + +This is sound. The documents would benefit from being equally realistic about the _duration_ of the bridge phase. In a system with this many extensions, this much surface area, and this much organic complexity, the bridge may persist for a very long time. This is not failure — it is the natural pace of genuine transformation. + +## VII. Summary of Findings + +### Factual Corrections Required + +| Document | Error | Correction | +| --------------------- | ---------------------------------- | ---------------------------------- | +| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` | +| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` | +| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated | +| `entity-problems.md` | `configure()` ~180 lines | ~247 lines | +| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` | + +### Analytical Gaps + +1. **Extension API continuity** is the largest unaddressed risk in the migration. +2. **Atomicity guarantees** are claimed but not mechanically specified. +3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage. +4. **ECS line-count projections** are aspirational and should be marked as estimates. +5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything." + +### What the Documents Do Well + +The problem diagnosis is grounded, specific, and verified. The proto-ECS analysis correctly identifies organic convergence toward ECS patterns. The lifecycle scenarios effectively communicate the structural simplification that ECS enables. The change-tracker document is accurate and immediately useful. + +These are the documents of a system that has looked at itself honestly — which is, as any analyst will tell you, the necessary precondition for change. diff --git a/docs/architecture/ecs-lifecycle-scenarios.md b/docs/architecture/ecs-lifecycle-scenarios.md new file mode 100644 index 0000000000..8a14445efb --- /dev/null +++ b/docs/architecture/ecs-lifecycle-scenarios.md @@ -0,0 +1,744 @@ +# ECS Lifecycle Scenarios + +This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md). + +Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (what it looks like with the World), and a **Key Differences** table. + +## 1. Node Removal + +### Current Flow + +`LGraph.remove(node)` — 107 lines, touches 6+ entity types and 4+ external systems: + +```mermaid +sequenceDiagram + participant Caller + participant G as LGraph + participant N as LGraphNode + participant L as LLink + participant R as Reroute + participant C as LGraphCanvas + participant LS as LayoutStore + + Caller->>G: remove(node) + G->>G: beforeChange() [undo checkpoint] + + loop each input slot + G->>N: disconnectInput(i) + N->>L: link.disconnect(network) + L->>G: _links.delete(linkId) + L->>R: cleanup orphaned reroutes + N->>LS: layoutMutations.removeLink() + N->>G: _version++ + end + + loop each output slot + G->>N: disconnectOutput(i) + Note over N,R: same cascade as above + end + + G->>G: scan floatingLinks for node refs + G->>G: if SubgraphNode: check refs, maybe delete subgraph def + G->>N: node.onRemoved?.() + G->>N: node.graph = null + G->>G: _version++ + + loop each canvas + G->>C: deselect(node) + G->>C: delete selected_nodes[id] + end + + G->>G: splice from _nodes[], delete from _nodes_by_id + G->>G: onNodeRemoved?.(node) + G->>C: setDirtyCanvas(true, true) + G->>G: afterChange() [undo checkpoint] + G->>G: updateExecutionOrder() +``` + +Problems: the graph method manually disconnects every slot, cleans up reroutes, scans floating links, checks subgraph references, notifies canvases, and recomputes execution order — all in one method that knows about every entity type. + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant CS as ConnectivitySystem + participant W as World + participant VS as VersionSystem + + Caller->>CS: removeNode(world, nodeId) + + CS->>W: getComponent(nodeId, Connectivity) + W-->>CS: { inputSlotIds, outputSlotIds } + + loop each slotId + CS->>W: getComponent(slotId, SlotConnection) + W-->>CS: { linkIds } + loop each linkId + CS->>CS: removeLink(world, linkId) + Note over CS,W: removes Link entity + updates remote slots + end + CS->>W: deleteEntity(slotId) + end + + CS->>W: getComponent(nodeId, WidgetContainer) + W-->>CS: { widgetIds } + loop each widgetId + CS->>W: deleteEntity(widgetId) + end + + CS->>W: deleteEntity(nodeId) + Note over W: removes Position, NodeVisual, NodeType,
Connectivity, Execution, Properties,
WidgetContainer — all at once + + CS->>VS: markChanged() +``` + +### Key Differences + +| Aspect | Current | ECS | +| ------------------- | ------------------------------------------------ | ------------------------------------------------------ | +| Lines of code | ~107 in one method | ~30 in system function | +| Entity types known | Graph knows about all 6+ types | ConnectivitySystem knows Connectivity + SlotConnection | +| Cleanup | Manual per-slot, per-link, per-reroute | `deleteEntity()` removes all components atomically | +| Canvas notification | `setDirtyCanvas()` called explicitly | RenderSystem sees missing entity on next frame | +| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup | +| Undo/redo | `beforeChange()`/`afterChange()` manually placed | System snapshots affected components before deletion | +| Testability | Needs full LGraph + LGraphCanvas | Needs only World + ConnectivitySystem | + +## 2. Serialization + +### Current Flow + +`LGraph.serialize()` → `asSerialisable()` — walks every collection manually: + +```mermaid +sequenceDiagram + participant Caller + participant G as LGraph + participant N as LGraphNode + participant L as LLink + participant R as Reroute + participant Gr as LGraphGroup + participant SG as Subgraph + + Caller->>G: serialize() + G->>G: asSerialisable() + + loop each node + G->>N: node.serialize() + N->>N: snapshot inputs, outputs (with link IDs) + N->>N: snapshot properties + N->>N: collect widgets_values[] + N-->>G: ISerialisedNode + end + + loop each link + G->>L: link.asSerialisable() + L-->>G: SerialisableLLink + end + + loop each reroute + G->>R: reroute.asSerialisable() + R-->>G: SerialisableReroute + end + + loop each group + G->>Gr: group.serialize() + Gr-->>G: ISerialisedGroup + end + + G->>G: findUsedSubgraphIds() + loop each used subgraph + G->>SG: subgraph.asSerialisable() + Note over SG: recursively serializes internal nodes, links, etc. + SG-->>G: ExportedSubgraph + end + + G-->>Caller: ISerialisedGraph +``` + +Problems: serialization logic lives in 6 different `serialize()` methods across 6 classes. Widget values are collected inline during node serialization. The graph walks its own collections — no separation of "what to serialize" from "how to serialize." + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant SS as SerializationSystem + participant W as World + + Caller->>SS: serialize(world) + + SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity) + W-->>SS: all node entities with their components + + SS->>W: queryAll(LinkEndpoints) + W-->>SS: all link entities + + SS->>W: queryAll(SlotIdentity, SlotConnection) + W-->>SS: all slot entities + + SS->>W: queryAll(RerouteLinks, Position) + W-->>SS: all reroute entities + + SS->>W: queryAll(GroupMeta, GroupChildren, Position) + W-->>SS: all group entities + + SS->>W: queryAll(SubgraphStructure, SubgraphMeta) + W-->>SS: all subgraph entities + + SS->>SS: assemble JSON from component data + SS-->>Caller: SerializedGraph +``` + +### Key Differences + +| Aspect | Current | ECS | +| ---------------------- | ----------------------------------------------- | ---------------------------------------------- | +| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem | +| Widget values | Collected inline during `node.serialize()` | WidgetValue component queried directly | +| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat query — SubgraphStructure has entity refs | +| Adding a new component | Modify the entity's `serialize()` method | Add component to query in SerializationSystem | +| Testing | Need full object graph to test serialization | Mock World with test components | + +## 3. Deserialization + +### Current Flow + +`LGraph.configure(data)` — ~180 lines, two-phase node creation: + +```mermaid +sequenceDiagram + participant Caller + participant G as LGraph + participant N as LGraphNode + participant L as LLink + participant WVS as WidgetValueStore + + Caller->>G: configure(data) + G->>G: clear() [destroy all existing entities] + G->>G: _configureBase(data) [set id, extra] + + loop each serialized link + G->>L: LLink.create(linkData) + G->>G: _links.set(link.id, link) + end + + loop each serialized reroute + G->>G: setReroute(rerouteData) + end + + opt has subgraph definitions + G->>G: deduplicateSubgraphNodeIds() + loop each subgraph (topological order) + G->>G: createSubgraph(data) + end + end + + rect rgb(60, 40, 40) + Note over G,N: Phase 1: Create nodes (unlinked) + loop each serialized node + G->>N: LiteGraph.createNode(type) + G->>G: graph.add(node) [assigns ID] + end + end + + rect rgb(40, 60, 40) + Note over G,N: Phase 2: Configure nodes (links now resolvable) + loop each node + G->>N: node.configure(nodeData) + N->>N: create slots, restore properties + N->>N: resolve links from graph._links + N->>N: restore widget values + N->>WVS: widget.setNodeId() → register in store + N->>N: fire onConnectionsChange per linked slot + end + end + + G->>G: add floating links + G->>G: validate reroutes + G->>G: _removeDuplicateLinks() + + loop each serialized group + G->>G: create + configure group + end + + G->>G: updateExecutionOrder() +``` + +Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs. + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant SS as SerializationSystem + participant W as World + participant LS as LayoutSystem + participant ES as ExecutionSystem + + Caller->>SS: deserialize(world, data) + + SS->>W: clear() [remove all entities] + + Note over SS,W: All entities created in one pass — no two-phase needed + + loop each node in data + SS->>W: createEntity(NodeEntityId) + SS->>W: setComponent(id, Position, {...}) + SS->>W: setComponent(id, NodeType, {...}) + SS->>W: setComponent(id, NodeVisual, {...}) + SS->>W: setComponent(id, Properties, {...}) + SS->>W: setComponent(id, Execution, {...}) + end + + loop each slot in data + SS->>W: createEntity(SlotEntityId) + SS->>W: setComponent(id, SlotIdentity, {...}) + SS->>W: setComponent(id, SlotConnection, {...}) + end + + Note over SS,W: Slots reference links by ID — no resolution needed yet + + loop each link in data + SS->>W: createEntity(LinkEntityId) + SS->>W: setComponent(id, LinkEndpoints, {...}) + end + + Note over SS,W: Connectivity assembled from slot/link components + + loop each widget in data + SS->>W: createEntity(WidgetEntityId) + SS->>W: setComponent(id, WidgetIdentity, {...}) + SS->>W: setComponent(id, WidgetValue, {...}) + end + + SS->>SS: create reroutes, groups, subgraphs similarly + + Note over SS,W: Systems react to populated World + + SS->>LS: runLayout(world) + SS->>ES: computeExecutionOrder(world) +``` + +### Key Differences + +| Aspect | Current | ECS | +| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances | +| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: WidgetValue component written directly | +| Store population | Side effect of `widget.setNodeId()` | World IS the store — writing component IS population | +| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems query World after deserialization | +| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs | +| Error handling | Failed node → placeholder with `has_errors=true` | Failed entity → skip; components that loaded are still valid | + +## 4. Pack Subgraph + +### Current Flow + +`LGraph.convertToSubgraph(items)` — clones nodes, computes boundaries, creates SubgraphNode: + +```mermaid +sequenceDiagram + participant Caller + participant G as LGraph + participant N as LGraphNode + participant SG as Subgraph + participant SGN as SubgraphNode + + Caller->>G: convertToSubgraph(selectedItems) + G->>G: beforeChange() + + G->>G: getBoundaryLinks(items) + Note over G: classify links as internal, boundary-in, boundary-out + + G->>G: splitPositionables(items) → nodes, reroutes, groups + G->>N: multiClone(nodes) → cloned nodes (no links) + G->>G: serialize internal links, reroutes + + G->>G: mapSubgraphInputsAndLinks(boundaryInputLinks) + G->>G: mapSubgraphOutputsAndLinks(boundaryOutputLinks) + + G->>G: createSubgraph(exportedData) + G->>SG: subgraph.configure(data) + + loop disconnect boundary links + G->>N: inputNode.disconnectInput() + G->>N: outputNode.disconnectOutput() + end + + loop remove original items + G->>G: remove(node), remove(reroute), remove(group) + end + + G->>SGN: LiteGraph.createNode(subgraph.id) + G->>G: graph.add(subgraphNode) + + loop reconnect boundary inputs + G->>N: externalNode.connectSlots(output, subgraphNode, input) + end + + loop reconnect boundary outputs + G->>SGN: subgraphNode.connectSlots(output, externalNode, input) + end + + G->>G: afterChange() +``` + +Problems: 200+ lines in one method. Manual boundary link analysis. Clone-serialize-configure dance. Disconnect-remove-recreate-reconnect sequence with many intermediate states where the graph is inconsistent. + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant CS as ConnectivitySystem + participant W as World + + Caller->>CS: packSubgraph(world, selectedEntityIds) + + CS->>W: query Connectivity + SlotConnection for selected nodes + CS->>CS: classify links as internal vs boundary + + CS->>W: create new GraphId scope in scopes registry + + Note over CS,W: Create SubgraphNode entity in parent scope + + CS->>W: createEntity(NodeEntityId) [the SubgraphNode] + CS->>W: setComponent(nodeId, Position, { center of selection }) + CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface }) + CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' }) + + Note over CS,W: Re-parent selected entities into new graph scope + + loop each selected entity + CS->>W: update graphScope to new graphId + end + + Note over CS,W: Create boundary slots on SubgraphNode + + loop each boundary input link + CS->>W: create SlotEntity on SubgraphNode + CS->>W: update LinkEndpoints to target new slot + end + + loop each boundary output link + CS->>W: create SlotEntity on SubgraphNode + CS->>W: update LinkEndpoints to source from new slot + end +``` + +### Key Differences + +| Aspect | Current | ECS | +| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- | +| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId | +| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots | +| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together | +| Code size | 200+ lines | ~50 lines in system | +| Undo | `beforeChange()`/`afterChange()` wraps everything | Snapshot affected components before mutation | + +## 5. Unpack Subgraph + +### Current Flow + +`LGraph.unpackSubgraph(subgraphNode)` — clones internal nodes, remaps IDs, reconnects boundary: + +```mermaid +sequenceDiagram + participant Caller + participant G as LGraph + participant SGN as SubgraphNode + participant SG as Subgraph + participant N as LGraphNode + + Caller->>G: unpackSubgraph(subgraphNode) + G->>G: beforeChange() + + G->>SG: get internal nodes + G->>N: multiClone(internalNodes) + + loop each cloned node + G->>G: assign new ID (++lastNodeId) + G->>G: nodeIdMap[oldId] = newId + G->>G: graph.add(node) + G->>N: node.configure(info) + G->>N: node.setPos(pos + offset) + end + + G->>G: clone and add groups + + Note over G,SG: Remap internal links + + loop each internal link + G->>G: remap origin_id/target_id via nodeIdMap + opt origin is SUBGRAPH_INPUT_ID + G->>G: resolve to external source via subgraphNode.inputs + end + opt target is SUBGRAPH_OUTPUT_ID + G->>G: resolve to external target via subgraphNode.outputs + end + end + + G->>G: remove(subgraphNode) + G->>G: deduplicate links + G->>G: create new LLink objects in parent graph + G->>G: remap reroute parentIds + G->>G: afterChange() +``` + +Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20) require special-case handling. Boundary link resolution requires looking up the SubgraphNode's slots to find external connections. + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant CS as ConnectivitySystem + participant W as World + + Caller->>CS: unpackSubgraph(world, subgraphNodeId) + + CS->>W: getComponent(subgraphNodeId, SubgraphStructure) + W-->>CS: { graphId, interface } + + CS->>W: query entities where graphScope = graphId + W-->>CS: all child entities (nodes, links, reroutes, etc.) + + Note over CS,W: Re-parent entities to containing graph scope + + loop each child entity + CS->>W: update graphScope to parent scope + end + + Note over CS,W: Reconnect boundary links + + loop each boundary slot in interface + CS->>W: getComponent(slotId, SlotConnection) + CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot + end + + CS->>W: deleteEntity(subgraphNodeId) + CS->>W: remove graphId from scopes registry + + Note over CS,W: Offset positions + + loop each moved entity + CS->>W: update Position component + end +``` + +### Key Differences + +| Aspect | Current | ECS | +| ----------------- | --------------------------------------------------- | --------------------------------------------------------------- | +| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes | +| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities | +| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes | +| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place | +| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed | + +## 6. Connect Slots + +### Current Flow + +`LGraphNode.connectSlots()` — creates link, updates both endpoints, handles reroutes: + +```mermaid +sequenceDiagram + participant Caller + participant N1 as SourceNode + participant N2 as TargetNode + participant G as LGraph + participant L as LLink + participant R as Reroute + participant LS as LayoutStore + + Caller->>N1: connectSlots(output, targetNode, input) + + N1->>N1: validate slot types + N1->>N2: onConnectInput?() → can reject + N1->>N1: onConnectOutput?() → can reject + + opt input already connected + N1->>N2: disconnectInput(inputIndex) + end + + N1->>L: new LLink(++lastLinkId, type, ...) + N1->>G: _links.set(link.id, link) + N1->>LS: layoutMutations.createLink() + + N1->>N1: output.links.push(link.id) + N1->>N2: input.link = link.id + + loop each reroute in path + N1->>R: reroute.linkIds.add(link.id) + end + + N1->>G: _version++ + N1->>N1: onConnectionsChange?(OUTPUT, ...) + N1->>N2: onConnectionsChange?(INPUT, ...) + N1->>G: setDirtyCanvas() + N1->>G: afterChange() +``` + +Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method. + +### ECS Flow + +```mermaid +sequenceDiagram + participant Caller + participant CS as ConnectivitySystem + participant W as World + participant VS as VersionSystem + + Caller->>CS: connect(world, outputSlotId, inputSlotId) + + CS->>W: getComponent(inputSlotId, SlotConnection) + opt already connected + CS->>CS: removeLink(world, existingLinkId) + end + + CS->>W: createEntity(LinkEntityId) + CS->>W: setComponent(linkId, LinkEndpoints, {
originNodeId, originSlotIndex,
targetNodeId, targetSlotIndex, type
}) + + CS->>W: update SlotConnection on outputSlotId (add linkId) + CS->>W: update SlotConnection on inputSlotId (set linkId) + + CS->>VS: markChanged() +``` + +### Key Differences + +| Aspect | Current | ECS | +| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- | +| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (queries World) | +| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `markChanged()` — one call | +| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system | +| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Component update: `setComponent(slotId, SlotConnection, ...)` | +| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function | + +## 7. Copy / Paste + +### Current Flow + +Copy: serialize selected items → clipboard. Paste: deserialize with new IDs. + +```mermaid +sequenceDiagram + participant User + participant C as LGraphCanvas + participant G as LGraph + participant N as LGraphNode + participant CB as Clipboard + + rect rgb(40, 40, 60) + Note over User,CB: Copy + User->>C: Ctrl+C + C->>C: _serializeItems(selectedItems) + loop each selected node + C->>N: node.clone().serialize() + C->>C: collect input links + end + C->>C: collect groups, reroutes + C->>C: recursively collect subgraph definitions + C->>CB: localStorage.setItem(JSON.stringify(data)) + end + + rect rgb(40, 60, 40) + Note over User,CB: Paste + User->>C: Ctrl+V + C->>CB: localStorage.getItem() + C->>C: _deserializeItems(parsed) + + C->>C: remap subgraph IDs (new UUIDs) + C->>C: deduplicateSubgraphNodeIds() + C->>G: beforeChange() + + loop each subgraph + C->>G: createSubgraph(data) + end + loop each node (id=-1 forces new ID) + C->>G: graph.add(node) + C->>N: node.configure(info) + end + loop each reroute + C->>G: setReroute(data) + C->>C: remap parentIds + end + loop each link + C->>N: outNode.connect(slot, inNode, slot) + end + + C->>C: offset positions to cursor + C->>C: selectItems(created) + C->>G: afterChange() + end +``` + +Problems: clone-serialize-parse-remap-deserialize dance. Every entity type has +its own ID remapping logic. Subgraph IDs, node IDs, reroute IDs, and link +parent IDs all remapped independently. ~300 lines across multiple methods. + +### ECS Flow + +```mermaid +sequenceDiagram + participant User + participant CS as ClipboardSystem + participant W as World + participant CB as Clipboard + + rect rgb(40, 40, 60) + Note over User,CB: Copy + User->>CS: copy(world, selectedEntityIds) + CS->>W: snapshot all components for selected entities + CS->>W: snapshot components for child entities (slots, widgets) + CS->>W: snapshot connected links (LinkEndpoints) + CS->>CB: store component snapshot + end + + rect rgb(40, 60, 40) + Note over User,CB: Paste + User->>CS: paste(world, position) + CS->>CB: retrieve snapshot + + CS->>CS: generate ID remap table (old → new branded IDs) + + loop each entity in snapshot + CS->>W: createEntity(newId) + loop each component + CS->>W: setComponent(newId, type, remappedData) + Note over CS,W: entity ID refs in component data
are remapped via table + end + end + + CS->>CS: offset all Position components to cursor + end +``` + +### Key Differences + +| Aspect | Current | ECS | +| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) | +| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components | +| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createEntity()` → `setComponent()` per entity (flat) | +| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs | +| Code complexity | ~300 lines across 4 methods | ~60 lines in one system | + +## Summary: Cross-Cutting Benefits + +| Benefit | Scenarios Where It Applies | +| ----------------------------- | -------------------------------------------------------------------------- | +| **Atomic operations** | Node Removal, Pack/Unpack — no intermediate inconsistent state | +| **No scattered `_version++`** | All scenarios — VersionSystem handles change tracking | +| **No callback cascades** | Deserialization, Connect — systems query World instead of firing callbacks | +| **Uniform ID handling** | Copy/Paste, Unpack — one remap table instead of per-type logic | +| **Entity deletion = cleanup** | Node Removal — `deleteEntity()` removes all components | +| **No two-phase creation** | Deserialization — components reference IDs, not instances | +| **Move instead of clone** | Pack/Unpack — entities keep their IDs, just change scope | +| **Testable in isolation** | All scenarios — mock World, test one system | +| **Undo/redo for free** | All scenarios — snapshot components before mutation, restore on undo | diff --git a/docs/architecture/ecs-migration-plan.md b/docs/architecture/ecs-migration-plan.md new file mode 100644 index 0000000000..a3ba8fbe53 --- /dev/null +++ b/docs/architecture/ecs-migration-plan.md @@ -0,0 +1,722 @@ +# ECS Migration Plan + +A phased roadmap for migrating the litegraph entity system to the ECS +architecture described in [ADR 0008](../adr/0008-entity-component-system.md). +Each phase is independently shippable. Later phases depend on earlier ones +unless noted otherwise. + +For the problem analysis, see [Entity Problems](entity-problems.md). For the +target architecture, see [ECS Target Architecture](ecs-target-architecture.md). +For verified accuracy of these documents, see +[Appendix: Critical Analysis](appendix-critical-analysis.md). + +## Planning assumptions + +- The bridge period is expected to span 2-3 release cycles. +- Bridge work is treated as transitional debt with explicit owners and sunset + checkpoints, not as a permanent architecture layer. +- Phase 5 is entered only by explicit go/no-go review against the criteria in + this document. + +## Phase 0: Foundation + +Zero behavioral risk. Prepares the codebase for extraction without changing +runtime semantics. All items are independently shippable. + +### 0a. Centralize version counter + +`graph._version++` appears in 19 locations across 7 files. The counter is only +read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It +is not used for dirty-checking, caching, or reactivity. + +**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct +increments. + +``` +incrementVersion(): void { + this._version++ +} +``` + +| File | Sites | +| ---------------------- | ------------------------------------------------------- | +| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) | +| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) | +| `LGraphCanvas.ts` | 2 (lines 3084, 7880) | +| `BaseWidget.ts` | 1 (line 439) | +| `SubgraphInput.ts` | 1 (line 137) | +| `SubgraphInputNode.ts` | 1 (line 190) | +| `SubgraphOutput.ts` | 1 (line 102) | + +**Why first:** Creates the seam where a VersionSystem can later intercept, +batch, or replace the mechanism. Mechanical find-and-replace with zero +behavioral change. + +**Risk:** None. Existing null guards at call sites are preserved. + +### 0b. Add missing ID type aliases + +`NodeId`, `LinkId`, and `RerouteId` exist as type aliases. Two are missing: + +| Type | Definition | Location | +| ----------- | ---------- | ---------------------------------------------------------------- | +| `GroupId` | `number` | `LGraphGroup.ts` (currently implicit on `id: number` at line 39) | +| `SlotIndex` | `number` | `interfaces.ts` (slot positions are untyped `number` everywhere) | + +**Change:** Add the type aliases, update property declarations, re-export from +barrel (`litegraph.ts`). + +**Why:** Foundation for branded IDs. Type aliases are erased at compile time — +zero runtime impact. + +**Risk:** None. Type-only change. + +### 0c. Fix architecture doc errors + +Five factual errors verified during code review (see +[Appendix](appendix-critical-analysis.md#vii-summary-of-findings)): + +- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should + be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240 + lines not ~180 +- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on + PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()` + +--- + +## Phase 1: Types and World Shell + +Introduces the ECS type vocabulary and an empty World. No migration of existing +code — new types coexist with old ones. + +### 1a. Branded entity ID types + +Define branded types in a new `src/ecs/entityId.ts`: + +``` +type NodeEntityId = number & { readonly __brand: 'NodeEntityId' } +type LinkEntityId = number & { readonly __brand: 'LinkEntityId' } +type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' } +type SlotEntityId = number & { readonly __brand: 'SlotEntityId' } +type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' } +type GroupEntityId = number & { readonly __brand: 'GroupEntityId' } +type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity +``` + +Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at +system boundaries (deserialization, legacy bridge). + +**Does NOT change existing code.** The branded types are new exports consumed +only by new ECS code. + +**Risk:** Low. New files, no modifications to existing code. + +**Consideration:** `NodeId = number | string` is the current type. The branded +`NodeEntityId` narrows to `number`. The `string` branch exists solely for +subgraph-related nodes (GroupNode hack). The migration must decide whether to: + +- Keep `NodeEntityId = number` and handle the string case at the bridge layer +- Or define `NodeEntityId = number | string` with branding (less safe) + +Recommend the former: the bridge layer coerces string IDs to a numeric +mapping, and only branded numeric IDs enter the World. + +### 1b. Component interfaces + +Define component interfaces in `src/ecs/components/`: + +``` +src/ecs/ + entityId.ts # Branded ID types + components/ + position.ts # Position (shared by Node, Reroute, Group) + nodeType.ts # NodeType + nodeVisual.ts # NodeVisual + connectivity.ts # Connectivity + execution.ts # Execution + properties.ts # Properties + widgetContainer.ts # WidgetContainer + linkEndpoints.ts # LinkEndpoints + ... + world.ts # World type and factory +``` + +Components are TypeScript interfaces only — no runtime code. They mirror +the decomposition in ADR 0008 Section "Component Decomposition." + +**Risk:** None. Interface-only files. + +### 1c. World type + +Define the World as a typed container: + +```ts +interface World { + nodes: Map + links: Map + widgets: Map + slots: Map + reroutes: Map + groups: Map + scopes: Map // graph scope DAG (parent or null for root) + + createEntity(kind: K): EntityIdFor + deleteEntity(kind: K, id: EntityIdFor): void + getComponent(id: EntityId, component: ComponentKey): C | undefined + setComponent(id: EntityId, component: ComponentKey, data: C): void +} +``` + +Subgraphs are not a separate entity kind. A node with a `SubgraphStructure` +component represents a subgraph. The `scopes` map tracks the graph nesting DAG. +See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full +model. + +World scope is per workflow instance. Linked subgraph definitions can be reused +across instances, but mutable runtime state (widget values, execution state, +selection/transient view state) remains instance-scoped through `graphId`. + +Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no +persistence. The World exists but nothing populates it yet. + +**Risk:** Low. New code, no integration points. + +--- + +## Phase 2: Bridge Layer + +Connects the legacy class instances to the World. Both old and new code can +read entity state; writes still go through legacy classes. + +### 2a. Read-only bridge for Position + +The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already +extracts position data for nodes, links, and reroutes into Y.js CRDTs. The +bridge reads from LayoutStore and populates the World's `Position` component. + +**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors +them into the World. New code reads `world.getComponent(nodeId, Position)`; +legacy code continues to read `node.pos` / LayoutStore directly. + +**Open question:** Should the World wrap the Y.js maps or maintain its own +plain-data copy? Options: + +| Approach | Pros | Cons | +| ---------------------- | ------------------------------------- | ----------------------------------------------- | +| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test | +| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead | +| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change | + +**Recommendation:** Start with "World copies from Y.js" for simplicity. The +copy is cheap (position is small data). Revisit if sync overhead becomes +measurable. + +**Risk:** Medium. Introduces a sync point between two state systems. Must +ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale +position during render). + +### 2b. Read-only bridge for WidgetValue + +WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget +state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is +the closest proto-ECS store. + +**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into +`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires +assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState). + +**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`. + +**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is +the ID mapping — widgets currently lack independent IDs, so the bridge must +maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup. + +### 2c. Read-only bridge for Node metadata + +Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by +reading from `LGraphNode` instances. These are simple property copies. + +**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge +creates the corresponding entity in the World and populates its components. +When a node is removed, the bridge deletes the entity. + +The `incrementVersion()` method from Phase 0a becomes the hook point — when +version increments, the bridge can re-sync changed components. (This is why +centralizing version first matters.) + +**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove) +without breaking existing behavior. The bridge is read-only (World mirrors +classes, not the reverse), which limits blast radius. + +### Bridge sunset criteria (applies to every Phase 2 bridge) + +A bridge can move from "transitional" to "removal candidate" only when: + +- All production reads for that concern flow through World component queries. +- All production writes for that concern flow through system APIs. +- Serialization parity tests show no diff between legacy and World paths. +- Extension compatibility tests pass without bridge-only fallback paths. + +These criteria prevent the bridge from becoming permanent by default. + +### Bridge duration and maintenance controls + +To contain dual-path maintenance cost during Phases 2-4: + +- Every bridge concern has a named owner and target sunset release. +- Every PR touching bridge-covered data paths must include parity tests for both + legacy and World-driven execution. +- Bridge fallback usage is instrumented in integration/e2e and reviewed every + milestone; upward trends block new bridge expansion. +- Any bridge that misses its target sunset release requires an explicit risk + review and revised removal plan. + +--- + +## Phase 3: Systems + +Introduce system functions that operate on World data. Systems coexist with +legacy methods — they don't replace them yet. + +### 3a. SerializationSystem (read-only) + +A function `serializeFromWorld(world: World): SerializedGraph` that produces +workflow JSON by querying World components. Run alongside the existing +`LGraph.serialize()` in tests to verify equivalence. + +**Why first:** Serialization is read-only and has a clear correctness check +(output must match existing serialization). It exercises every component type +and proves the World contains sufficient data. + +**Risk:** Low. Runs in parallel with existing code; does not replace it. + +### 3b. VersionSystem + +Replace the `incrementVersion()` method with a system that owns all change +tracking. The system observes component mutations on the World and +auto-increments the version counter. + +**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World +doesn't see changes). + +**Risk:** Medium. Must not miss any change that the scattered `_version++` +currently catches. The 19-site inventory from Phase 0a serves as the test +matrix. + +### 3c. ConnectivitySystem (queries only) + +A system that can answer connectivity queries by reading `Connectivity`, +`SlotConnection`, and `LinkEndpoints` components from the World: + +- "What nodes are connected to this node's inputs?" +- "What links pass through this reroute?" +- "What is the execution order?" + +Does not perform mutations yet — just queries. Validates that the World's +connectivity data is complete and consistent with the class-based graph. + +**Risk:** Low. Read-only system with equivalence tests. + +--- + +## Phase 4: Write Path Migration + +Systems begin owning mutations. Legacy class methods delegate to systems. +This is the highest-risk phase. + +### 4a. Position writes through World + +New code writes position via `world.setComponent(nodeId, Position, ...)`. +The bridge propagates changes back to LayoutStore and `LGraphNode.pos`. + +**This inverts the data flow:** Phase 2 had legacy -> World (read bridge). +Phase 4 has World -> legacy (write bridge). Both paths must work during the +transition. + +**Risk:** High. Two-way sync between World and legacy state. Must handle +re-entrant updates (World write triggers bridge, which writes to legacy, +which must NOT trigger another World write). + +### 4b. ConnectivitySystem mutations + +`connect()`, `disconnect()`, `removeNode()` operations implemented as system +functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the +system. + +**Extension API concern:** The current system fires callbacks at each step: + +- `onConnectInput()` / `onConnectOutput()` — can reject connections +- `onConnectionsChange()` — notifies after connection change +- `onRemoved()` — notifies after node removal + +These callbacks are the **extension API contract**. The ConnectivitySystem +must fire them at the same points in the operation, or extensions break. + +**Recommended approach:** The system emits lifecycle events that the bridge +layer translates into legacy callbacks. This preserves the contract without +the system knowing about the callback API. + +**Phase 4 callback contract (locked):** + +- `onConnectOutput()` and `onConnectInput()` run before any World mutation. +- If either callback rejects, abort with no component writes, no version bump, + and no lifecycle events. +- `onConnectionsChange()` fires synchronously after commit, preserving current + source-then-target ordering. +- Bridge lifecycle events remain internal. Legacy callbacks stay the public + compatibility API during Phase 4. + +**Risk:** High. Extensions depend on callback ordering and timing. Must be +validated against real-world extensions. + +### 4c. Widget write path + +Widget value changes go through the World instead of directly through +WidgetValueStore. The World's `WidgetValue` component becomes the single +source of truth; WidgetValueStore becomes a read-through cache or is removed. + +**Risk:** Medium. WidgetValueStore is already well-abstracted. The main +change is routing writes through the World instead of the store. + +### 4d. Layout write path and render decoupling + +Remove layout side effects from render incrementally by node family. + +**Approach:** + +1. Inventory `drawNode()` call paths that still trigger `arrange()`. +2. For one node family at a time, run `LayoutSystem` in update phase and mark + entities as layout-clean before render. +3. Keep a temporary compatibility fallback that runs legacy layout only for + non-migrated families. +4. Delete fallback once parity tests and frame-time budgets are met. + +**Risk:** High. Mixed-mode operation must avoid stale layout reads. Requires +family-level rollout and targeted regression tests. + +### Render hot-path performance gate + +Before enabling ECS render reads as default for any migrated family: + +- Benchmark representative workflows (200-node and 500-node minimum). +- Compare legacy vs ECS p95 frame time and mean draw cost. +- Block rollout on statistically significant regression beyond agreed budget + (default budget: 5% p95 frame-time regression ceiling). +- Capture profiler traces proving the dominant cost is not repeated + `world.getComponent()` lookups. + +### Phase 3 -> 4 gate (required) + +Phase 4 starts only when all of the following are true: + +- A transaction wrapper API exists on the World and is used by connectivity and + widget write paths in integration tests. +- Undo batching parity is proven: one logical user action yields one undo + checkpoint in both legacy and ECS paths. +- Callback timing and rejection semantics from Phase 4b are covered by + integration tests. +- A representative extension suite passes, including `rgthree-comfy`. +- Write bridge re-entrancy tests prove there is no World <-> legacy feedback + loop. +- Layout migration for any enabled node family passes read-only render checks + (no `arrange()` writes during draw). +- Render hot-path benchmark gate passes for every family moving to ECS-first + reads. + +--- + +## Phase 5: Legacy Removal + +Remove bridge layers and deprecated class properties. This phase happens +per-component, not all at once. + +### 5a. Remove Position bridge + +Once all position reads and writes go through the World, remove the bridge +and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`. + +### 5b. Remove widget class hierarchy + +Once all widget behavior is in systems, the 23+ widget subclasses can be +replaced with component data + system functions. `BaseWidget`, `NumberWidget`, +`ComboWidget`, etc. become configuration data rather than class instances. + +### 5c. Dissolve god objects + +`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is +holding the entity ID and delegating to the World. Eventually, they can be +removed entirely, replaced by entity ID + component queries. + +**Risk:** Very High. This is the irreversible step. Must be done only after +thorough validation that all consumers (including extensions) work with the +ECS path. + +### Phase 4 -> 5 exit criteria (required) + +Legacy removal starts only when all of the following are true: + +- The component being removed has no remaining direct reads or writes outside + World/system APIs. +- Serialization equivalence tests pass continuously for one release cycle. +- A representative extension compatibility matrix is green, including + `rgthree-comfy`. +- Bridge instrumentation shows zero fallback-path usage in integration and e2e + suites. +- A rollback plan exists for each removal PR until the release is cut. +- ECS write path has run as default behind a kill switch for at least one full + release cycle. +- No unresolved P0/P1 extension regressions are attributed to ECS migration in + that cycle. + +### Phase 5 trigger packet (required before first legacy-removal PR) + +The team prepares a single go/no-go packet containing: + +- Phase 4 -> 5 criteria checklist with links to evidence. +- Extension compatibility matrix results. +- Bridge fallback usage report (must be zero for the target concern). +- Performance gate report for ECS render/read paths. +- Rollback owner, rollback steps, and release coordination sign-off. + +--- + +## Open Questions + +### CRDT / ECS coexistence + +The LayoutStore uses Y.js CRDTs for collaboration-ready position data +(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World +uses plain `Map`s. These must coexist. + +**Options explored in Phase 2a.** The recommended path (World copies from Y.js) +defers the hard question. Eventually, the World may need to be CRDT-native — +but this requires a separate ADR. + +**Questions to resolve:** + +- Should non-position components also be CRDT-backed for collaboration? +- Does the World need an operation log for undo/redo, or can that remain + external (Y.js undo manager)? +- How does conflict resolution work when two users modify the same component? + +### Extension API preservation + +The current system exposes lifecycle callbacks on entity classes: + +| Callback | Class | Purpose | +| --------------------- | ------------ | ----------------------------------- | +| `onConnectInput` | `LGraphNode` | Validate/reject incoming connection | +| `onConnectOutput` | `LGraphNode` | Validate/reject outgoing connection | +| `onConnectionsChange` | `LGraphNode` | React to topology change | +| `onRemoved` | `LGraphNode` | Cleanup on deletion | +| `onAdded` | `LGraphNode` | Setup on graph insertion | +| `onConfigure` | `LGraphNode` | Post-deserialization hook | +| `onWidgetChanged` | `LGraphNode` | React to widget value change | + +Extensions register these callbacks to customize node behavior. The ECS +migration must preserve this contract or provide a documented migration path +for extension authors. + +**Recommended approach:** Define an `EntityLifecycleEvent` system that emits +typed events at the same points where callbacks currently fire. The bridge +layer translates events into legacy callbacks. Extensions can gradually adopt +event listeners instead of callbacks. + +**Phase 4 decisions:** + +- Rejection callbacks act as pre-commit guards (reject before World mutation). +- Callback dispatch remains synchronous during the bridge period. +- Callback order remains: output validation -> input validation -> commit -> + output change notification -> input change notification. + +### Extension Migration Examples (old -> new) + +The bridge keeps legacy callbacks working, but extension authors can migrate +incrementally to ECS-native patterns. + +#### 1) Widget lookup by name + +```ts +// Legacy pattern +const seedWidget = node.widgets?.find((w) => w.name === 'seed') +seedWidget?.setValue(42) + +// ECS pattern (using the bridge/world widget lookup index) +const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed') +if (seedWidgetId) { + const widgetValue = world.getComponent(seedWidgetId, WidgetValue) + if (widgetValue) { + world.setComponent(seedWidgetId, WidgetValue, { + ...widgetValue, + value: 42 + }) + } +} +``` + +#### 2) `onConnectionsChange` callback + +```ts +// Legacy pattern +nodeType.prototype.onConnectionsChange = function ( + side, + slot, + connected, + linkInfo +) { + updateExtensionState(this.id, side, slot, connected, linkInfo) +} + +// ECS pattern +lifecycleEvents.on('connection.changed', (event) => { + if (event.nodeId !== nodeId) return + updateExtensionState( + event.nodeId, + event.side, + event.slotIndex, + event.connected, + event.linkInfo + ) +}) +``` + +#### 3) `onRemoved` callback + +```ts +// Legacy pattern +nodeType.prototype.onRemoved = function () { + cleanupExtensionResources(this.id) +} + +// ECS pattern +lifecycleEvents.on('entity.removed', (event) => { + if (event.kind !== 'node' || event.entityId !== nodeId) return + cleanupExtensionResources(event.entityId) +}) +``` + +#### 4) `graph._version++` + +```ts +// Legacy pattern (do not add new usages) +graph._version++ + +// Bridge-safe transitional pattern (Phase 0a) +graph.incrementVersion() + +// ECS-native pattern: mutate through command/system API. +// VersionSystem bumps once at transaction commit. +executor.run({ + type: 'SetWidgetValue', + execute(world) { + const value = world.getComponent(widgetId, WidgetValue) + if (!value) return + world.setComponent(widgetId, WidgetValue, { ...value, value: 42 }) + } +}) +``` + +**Question to resolve after compatibility parity:** + +- Should ECS-native lifecycle events stay synchronous after bridge removal, or + can they become asynchronous once legacy callback compatibility is dropped? + +### Atomicity and transactions + +The ECS lifecycle scenarios claim operations are "atomic." This requires +the World to support transactions — the ability to batch multiple component +writes and commit or rollback as a unit. + +**Current state:** `beforeChange()` / `afterChange()` provide undo/redo +checkpoints but not true transactions. The graph can be in an inconsistent +state between these calls. + +**Phase 4 baseline semantics:** + +- Mutating systems run inside `world.transaction(label, fn)`. +- The bridge maps one World transaction to one `beforeChange()` / + `afterChange()` bracket. +- Operations with multiple component writes (for example `connect()` touching + slots, links, and node metadata) still commit as one transaction and therefore + one undo entry. +- Failed transactions do not publish partial writes, lifecycle events, or + version increments. + +**Questions to resolve:** + +- How should `world.transaction()` interact with Y.js transactions when a + component is CRDT-backed? +- Is eventual consistency acceptable for derived data updates between + transactions, or must post-transaction state always be immediately + consistent? + +### Keying strategy unification + +The 6 proto-ECS stores use 6 different keying strategies: + +| Store | Key Format | +| ----------------------- | --------------------------------- | +| WidgetValueStore | `"${nodeId}:${widgetName}"` | +| PromotionStore | `"${sourceNodeId}:${widgetName}"` | +| DomWidgetStore | Widget UUID | +| LayoutStore | Raw nodeId/linkId/rerouteId | +| NodeOutputStore | `"${subgraphId}:${nodeId}"` | +| SubgraphNavigationStore | subgraphId or `'root'` | + +The World unifies these under branded entity IDs. But stores that use +composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural +reality — a widget is identified by its relationship to a node. Synthetic +`WidgetEntityId`s replace this with an opaque number, requiring a reverse +lookup index. + +**Trade-off:** Type safety and uniformity vs. self-documenting keys. The +World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`) +for the transition period. + +--- + +## Dependency Graph + +``` +Phase 0a (incrementVersion) ──┐ +Phase 0b (ID type aliases) ───┤ +Phase 0c (doc fixes) ─────────┤── no dependencies between these + │ +Phase 1a (branded IDs) ────────┤ +Phase 1b (component interfaces) ┤── 1b depends on 1a +Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b + +Phase 2a (Position bridge) ────┐── depends on 1c +Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c +Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c + +Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c +Phase 3b (VersionSystem) ──────── depends on 0a, 2c +Phase 3c (ConnectivitySystem) ──── depends on 2c + +Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c + +Phase 4a (Position writes) ────── depends on 2a, 3b +Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate +Phase 4c (Widget writes) ─────── depends on 2b +Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate + +Phase 4->5 exit criteria ──────── depends on all of Phase 4 + +Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria +``` + +## Risk Summary + +| Phase | Risk | Reversibility | Extension Impact | +| ------------------ | ---------- | ----------------------- | --------------------------- | +| 0 (Foundation) | None | Fully reversible | None | +| 1 (Types/World) | Low | New files, deletable | None | +| 2 (Bridge) | Low-Medium | Bridge is additive | None | +| 3 (Systems) | Low-Medium | Systems run in parallel | None | +| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved | +| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate | + +The plan is designed so that Phases 0-3 can ship without any risk to +extensions or existing behavior. Phase 4 is where the real migration begins, +and Phase 5 is the point of no return. diff --git a/docs/architecture/ecs-target-architecture.md b/docs/architecture/ecs-target-architecture.md new file mode 100644 index 0000000000..671d5ff40e --- /dev/null +++ b/docs/architecture/ecs-target-architecture.md @@ -0,0 +1,568 @@ +# ECS Target Architecture + +This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md). + +## 1. World Overview + +The World is the single source of truth for runtime entity state in one +workflow instance. Entities are just branded IDs. Components are plain data +objects. Systems are functions that query the World. + +```mermaid +graph TD + subgraph World["World (Central Registry)"] + direction TB + NodeStore["Nodes +Map<NodeEntityId, NodeComponents>"] + LinkStore["Links +Map<LinkEntityId, LinkComponents>"] + ScopeRegistry["Graph Scopes +Map<GraphId, ParentGraphId | null>"] + WidgetStore["Widgets +Map<WidgetEntityId, WidgetComponents>"] + SlotStore["Slots +Map<SlotEntityId, SlotComponents>"] + RerouteStore["Reroutes +Map<RerouteEntityId, RerouteComponents>"] + GroupStore["Groups +Map<GroupEntityId, GroupComponents>"] + end + + subgraph Systems["Systems (Behavior)"] + direction TB + RS["RenderSystem"] + SS["SerializationSystem"] + CS["ConnectivitySystem"] + LS["LayoutSystem"] + ES["ExecutionSystem"] + VS["VersionSystem"] + end + + RS -->|reads| World + SS -->|reads/writes| World + CS -->|reads/writes| World + LS -->|reads/writes| World + ES -->|reads| World + VS -->|reads/writes| World + + style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0 + style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0 +``` + +### Entity IDs + +```mermaid +graph LR + subgraph "Branded IDs (compile-time distinct)" + NID["NodeEntityId +number & { __brand: 'NodeEntityId' }"] + LID["LinkEntityId +number & { __brand: 'LinkEntityId' }"] + WID["WidgetEntityId +number & { __brand: 'WidgetEntityId' }"] + SLID["SlotEntityId +number & { __brand: 'SlotEntityId' }"] + RID["RerouteEntityId +number & { __brand: 'RerouteEntityId' }"] + GID["GroupEntityId +number & { __brand: 'GroupEntityId' }"] + end + + GRID["GraphId +string & { __brand: 'GraphId' }"]:::scopeId + + NID -.-x LID + LID -.-x WID + WID -.-x SLID + + classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5 + + linkStyle 0 stroke:red,stroke-dasharray:5 + linkStyle 1 stroke:red,stroke-dasharray:5 + linkStyle 2 stroke:red,stroke-dasharray:5 +``` + +Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected. + +Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md). + +### Linked subgraphs and instance-varying state + +Linked subgraph definitions can be shared structurally, but mutable values are +instance-scoped. + +- Shared definition-level data (interface shape, default metadata) can be reused + across instances. +- Runtime state (`WidgetValue`, execution/transient state, selection) is scoped + to the containing `graphId` chain inside one World instance. +- "Single source of truth" therefore means one source per workflow instance, + not one global source across all linked instances. + +### Recursive subgraphs without inheritance + +Recursive containment is represented through graph scopes rather than +`Subgraph extends LGraph` inheritance. + +- A subgraph node points to a child graph via + `SubgraphStructure.childGraphId`. +- The scope registry stores `childGraphId -> parentGraphId` links. +- Depth queries traverse this scope DAG, then filter entities by `graphScope`. + +## 2. Component Composition + +### Node: Before vs After + +```mermaid +graph LR + subgraph Before["LGraphNode (monolith)"] + direction TB + B1["pos, size, bounding"] + B2["color, bgcolor, title"] + B3["type, category, nodeData"] + B4["inputs[], outputs[]"] + B5["order, mode, flags"] + B6["properties, properties_info"] + B7["widgets[]"] + B8["serialize(), configure()"] + B9["drawSlots(), drawWidgets()"] + B10["execute(), triggerSlot()"] + B11["graph._version++"] + B12["connect(), disconnect()"] + end + + subgraph After["NodeEntityId + Components"] + direction TB + A1["Position +{ pos, size, bounding }"] + A2["NodeVisual +{ color, bgcolor, boxcolor, title }"] + A3["NodeType +{ type, category, nodeData }"] + A4["Connectivity +{ inputSlotIds[], outputSlotIds[] }"] + A5["Execution +{ order, mode, flags }"] + A6["Properties +{ properties, propertiesInfo }"] + A7["WidgetContainer +{ widgetIds[] }"] + end + + B1 -.-> A1 + B2 -.-> A2 + B3 -.-> A3 + B4 -.-> A4 + B5 -.-> A5 + B6 -.-> A6 + B7 -.-> A7 + + B8 -.->|"moves to"| SYS1["SerializationSystem"] + B9 -.->|"moves to"| SYS2["RenderSystem"] + B10 -.->|"moves to"| SYS3["ExecutionSystem"] + B11 -.->|"moves to"| SYS4["VersionSystem"] + B12 -.->|"moves to"| SYS5["ConnectivitySystem"] + + style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 +``` + +### Link: Before vs After + +```mermaid +graph LR + subgraph Before["LLink (class)"] + direction TB + B1["origin_id, origin_slot +target_id, target_slot, type"] + B2["color, path, _pos"] + B3["_dragging, data"] + B4["disconnect()"] + B5["resolve()"] + end + + subgraph After["LinkEntityId + Components"] + direction TB + A1["LinkEndpoints +{ originId, originSlot, +targetId, targetSlot, type }"] + A2["LinkVisual +{ color, path, centerPos }"] + A3["LinkState +{ dragging, data }"] + end + + B1 -.-> A1 + B2 -.-> A2 + B3 -.-> A3 + B4 -.->|"moves to"| SYS1["ConnectivitySystem"] + B5 -.->|"moves to"| SYS2["ConnectivitySystem"] + + style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 +``` + +### Widget: Before vs After + +```mermaid +graph LR + subgraph Before["BaseWidget (class)"] + direction TB + B1["name, type, _node"] + B2["value, options, serialize"] + B3["computedHeight, margin"] + B4["drawWidget(), onClick()"] + B5["useWidgetValueStore()"] + B6["usePromotionStore()"] + end + + subgraph After["WidgetEntityId + Components"] + direction TB + A1["WidgetIdentity +{ name, widgetType, parentNodeId }"] + A2["WidgetValue +{ value, options, serialize }"] + A3["WidgetLayout +{ computedHeight, constraints }"] + end + + B1 -.-> A1 + B2 -.-> A2 + B3 -.-> A3 + B4 -.->|"moves to"| SYS1["RenderSystem"] + B5 -.->|"absorbed by"| SYS2["World (is the store)"] + B6 -.->|"moves to"| SYS3["PromotionSystem"] + + style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 +``` + +## 3. System Architecture + +Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern. + +```mermaid +graph TD + subgraph InputPhase["Input Phase"] + UserInput["User Input +(pointer, keyboard)"] + APIInput["API Input +(backend execution results)"] + end + + subgraph UpdatePhase["Update Phase (ordered)"] + direction TB + CS["ConnectivitySystem +Manages link/slot mutations. +Writes: LinkEndpoints, SlotConnection, +Connectivity"] + VS["VersionSystem +Centralizes change tracking. +Replaces 15+ scattered _version++. +Writes: version counter"] + LS["LayoutSystem +Computes positions and sizes. +Runs BEFORE render, not during. +Reads: Connectivity, WidgetContainer +Writes: Position, SlotVisual, WidgetLayout"] + ES["ExecutionSystem +Determines run order. +Reads: Connectivity, Execution +Writes: Execution.order"] + end + + subgraph RenderPhase["Render Phase (read-only)"] + RS["RenderSystem +Pure read of components. +No state mutation. +Reads: Position, *Visual, *Layout"] + end + + subgraph PersistPhase["Persist Phase"] + SS["SerializationSystem +Reads/writes all components. +Handles workflow JSON."] + end + + UserInput --> CS + APIInput --> ES + CS --> VS + VS --> LS + LS --> RS + CS --> SS + + style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0 + style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0 + style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0 + style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0 +``` + +### System-Component Access Matrix + +```mermaid +graph LR + subgraph Systems + RS["Render"] + SS["Serialization"] + CS["Connectivity"] + LS["Layout"] + ES["Execution"] + VS["Version"] + end + + subgraph Components + Pos["Position"] + NV["NodeVisual"] + NT["NodeType"] + Con["Connectivity"] + Exe["Execution"] + Props["Properties"] + WC["WidgetContainer"] + LE["LinkEndpoints"] + LV["LinkVisual"] + SC["SlotConnection"] + SV["SlotVisual"] + WVal["WidgetValue"] + WL["WidgetLayout"] + end + + RS -.->|read| Pos + RS -.->|read| NV + RS -.->|read| LV + RS -.->|read| SV + RS -.->|read| WL + + LS -->|write| Pos + LS -->|write| SV + LS -->|write| WL + LS -.->|read| Con + LS -.->|read| WC + + CS -->|write| LE + CS -->|write| SC + CS -->|write| Con + + ES -.->|read| Con + ES -->|write| Exe + + SS -.->|read/write| Pos + SS -.->|read/write| NT + SS -.->|read/write| Props + SS -.->|read/write| WVal + SS -.->|read/write| LE + + VS -.->|read| Pos + VS -.->|read| Con +``` + +## 4. Dependency Flow + +### Before: Tangled References + +```mermaid +graph TD + Node["LGraphNode"] <-->|"circular"| Graph["LGraph"] + Graph <-->|"circular"| Subgraph["Subgraph"] + Node -->|"this.graph._links"| Links["LLink Map"] + Node -->|"this.graph.getNodeById"| Node + Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph + Canvas -->|"node.graph.remove(node)"| Graph + Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"] + Widget -->|"usePromotionStore()"| Store2["Pinia Store"] + Node -->|"useLayoutMutations()"| Store3["Layout Store"] + Graph -->|"useLayoutMutations()"| Store3 + LLink["LLink"] -->|"useLayoutMutations()"| Store3 + + style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 +``` + +### After: Unidirectional Data Flow + +```mermaid +graph TD + subgraph Systems["Systems"] + RS["RenderSystem"] + CS["ConnectivitySystem"] + LS["LayoutSystem"] + ES["ExecutionSystem"] + SS["SerializationSystem"] + VS["VersionSystem"] + end + + World["World +(instance-scoped source of truth)"] + + subgraph Components["Component Stores"] + Pos["Position"] + Vis["*Visual"] + Con["Connectivity"] + Val["*Value"] + end + + Systems -->|"query/mutate"| World + World -->|"contains"| Components + + style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0 + style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0 +``` + +Key differences: + +- **No circular dependencies**: entities are IDs, not class instances +- **No Demeter violations**: systems query the World directly, never reach through entities +- **No scattered store access**: the World _is_ the store; systems are the only writers +- **Unidirectional**: Input → Systems → World → Render (no back-edges) +- **Instance safety**: linked definitions can be reused without forcing shared + mutable widget/execution state across instances + +## 5. Problem Resolution Map + +How each problem from [entity-problems.md](entity-problems.md) is resolved: + +```mermaid +graph LR + subgraph Problems["Current Problems"] + P1["God Objects +(9k+ line classes)"] + P2["Circular Deps +(LGraph ↔ Subgraph)"] + P3["Mixed Concerns +(render + domain + state)"] + P4["Inconsistent IDs +(number|string, no safety)"] + P5["Demeter Violations +(graph._links, graph._version++)"] + P6["Scattered Side Effects +(15+ _version++ sites)"] + P7["Render-Time Mutations +(arrange() during draw)"] + end + + subgraph Solutions["ECS Solutions"] + S1["Components: small, focused +data objects (5-10 fields each)"] + S2["Entities are just IDs. +No inheritance hierarchy. +Subgraph = node + component."] + S3["One system per concern. +Systems don't overlap."] + S4["Branded per-kind IDs. +Compile-time type errors."] + S5["Systems query World. +No entity→entity refs."] + S6["VersionSystem owns +all change tracking."] + S7["LayoutSystem runs in +update phase, before render. +RenderSystem is read-only."] + end + + P1 --> S1 + P2 --> S2 + P3 --> S3 + P4 --> S4 + P5 --> S5 + P6 --> S6 + P7 --> S7 + + style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 +``` + +## 6. Migration Bridge + +The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync. + +```mermaid +sequenceDiagram + participant Legacy as Legacy Code + participant Class as LGraphNode (class) + participant Bridge as Bridge Adapter + participant World as World (ECS) + participant New as New Code / Systems + + Note over Legacy,New: Phase 1: Bridge reads from class, writes to World + + Legacy->>Class: node.pos = [100, 200] + Class->>Bridge: pos setter intercepted + Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] }) + + New->>World: world.getComponent(nodeId, Position) + World-->>New: { pos: [100, 200], size: [...] } + + Note over Legacy,New: Phase 2: New features build on ECS directly + + New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] }) + World->>Bridge: change detected + Bridge->>Class: node._pos = [150, 250] + Legacy->>Class: node.pos + Class-->>Legacy: [150, 250] + + Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed + + New->>World: world.getComponent(nodeId, Position) + World-->>New: { pos: [150, 250] } +``` + +### Incremental layout/render separation + +Layout extraction is staged by node family, not all-at-once: + +1. Mark `arrange()` as deprecated in render paths and collect call-site + telemetry. +2. Run `LayoutSystem` during update for a selected node family behind a feature + gate. +3. Keep a temporary compatibility fallback for un-migrated node families only. +4. Remove the fallback once parity tests and frame-time budgets pass. + +This keeps `RenderSystem` read-only for migrated families while preserving +incremental rollout safety. + +### Migration Phases + +```mermaid +graph LR + subgraph Phase1["Phase 1: Types Only"] + T1["Define branded IDs"] + T2["Define component interfaces"] + T3["Define World type"] + end + + subgraph Phase2["Phase 2: Bridge"] + B1["Bridge adapters +class ↔ World sync"] + B2["New features use +World as source"] + B3["Old code unchanged"] + end + + subgraph Phase3["Phase 3: Extract"] + E1["Migrate one component +at a time"] + E2["Deprecate class +properties"] + E3["Systems replace +methods"] + end + + subgraph Phase4["Phase 4: Clean"] + C1["Remove bridge"] + C2["Remove legacy classes"] + C3["Systems are sole +behavior layer"] + end + + Phase1 --> Phase2 --> Phase3 --> Phase4 + + style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0 + style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0 + style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0 + style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 +``` + +This diagram is intentionally high level. The operational Phase 4 -> 5 entry +criteria (compatibility matrix, bridge fallback usage, rollback requirements) +are defined in [ecs-migration-plan.md](ecs-migration-plan.md). diff --git a/docs/architecture/ecs-world-command-api.md b/docs/architecture/ecs-world-command-api.md new file mode 100644 index 0000000000..f4bf822f7d --- /dev/null +++ b/docs/architecture/ecs-world-command-api.md @@ -0,0 +1,349 @@ +# World API and Command Layer + +How the ECS World's imperative API relates to ADR 0003's command pattern +requirement, and why the two are complementary rather than conflicting. + +This document responds to the concern that `world.setComponent()` and +`ConnectivitySystem.connect()` are "imperative mutators" incompatible with +serializable, idempotent commands. The short answer: they are the +**implementation** of commands, not a replacement for them. + +## Architectural Layering + +``` +Caller → Command → System (handler) → World (store) → Y.js (sync) + ↓ + Command Log (undo, replay, sync) +``` + +- **Commands** describe intent. They are serializable, deterministic, and + idempotent. +- **Systems** are command handlers. They validate, execute, and emit lifecycle + events. +- **The World** is the store. It holds component data. It does not know about + commands. + +This is the same relationship Redux has between actions, reducers, and the +store. The store's `dispatch()` is imperative. That does not make Redux +incompatible with serializable actions. + +## Proposed World Mutation API + +The World exposes a thin imperative surface. Every mutation goes through a +system, and every system call is invoked by a command. + +### World Core API + +```ts +interface World { + // Reads (no command needed) + getComponent(id: EntityId, key: ComponentKey): C | undefined + hasComponent(id: EntityId, key: ComponentKey): boolean + queryAll(...keys: C): QueryResult[] + + // Mutations (called only by systems, inside transactions) + createEntity(kind: K): EntityIdFor + deleteEntity(kind: K, id: EntityIdFor): void + setComponent(id: EntityId, key: ComponentKey, data: C): void + removeComponent(id: EntityId, key: ComponentKey): void + + // Transaction boundary + transaction(label: string, fn: () => T): T +} +``` + +These methods are **internal**. External callers never call +`world.setComponent()` directly — they submit commands. + +### Command Interface + +```ts +interface Command { + readonly type: string + execute(world: World): T +} +``` + +A command is a plain object with a `type` discriminator and an `execute` +method that receives the World. The command executor wraps every +`execute()` call in a World transaction. + +### Command Executor + +```ts +interface CommandExecutor { + run(command: Command): T + batch(label: string, commands: Command[]): void +} + +function createCommandExecutor(world: World): CommandExecutor { + return { + run(command) { + return world.transaction(command.type, () => command.execute(world)) + }, + batch(label, commands) { + world.transaction(label, () => { + for (const cmd of commands) cmd.execute(world) + }) + } + } +} +``` + +Every command execution: + +1. Opens a World transaction (maps to one `beforeChange`/`afterChange` + bracket for undo). +2. Calls the command's `execute()`, which invokes system functions. +3. Commits the transaction. On failure, rolls back — no partial writes, no + lifecycle events, no version bump. + +## From Imperative Calls to Commands + +The lifecycle scenarios in +[ecs-lifecycle-scenarios.md](ecs-lifecycle-scenarios.md) show system calls +like `ConnectivitySystem.connect(world, outputSlotId, inputSlotId)`. These +are the **internals** of a command. Here is how each scenario maps: + +### Connect Slots + +The lifecycle scenario shows: + +```ts +// Inside ConnectivitySystem — this is the handler, not the public API +ConnectivitySystem.connect(world, outputSlotId, inputSlotId) +``` + +The public API is a command: + +```ts +const connectSlots: Command = { + type: 'ConnectSlots', + outputSlotId, + inputSlotId, + + execute(world) { + ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId) + } +} + +executor.run(connectSlots) +``` + +The command object is serializable (`{ type, outputSlotId, inputSlotId }`). +It can be sent over a wire, stored in a log, or replayed. + +### Move Node + +```ts +const moveNode: Command = { + type: 'MoveNode', + nodeId, + pos: [150, 250], + + execute(world) { + LayoutSystem.moveNode(world, this.nodeId, this.pos) + } +} +``` + +### Remove Node + +```ts +const removeNode: Command = { + type: 'RemoveNode', + nodeId, + + execute(world) { + ConnectivitySystem.removeNode(world, this.nodeId) + } +} +``` + +### Set Widget Value + +```ts +const setWidgetValue: Command = { + type: 'SetWidgetValue', + widgetId, + value, + + execute(world) { + world.setComponent(this.widgetId, WidgetValue, { + ...world.getComponent(this.widgetId, WidgetValue)!, + value: this.value + }) + } +} +``` + +### Batch: Paste + +Paste is a compound operation — many entities created in one undo step: + +```ts +const paste: Command = { + type: 'Paste', + snapshot, + offset, + + execute(world) { + const remap = new Map() + + for (const entity of this.snapshot.entities) { + const newId = world.createEntity(entity.kind) + remap.set(entity.id, newId) + + for (const [key, data] of entity.components) { + world.setComponent(newId, key, remapEntityRefs(data, remap)) + } + } + + // Offset positions + for (const [, newId] of remap) { + const pos = world.getComponent(newId, Position) + if (pos) { + world.setComponent(newId, Position, { + ...pos, + pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]] + }) + } + } + } +} + +executor.run(paste) // one transaction, one undo step +``` + +## Addressing the Six Concerns + +The PR review raised six "critical conflicts." Here is how the command layer +resolves each: + +### 1. "The World API is imperative, not command-based" + +Correct — by design. The World is the store. Commands are the public +mutation API above it. `world.setComponent()` is to commands what +`state[key] = value` is to Redux reducers. + +### 2. "Systems are orchestrators, not command producers" + +Systems are command **handlers**. A command's `execute()` calls system +functions. Systems do not spontaneously mutate the World — they are invoked +by commands. + +### 3. "Auto-incrementing IDs are non-stable in concurrent environments" + +For local-only operations, auto-increment is fine. For CRDT sync, entity +creation goes through a CRDT-aware ID generator (Y.js provides this via +`doc.clientID` + logical clock). The command layer can select the ID +strategy: + +```ts +// Local-only command +world.createEntity(kind) // auto-increment + +// CRDT-aware command (future) +world.createEntityWithId(kind, crdtGeneratedId) +``` + +This is an ID generation concern, not an ECS architecture concern. + +### 4. "No transaction primitive exists" + +`world.transaction(label, fn)` is the primitive. It maps to one +`beforeChange`/`afterChange` bracket. The command executor wraps every +`execute()` call in a transaction. See the [migration plan's Phase 3→4 +gate](ecs-migration-plan.md#phase-3---4-gate-required) for the acceptance +criteria. + +### 5. "No idempotency guarantees" + +Idempotency is a property of the command, not the store. Two strategies: + +- **Content-addressed IDs**: The command specifies the entity ID rather than + auto-generating. Replaying the command with the same ID is a no-op if the + entity already exists. +- **Command deduplication**: The command log tracks applied command IDs. + Replaying an already-applied command is skipped. + +Both are standard CRDT patterns and belong in the command executor, not the +World. + +### 6. "No error semantics" + +Commands return results. The executor can wrap execution: + +```ts +type CommandResult = + | { status: 'applied'; value: T } + | { status: 'rejected'; reason: string } + | { status: 'no-op' } + +function run(command: Command): CommandResult { + try { + const value = world.transaction(command.type, () => command.execute(world)) + return { status: 'applied', value } + } catch (e) { + if (e instanceof RejectionError) { + return { status: 'rejected', reason: e.message } + } + throw e + } +} +``` + +Rejection semantics (e.g., `onConnectInput` returning false) throw a +`RejectionError` inside the system, which the transaction rolls back. + +## Why Two ADRs + +ADR 0003 defines the command pattern and CRDT sync layer. +ADR 0008 defines the entity data model. + +They are **complementary architectural layers**, not competing proposals: + +| Concern | Owns It | +| ------------------------- | -------- | +| Entity taxonomy and IDs | ADR 0008 | +| Component decomposition | ADR 0008 | +| World (store) | ADR 0008 | +| Command interface | ADR 0003 | +| Undo/redo via command log | ADR 0003 | +| CRDT sync | ADR 0003 | +| Serialization format | ADR 0008 | +| Replay and idempotency | ADR 0003 | + +Merging them into a single mega-ADR would conflate the data model with the +mutation strategy. Keeping them separate allows each to evolve independently +— the World can change its internal representation without affecting the +command API, and the command layer can adopt new sync strategies without +restructuring the entity model. + +## Relationship to Lifecycle Scenarios + +The [lifecycle scenarios](ecs-lifecycle-scenarios.md) show system-level +calls (`ConnectivitySystem.connect()`, `ClipboardSystem.paste()`, etc.). +These are the **inside** of a command — what the command handler does when +the command is executed. + +The scenarios deliberately omit the command layer to focus on how systems +interact with the World. Adding command wrappers is mechanical: every +system call shown in the scenarios becomes the body of a command's +`execute()` method. + +## When This Gets Built + +The command layer is not part of the initial ECS migration phases (0–3). +During Phases 0–3, the bridge layer provides mutation entry points that +will later become command handlers. The command layer is introduced in +Phase 4 when write paths migrate from legacy to ECS: + +- **Phase 4a**: Position write commands replace direct `node.pos =` assignment +- **Phase 4b**: Connectivity commands replace `node.connect()` / + `node.disconnect()` +- **Phase 4c**: Widget value commands replace direct store writes + +Each Phase 4 step introduces commands for one concern, with the system +function as the handler and the World transaction as the atomicity +boundary. diff --git a/docs/architecture/entity-interactions.md b/docs/architecture/entity-interactions.md new file mode 100644 index 0000000000..c0fcd6c32a --- /dev/null +++ b/docs/architecture/entity-interactions.md @@ -0,0 +1,441 @@ +# Entity Interactions (Current System) + +This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md). + +## Entities + +| Entity | Class | ID Type | Primary Location | +| -------- | ------------- | --------------- | ---------------------------------------------------------------------------- | +| Graph | `LGraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` | +| Node | `LGraphNode` | `NodeId` | `src/lib/litegraph/src/LGraphNode.ts` | +| Link | `LLink` | `LinkId` | `src/lib/litegraph/src/LLink.ts` | +| Subgraph | `Subgraph` | `UUID` | `src/lib/litegraph/src/LGraph.ts` (ECS: node component, not separate entity) | +| Widget | `BaseWidget` | name + nodeId | `src/lib/litegraph/src/widgets/BaseWidget.ts` | +| Slot | `SlotBase` | index on parent | `src/lib/litegraph/src/node/SlotBase.ts` | +| Reroute | `Reroute` | `RerouteId` | `src/lib/litegraph/src/Reroute.ts` | +| Group | `LGraphGroup` | `number` | `src/lib/litegraph/src/LGraphGroup.ts` | + +Under the ECS model, subgraphs are not a separate entity kind — they are nodes with `SubgraphStructure` and `SubgraphMeta` components. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md). + +## 1. Overview + +High-level ownership and reference relationships between all entities. + +```mermaid +graph TD + subgraph Legend + direction LR + L1[A] -->|owns| L2[B] + L3[C] -.->|references| L4[D] + L5[E] ==>|extends| L6[F] + end + + Graph["LGraph +(UUID)"] + Node["LGraphNode +(NodeId)"] + SubgraphEntity["Subgraph +(UUID)"] + SubgraphNode["SubgraphNode"] + Link["LLink +(LinkId)"] + Widget["BaseWidget +(name)"] + Slot["SlotBase +(index)"] + Reroute["Reroute +(RerouteId)"] + Group["LGraphGroup +(number)"] + Canvas["LGraphCanvas"] + + %% Ownership (solid) + Graph -->|"_nodes[]"| Node + Graph -->|"_links Map"| Link + Graph -->|"reroutes Map"| Reroute + Graph -->|"_groups[]"| Group + Graph -->|"_subgraphs Map"| SubgraphEntity + Node -->|"inputs[], outputs[]"| Slot + Node -->|"widgets[]"| Widget + + %% Extends (thick) + SubgraphEntity ==>|extends| Graph + SubgraphNode ==>|extends| Node + + %% References (dashed) + Link -.->|"origin_id, target_id"| Node + Link -.->|"parentId"| Reroute + Slot -.->|"link / links[]"| Link + Reroute -.->|"linkIds"| Link + Reroute -.->|"parentId"| Reroute + Group -.->|"_children Set"| Node + Group -.->|"_children Set"| Reroute + SubgraphNode -.->|"subgraph"| SubgraphEntity + Node -.->|"graph"| Graph + Canvas -.->|"graph"| Graph + Canvas -.->|"selectedItems"| Node + Canvas -.->|"selectedItems"| Group + Canvas -.->|"selectedItems"| Reroute +``` + +## 2. Connectivity + +How Nodes, Slots, Links, and Reroutes form the graph topology. + +```mermaid +graph LR + subgraph OutputNode["Origin Node"] + OSlot["Output Slot +links: LinkId[]"] + end + + subgraph InputNode["Target Node"] + ISlot["Input Slot +link: LinkId | null"] + end + + OSlot -->|"LinkId ref"| Link["LLink +origin_id + origin_slot +target_id + target_slot +type: ISlotType"] + Link -->|"LinkId ref"| ISlot + + Link -.->|"parentId"| R1["Reroute A"] + R1 -.->|"parentId"| R2["Reroute B"] + + R1 -.-|"linkIds Set"| Link + R2 -.-|"linkIds Set"| Link +``` + +### Subgraph Boundary Connections + +```mermaid +graph TD + subgraph ParentGraph["Parent Graph"] + ExtNode["External Node"] + SGNode["SubgraphNode +(in parent graph)"] + end + + subgraph SubgraphDef["Subgraph"] + SInput["SubgraphInput"] + SInputNode["SubgraphInputNode +(virtual)"] + InternalNode["Internal Node"] + SOutputNode["SubgraphOutputNode +(virtual)"] + SOutput["SubgraphOutput"] + end + + ExtNode -->|"Link (parent graph)"| SGNode + SGNode -.->|"maps to"| SInput + SInput -->|"owns"| SInputNode + SInputNode -->|"Link (subgraph)"| InternalNode + InternalNode -->|"Link (subgraph)"| SOutputNode + SOutputNode -->|"owned by"| SOutput + SOutput -.->|"maps to"| SGNode + SGNode -->|"Link (parent graph)"| ExtNode +``` + +### Floating Links (In-Progress Connections) + +```mermaid +graph LR + Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink +origin_id=-1 or target_id=-1"] + FL -->|"stored in"| FLMap["graph.floatingLinks Map"] + FL -.->|"may pass through"| Reroute + Reroute -.-|"floatingLinkIds Set"| FL + FL -->|"on drop"| Permanent["Permanent LLink +(registered in graph._links)"] +``` + +## 3. Rendering + +How LGraphCanvas draws each entity type. + +```mermaid +graph TD + Canvas["LGraphCanvas +render loop"] + + Canvas -->|"1. background"| DrawGroups["drawGroups()"] + Canvas -->|"2. connections"| DrawConns["drawConnections()"] + Canvas -->|"3. foreground"| DrawNodes["drawNode() per node"] + Canvas -->|"4. in-progress"| DrawLC["LinkConnector.renderLinks"] + + DrawGroups --> Group["group.draw(canvas, ctx)"] + + DrawConns --> LinkSeg["LinkSegment interface"] + LinkSeg --> Link["LLink path rendering"] + LinkSeg --> RerouteRender["Reroute inline rendering +(draw, drawSlots)"] + + DrawNodes --> NodeDraw["node drawing pipeline"] + NodeDraw -->|"drawSlots()"| SlotDraw["slot.draw() per slot"] + NodeDraw -->|"drawWidgets()"| WidgetDraw["widget.drawWidget() per widget"] + NodeDraw -->|"title, badges"| NodeChrome["title bar, buttons, badges"] + + DrawLC --> FloatingViz["Floating link visualization"] +``` + +### Rendering Order Detail + +```mermaid +sequenceDiagram + participant C as Canvas + participant Gr as Groups + participant L as Links/Reroutes + participant N as Nodes + participant S as Slots + participant W as Widgets + + C->>Gr: drawGroups() — background layer + Gr-->>C: group shapes + titles + + C->>L: drawConnections() — middle layer + L-->>C: bezier paths + reroute dots + + loop each node (back to front) + C->>N: drawNode() + N->>N: drawNodeShape() (background, title) + N->>S: drawSlots() (input/output circles) + S-->>N: slot shapes + labels + N->>W: drawWidgets() (if not collapsed) + W-->>N: widget UI elements + N-->>C: complete node + end + + C->>C: overlay (tooltips, debug) +``` + +## 4. Lifecycle + +Creation and destruction flows for each entity. + +### Node Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created: new LGraphNode(title) + Created --> Configured: node.configure(data) + Configured --> InGraph: graph.add(node) + + state InGraph { + [*] --> Active + Active --> Active: connect/disconnect slots + Active --> Active: add/remove widgets + Active --> Active: move, resize, collapse + } + + InGraph --> Removed: graph.remove(node) + Removed --> [*] + + note right of Created + Constructor sets defaults. + No graph reference yet. + end note + + note right of InGraph + node.onAdded(graph) called. + ID assigned from graph.state. + Slots may trigger onConnectionsChange. + end note + + note right of Removed + All links disconnected. + node.onRemoved() called. + Removed from graph._nodes. + end note +``` + +### Link Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created: node.connect() or connectSlots() + Created --> Registered: graph._links.set(id, link) + + state Registered { + [*] --> Active + Active --> Active: data flows through + Active --> Active: reroutes added/removed + } + + Registered --> Disconnected: node.disconnectInput/Output() + Disconnected --> Removed: link.disconnect(network) + Removed --> [*] + + note right of Created + new LLink(id, type, origin, slot, target, slot) + Output slot.links[] updated. + Input slot.link set. + end note + + note right of Removed + Removed from graph._links. + Orphaned reroutes cleaned up. + graph._version incremented. + end note +``` + +### Widget Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created: node.addWidget(type, name, value, options) + Created --> Concrete: toConcreteWidget() + Concrete --> Bound: widget.setNodeId(nodeId) + + state Bound { + [*] --> Active + Active --> Active: setValue() → store + node callback + Active --> Active: draw(), onClick(), onDrag() + } + + Bound --> Removed: node.removeWidget(widget) + Removed --> [*] + + note right of Bound + Registered in WidgetValueStore. + State keyed by graphId:nodeId:name. + Value reads/writes via store. + end note +``` + +### Subgraph Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Created: graph.createSubgraph(data) + + state Created { + [*] --> Defined + Defined: registered in rootGraph._subgraphs + } + + Created --> Instantiated: new SubgraphNode(subgraph) + Instantiated --> InGraph: graph.add(subgraphNode) + + state InGraph { + [*] --> Active + Active --> Active: add/remove inputs/outputs + Active --> Active: promote/demote widgets + Active --> Active: edit internal nodes + } + + InGraph --> Unpacked: graph.unpackSubgraph(node) + Unpacked --> [*] + + InGraph --> NodeRemoved: graph.remove(subgraphNode) + NodeRemoved --> MaybePurged: no other SubgraphNodes reference it? + MaybePurged --> [*] + + note right of Instantiated + SubgraphNode.subgraph = subgraph. + Inputs/outputs synced from subgraph. + end note + + note right of Unpacked + Internal nodes cloned to parent. + Links remapped. SubgraphNode removed. + Subgraph def removed if unreferenced. + end note +``` + +## 5. State Management + +External stores and their relationships to entities. + +```mermaid +graph TD + subgraph Entities + Node["LGraphNode"] + Widget["BaseWidget"] + Reroute["Reroute"] + Link["LLink"] + Graph["LGraph"] + SGNode["SubgraphNode"] + end + + subgraph Stores + WVS["WidgetValueStore +(Pinia)"] + PS["PromotionStore +(Pinia)"] + LM["LayoutMutations +(composable)"] + end + + subgraph GraphState["Graph Internal State"] + Version["graph._version"] + LGState["graph.state +(lastNodeId, lastLinkId, +lastRerouteId, lastGroupId)"] + end + + %% WidgetValueStore + Widget -->|"setNodeId() registers"| WVS + Widget <-->|"value, label, disabled"| WVS + WVS -.->|"keyed by graphId:nodeId:name"| Widget + + %% PromotionStore + SGNode -->|"tracks promoted widgets"| PS + Widget -.->|"isPromotedByAny() query"| PS + + %% LayoutMutations + Node -->|"pos/size setter"| LM + Reroute -->|"move()"| LM + Link -->|"connectSlots()/disconnect()"| LM + Graph -->|"add()/remove()"| LM + + %% Graph state + Node -->|"connect/disconnect"| Version + Widget -->|"setValue()"| Version + Node -->|"collapse/toggleAdvanced"| Version + Graph -->|"add/remove entities"| LGState +``` + +### Change Notification Flow + +```mermaid +sequenceDiagram + participant E as Entity (Node/Widget/Link) + participant G as LGraph + participant C as LGraphCanvas + participant R as Render Loop + + E->>G: graph._version++ + E->>G: graph.beforeChange() (undo checkpoint) + + Note over E,G: ... mutation happens ... + + E->>G: graph.afterChange() (undo checkpoint) + E->>G: graph.change() + G->>C: canvasAction → canvas.setDirty(true, true) + C->>R: dirty flags checked on next frame + R->>C: full redraw +``` + +### Widget State Delegation + +```mermaid +sequenceDiagram + participant N as Node + participant W as Widget + participant S as WidgetValueStore + participant G as Graph + + N->>W: addWidget(type, name, value) + W->>W: toConcreteWidget() + N->>W: setNodeId(nodeId) + W->>S: registerWidget(graphId, state) + S-->>W: state reference stored in widget._state + + Note over W,S: All value access now goes through store + + W->>S: widget.value = newVal (setter) + S-->>S: store.state.value = newVal + W->>N: node.onWidgetChanged?.(name, val) + W->>G: graph._version++ +``` diff --git a/docs/architecture/entity-problems.md b/docs/architecture/entity-problems.md new file mode 100644 index 0000000000..5ea8373428 --- /dev/null +++ b/docs/architecture/entity-problems.md @@ -0,0 +1,214 @@ +# Entity System Structural Problems + +This document catalogs the structural problems in the current litegraph entity system. It provides the concrete "why" behind the ECS migration proposed in [ADR 0008](../adr/0008-entity-component-system.md). For the as-is relationship map, see [Entity Interactions](entity-interactions.md). + +All file references are relative to `src/lib/litegraph/src/`. + +## 1. God Objects + +The three largest classes carry far too many responsibilities: + +| Class | Lines | Responsibilities | +| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `LGraphCanvas` | ~9,100 | Rendering, input handling, selection, link dragging, context menus, clipboard, undo/redo hooks, node layout triggers | +| `LGraphNode` | ~4,300 | Domain model, connectivity, serialization, rendering (slots, widgets, badges, title), layout, execution, property management | +| `LGraph` | ~3,100 | Container management, serialization, canvas notification, subgraph lifecycle, execution ordering, link deduplication | + +`LGraphNode` alone has ~539 method/property definitions. A sampling of the concerns it mixes: + +| Concern | Examples | +| ------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Rendering | `renderingColor` (line 328), `renderingBgColor` (line 335), `drawSlots()`, `drawWidgets()`, `measure(ctx)` (line 2074) | +| Serialization | `serialize()` (line 943), `configure()` (line 831), `toJSON()` (line 1033) | +| Connectivity | `connect()`, `connectSlots()`, `disconnectInput()`, `disconnectOutput()` | +| Execution | `execute()` (line 1418), `triggerSlot()` | +| Layout | `arrange()`, `_arrangeWidgets()`, `computeSize()` | +| State mgmt | `setProperty()`, `onWidgetChanged()`, direct `graph._version++` | + +## 2. Circular Dependencies + +**LGraph ↔ Subgraph**: `Subgraph` extends `LGraph`, but `LGraph` creates and manages `Subgraph` instances. This forces: + +- A barrel export in `litegraph.ts` that re-exports 40+ modules with **order-dependent imports** +- An explicit comment at `litegraph.ts:15`: _"Must remain above LiteGraphGlobal (circular dependency due to abstract factory behaviour in 'configure')"_ +- Test files must use the barrel import (`import { LGraph, Subgraph } from '.../litegraph'`) rather than direct imports, or they break + +The `Subgraph` class is defined inside `LGraph.ts` (line 2761) rather than in its own file — a symptom of the circular dependency being unresolvable with the current class hierarchy. + +## 3. Mixed Concerns + +### Rendering in Domain Objects + +`LGraphNode.measure()` (line 2074) accepts a `CanvasRenderingContext2D` parameter and sets `ctx.font` — a rendering operation embedded in what should be a domain model: + +``` +measure(ctx?: CanvasRenderingContext2D, options?: MeasureOptions): void { + ... + if (ctx) ctx.font = this.innerFontStyle +``` + +### State Mutation During Render + +`LGraphCanvas.drawNode()` (line 5554) mutates node state as a side effect of rendering: + +- Line 5562: `node._setConcreteSlots()` — rebuilds slot arrays +- Line 5564: `node.arrange()` — recalculates widget layout +- Lines 5653-5655: same mutations repeated for a second code path + +This means the render pass is not idempotent — drawing a node changes its state. + +### Store Dependencies in Domain Objects + +`BaseWidget` (line 20-22) imports two Pinia stores at the module level: + +- `usePromotionStore` — queried on every `getOutlineColor()` call +- `useWidgetValueStore` — widget state delegation via `setNodeId()` + +Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores. + +### Serialization Interleaved with Container Logic + +`LGraph.configure()` (line 2400) mixes deserialization, event dispatch, store clearing, and container state setup in a single 180-line method. A change to serialization format risks breaking container lifecycle, and vice versa. + +## 4. Inconsistent ID Systems + +### Ambiguous NodeId + +```ts +export type NodeId = number | string // LGraphNode.ts:100 +``` + +Most nodes use numeric IDs, but subgraph-related nodes use strings. Code must use runtime type guards (`typeof node.id === 'number'` at LGraph.ts:978, LGraphCanvas.ts:9045). This is a source of subtle bugs. + +### Magic Numbers + +```ts +export const SUBGRAPH_INPUT_ID = -10 // constants.ts:8 +export const SUBGRAPH_OUTPUT_ID = -20 // constants.ts:11 +``` + +Negative sentinel values in the ID space. Links check `origin_id === SUBGRAPH_INPUT_ID` to determine if they cross a subgraph boundary — a special case baked into the general-purpose `LLink` class. + +### No Independent Widget or Slot IDs + +**Widgets** are identified by `name + parent node`. Code searches by name in multiple places: + +- `LGraphNode.ts:904` — `this.inputs.find((i) => i.widget?.name === w.name)` +- `LGraphNode.ts:4077` — `slot.widget.name === widget.name` +- `LGraphNode.ts:4086` — `this.widgets?.find((w) => w.name === slot.widget.name)` + +If a widget is renamed, all these lookups silently break. + +**Slots** are identified by their array index on the parent node. The serialized link format (`SerialisedLLinkArray`) stores slot indices: + +```ts +type SerialisedLLinkArray = [ + id, + origin_id, + origin_slot, + target_id, + target_slot, + type +] +``` + +If slots are reordered (e.g., by an extension adding a slot), all links referencing that node become stale. + +### No Cross-Kind ID Safety + +Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008. + +## 5. Law of Demeter Violations + +Entities routinely reach through their container to access internal state and sibling entities. + +### Nodes Reaching Into Graph Internals + +8+ locations in `LGraphNode` access the graph's private `_links` map directly: + +- Line 877: `this.graph._links.get(input.link)` +- Line 891: `this.graph._links.get(linkId)` +- Line 1254: `const link_info = this.graph._links.get(input.link)` + +Nodes also reach through the graph to access sibling nodes' slots: + +- Line 1150: `this.graph.getNodeById(link.origin_id)` → read origin's outputs +- Line 1342: `this.graph.getNodeById(link.target_id)` → read target's inputs +- Line 1556: `node.inputs[link_info.target_slot]` (accessing a sibling's slot by index) + +### Canvas Mutating Graph Internals + +`LGraphCanvas` directly increments the graph's version counter: + +- Line 3084: `node.graph._version++` +- Line 7880: `node.graph._version++` + +The canvas also reaches through nodes to their container: + +- Line 8337: `node.graph.remove(node)` — canvas deletes a node by reaching through the node to its graph + +### Entities Mutating Container State + +`LGraphNode` directly mutates `graph._version++` from 8+ locations (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567). There is no encapsulated method for signaling a version change — every call site manually increments the counter. + +## 6. Scattered Side Effects + +### Version Counter + +`graph._version` is incremented from **15+ locations** across three files: + +| File | Locations | +| ----------------- | --------------------------------------------------- | +| `LGraph.ts` | Lines 956, 989, 1042, 1109, 2643 | +| `LGraphNode.ts` | Lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567 | +| `LGraphCanvas.ts` | Lines 3084, 7880 | + +No central mechanism exists. It's easy to forget an increment (stale render) or add a redundant one (wasted work). + +### Module-Scope Store Access + +Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime: + +- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope) +- `Reroute.ts` — same pattern at module scope +- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore` + +These make the domain objects untestable without a Vue app context. + +### Change Notification Sprawl + +`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from +**12+ locations** in `LGraphCanvas` alone (lines 1574, 1592, 1604, 1620, 1752, +1770, 8754, 8760, 8771, 8777, 8803, 8811). These calls are grouping brackets: +misplaced or missing pairs can split one logical operation across multiple undo +entries, while unmatched extra calls can delay checkpoint emission until the +nesting counter returns to zero. + +## 7. Render-Time Mutations + +The render pass is not pure — it mutates state as a side effect: + +| Location | Mutation | +| ----------------------------------- | ------------------------------------------------------------------- | +| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays | +| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes | +| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame | +| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance | + +This means: + +- Rendering order matters (later nodes see side effects from earlier nodes) +- Performance profiling conflates render cost with layout cost +- Concurrent or partial renders would produce inconsistent state + +## How ECS Addresses These Problems + +| Problem | ECS Solution | +| ---------------------- | ----------------------------------------------------------------------------- | +| God objects | Data split into small, focused components; behavior lives in systems | +| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy | +| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) | +| Inconsistent IDs | Branded per-kind IDs with compile-time safety | +| Demeter violations | Systems query the World directly; no entity-to-entity references | +| Scattered side effects | Version tracking becomes a system responsibility; stores become systems | +| Render-time mutations | Render system reads components without writing; layout system runs separately | diff --git a/docs/architecture/proto-ecs-stores.md b/docs/architecture/proto-ecs-stores.md new file mode 100644 index 0000000000..0831f0535d --- /dev/null +++ b/docs/architecture/proto-ecs-stores.md @@ -0,0 +1,376 @@ +# Proto-ECS: Existing State Extraction + +The codebase has already begun extracting entity state into external Pinia stores — an organic, partial migration toward the ECS principles described in [ADR 0008](../adr/0008-entity-component-system.md). This document catalogs those stores, analyzes how they align with the ECS target, and identifies what remains to be extracted. + +For the full problem analysis, see [Entity Problems](entity-problems.md). For the ECS target, see [ECS Target Architecture](ecs-target-architecture.md). + +## 1. What's Already Extracted + +Six stores extract entity state out of class instances into centralized, queryable registries: + +| Store | Extracts From | Scoping | Key Format | Data Shape | +| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- | +| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object | +| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries | +| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index | +| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) | +| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs | +| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache | + +## 2. WidgetValueStore + +**File:** `src/stores/widgetValueStore.ts` + +The closest thing to a true ECS component store in the codebase today. + +### State Shape + +``` +Map> + │ │ │ + graphId "nodeId:name" pure data object +``` + +`WidgetState` is a plain data object with no methods: + +| Field | Type | Purpose | +| ----------- | ---------------- | ------------------------------------------ | +| `nodeId` | `NodeId` | Owning node | +| `name` | `string` | Widget name | +| `type` | `string` | Widget type (e.g., `'number'`, `'toggle'`) | +| `value` | `TWidgetValue` | Current value | +| `label` | `string?` | Display label | +| `disabled` | `boolean?` | Disabled state | +| `serialize` | `boolean?` | Whether to include in workflow JSON | +| `options` | `IWidgetOptions` | Configuration | + +### Two-Phase Delegation + +**Phase 1 — Construction:** Widget creates a local `_state` object with initial values. + +**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object: + +``` +widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId }) +``` + +After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference. + +### What's Extracted vs What Remains + +```mermaid +graph LR + subgraph Extracted["Extracted to Store"] + style Extracted fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + V["value"] + L["label"] + D["disabled"] + S["serialize"] + O["options (ref)"] + end + + subgraph Remains["Remains on Class"] + style Remains fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + Node["_node (back-ref)"] + Draw["drawWidget(), drawWidgetShape()"] + Events["onClick(), onDrag(), onPointerDown()"] + Layout["y, computedHeight, width"] + CB["callback, linkedWidgets"] + DOM["element (DOM widgets)"] + end + + BW["BaseWidget"] --> Extracted + BW --> Remains +``` + +### ECS Alignment + +| Aspect | ECS-like | Why | +| --------------------------- | -------- | ------------------------------------------------- | +| `WidgetState` is plain data | Yes | No methods, serializable, reactive | +| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` cleans up | +| Query API | Yes | `getWidget()`, `getNodeWidgets()` | +| Cross-subgraph sync | Yes | Same nodeId:name shares state across depths | +| Back-reference (`_node`) | **No** | Widget still holds owning node ref | +| Behavior on class | **No** | Drawing, events, callbacks still on widget | +| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object | + +## 3. PromotionStore + +**File:** `src/stores/promotionStore.ts` + +Extracts subgraph widget promotion decisions into a centralized, ref-counted registry. + +### State Shape + +``` +graphPromotions: Map> + │ │ │ + graphId subgraphNodeId ordered promotion entries + +graphRefCounts: Map> + │ │ │ + graphId entryKey count of nodes promoting this widget +``` + +### Ref-Counting for O(1) Queries + +The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables: + +```ts +isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean +// O(1) lookup: refCounts.get(key) > 0 +``` + +Without ref counting, this query would require scanning all SubgraphNodes in the graph. + +### View Reconciliation Layer + +`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI: + +```mermaid +graph LR + PS["PromotionStore +(data)"] -->|"entries"| VM["PromotedWidgetViewManager +(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView +(proxy widget)"] + PV -->|"resolveDeepest()"| CW["Concrete Widget +(leaf node)"] + PV -->|"reads value"| WVS["WidgetValueStore"] +``` + +The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing. + +### ECS Alignment + +| Aspect | ECS-like | Why | +| ---------------------------------- | --------- | ----------------------------------------------------------------------- | +| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies | +| Ref-counted queries | Yes | Efficient global state queries without scanning | +| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` | +| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode | +| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly | +| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame | + +## 4. LayoutStore (CRDT) + +**File:** `src/renderer/core/layout/store/layoutStore.ts` + +The most architecturally advanced extraction — uses Y.js CRDTs for collaboration-ready position state. + +### State Shape + +``` +ynodes: Y.Map // nodeId → { pos, size, zIndex, bounds } +ylinks: Y.Map> // linkId → link layout data +yreroutes: Y.Map> // rerouteId → reroute layout data +``` + +### Write API + +`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API: + +- `moveNode(graphId, nodeId, pos)` +- `resizeNode(graphId, nodeId, size)` +- `setNodeZIndex(graphId, nodeId, zIndex)` +- `createLink(graphId, linkId, ...)` +- `removeLink(graphId, linkId)` +- `moveReroute(graphId, rerouteId, pos)` + +### The Scattered Access Problem + +This composable is called at **module scope** in domain objects: + +- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` +- `Reroute.ts` — same pattern +- `LGraphNode.ts` — imported and called in methods + +These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context. + +### ECS Alignment + +| Aspect | ECS-like | Why | +| ---------------------------- | --------- | --------------------------------------------------- | +| Position data extracted | Yes | Closest to the ECS `Position` component | +| CRDT-ready | Yes | Enables collaboration (ADR 0003) | +| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store | +| Mutation API (composable) | Partially | System-like, but called from entities, not a system | +| Module-scope access | **No** | Domain objects import store at module level | +| No entity ID branding | **No** | Plain numbers, no type safety across kinds | + +## 5. Pattern Analysis + +### What These Stores Have in Common (Proto-ECS) + +1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data +2. **Centralized registries**: Each store is a `Map` — structurally identical to an ECS component store +3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore) +4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries +5. **Separation of data from behavior**: The stores hold data; classes retain behavior + +### What's Missing vs Full ECS + +```mermaid +graph TD + subgraph Have["What We Have"] + style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + H1["Centralized data stores"] + H2["Plain data components +(WidgetState, LayoutMap)"] + H3["Query APIs +(getWidget, isPromotedByAny)"] + H4["Graph-scoped lifecycle"] + H5["Partial position extraction +(LayoutStore)"] + end + + subgraph Missing["What's Missing"] + style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + M1["Unified World +(6 stores, 6 keying strategies)"] + M2["Branded entity IDs +(keys are string concatenations)"] + M3["System layer +(mutations from anywhere)"] + M4["Complete extraction +(behavior still on classes)"] + M5["No entity-to-entity refs +(back-refs remain)"] + M6["Render/update separation +(stores queried during render)"] + end +``` + +### Keying Strategy Comparison + +Each store invents its own identity scheme: + +| Store | Key Format | Entity ID Used | Type-Safe? | +| ---------------- | --------------------------------- | ----------------------- | ---------- | +| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No | +| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No | +| DomWidgetStore | Widget UUID | UUID (string) | No | +| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No | +| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No | + +In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection. + +## 6. Extraction Map + +Current state of extraction for each entity kind: + +```mermaid +graph TD + subgraph Node["LGraphNode"] + N_ext["Extracted: +- pos, size → LayoutStore +- zIndex → LayoutStore"] + N_rem["Remains on class: +- type, category, nodeData +- color, bgcolor, boxcolor +- inputs[], outputs[] +- widgets[] +- properties +- order, mode, flags +- serialize(), configure() +- drawSlots(), drawWidgets() +- connect(), disconnect()"] + end + + subgraph Widget["BaseWidget"] + W_ext["Extracted: +- value → WidgetValueStore +- label → WidgetValueStore +- disabled → WidgetValueStore +- promotion status → PromotionStore +- DOM pos/vis → DomWidgetStore"] + W_rem["Remains on class: +- _node back-ref +- drawWidget() +- onClick(), onDrag() +- computedHeight +- callback, linkedWidgets"] + end + + subgraph Link["LLink"] + L_ext["Extracted: +- layout data → LayoutStore"] + L_rem["Remains on class: +- origin_id, target_id +- origin_slot, target_slot +- type, color, path +- data, _dragging +- disconnect(), resolve()"] + end + + subgraph Reroute["Reroute"] + R_ext["Extracted: +- pos → LayoutStore"] + R_rem["Remains on class: +- parentId, linkIds +- floatingLinkIds +- color, draw() +- findSourceOutput()"] + end + + subgraph Group["LGraphGroup"] + G_ext["Extracted: +(nothing)"] + G_rem["Remains on class: +- pos, size, bounding +- title, font, color +- _children, _nodes +- draw(), move() +- recomputeInsideNodes()"] + end + + subgraph Subgraph["Subgraph (node component)"] + S_ext["Extracted: +- promotions → PromotionStore"] + S_rem["Remains on class: +- name, description +- inputs[], outputs[] +- inputNode, outputNode +- All LGraph state"] + end + + style N_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + style W_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + style L_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + style R_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + style G_ext fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style S_ext fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0 + + style N_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style W_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style L_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style R_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style G_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 + style S_rem fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0 +``` + +## 7. Migration Gap Analysis + +What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md): + +| Entity | Already Extracted | Still on Class | ECS Target Components | Gap | +| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class | +| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted | +| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain | +| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started | +| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted | +| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started | +| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind | + +### Priority Order for Extraction + +Based on existing progress and problem severity: + +1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction) +2. **Node Position** — already in LayoutStore, needs branded ID and formal component type +3. **Link** — small component set, high coupling pain +4. **Slot** — no extraction yet, but small and self-contained +5. **Reroute** — partially extracted, moderate complexity +6. **Group** — no extraction, but least coupled to other entities +7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) diff --git a/docs/architecture/subgraph-boundaries-and-promotion.md b/docs/architecture/subgraph-boundaries-and-promotion.md new file mode 100644 index 0000000000..51d7b8b941 --- /dev/null +++ b/docs/architecture/subgraph-boundaries-and-promotion.md @@ -0,0 +1,574 @@ +# Subgraph Boundaries and Widget Promotion + +A companion to [ADR 0008](../adr/0008-entity-component-system.md). Where the ADR +defines the entity taxonomy and component decomposition, this document examines +the three questions the ADR defers — questions that turn out to be facets of a +single deeper insight. + +For the structural problems motivating this work, see +[Entity Problems](entity-problems.md). For the target architecture, see +[ECS Target Architecture](ecs-target-architecture.md). For the phased migration +roadmap, see [ECS Migration Plan](ecs-migration-plan.md). + +--- + +## 1. Graph Model Unification + +### The false distinction + +Consider a subgraph. It contains nodes, links, reroutes, groups. It has inputs +and outputs. It can be serialized, deserialized, copied, pasted. It has an +execution order. It has a version counter. + +Now consider a graph. It contains nodes, links, reroutes, groups. It has inputs +and outputs (to the execution backend). It can be serialized, deserialized, +copied, pasted. It has an execution order. It has a version counter. + +These are the same thing. + +The current codebase almost knows this. `Subgraph extends LGraph` — the +inheritance hierarchy encodes the identity. But it encodes it as a special case +of a general case, when in truth there is no special case. A subgraph is not a +_kind_ of graph. A subgraph _is_ a graph. The root workflow is not a privileged +container — it is simply a graph that happens to have no parent. + +This is the pattern that appears everywhere in nature and mathematics: the +Mandelbrot set, the branching of rivers, the structure of lungs. At every scale, +the same shape. A graph that contains a node that contains a graph that contains +a node that contains a graph. The part is isomorphic to the whole. + +### What the code says + +Three symptoms reveal the false distinction: + +1. **`Subgraph` lives inside `LGraph.ts`** (line 2761). It cannot be extracted + to its own file because the circular dependency between `Subgraph` and + `LGraph` is unresolvable under the current inheritance model. The + architecture is telling us, in the only language it has, that these two + classes want to be one thing. + +2. **`Subgraph` overrides `state` to delegate to `rootGraph.state`** (line + 2790). A subgraph does not own its own ID counters — it borrows them from the + root. It is not independent. It never was. + +3. **Execution flattens the hierarchy anyway.** `SubgraphNode.getInnerNodes()` + dissolves the nesting boundary to produce a flat execution order. The runtime + already treats the hierarchy as transparent. Only the data model pretends + otherwise. + +### The unified model + +```mermaid +graph TD + subgraph Current["Current: Inheritance Hierarchy"] + direction TB + LG["LGraph (base)"] + SG["Subgraph (extends LGraph)"] + LGN["LGraphNode (base)"] + SGN["SubgraphNode (extends LGraphNode)"] + LG -.->|"_subgraphs Map"| SG + SG ==>|"extends"| LG + SGN ==>|"extends"| LGN + SGN -.->|".subgraph"| SG + end + + subgraph Unified["Unified: Composition"] + direction TB + W["World (flat)"] + N1["Node A + graphScope: root"] + N2["Node B (subgraph carrier) + graphScope: root + + SubgraphStructure component"] + N3["Node C + graphScope: graph-2"] + N4["Node D + graphScope: graph-2"] + W --- N1 + W --- N2 + W --- N3 + W --- N4 + N2 -.->|"SubgraphStructure.graphId"| GS2["graph-2"] + end + + style Current fill:#2a1a1a,stroke:#4a2a2a,color:#e0e0e0 + style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0 +``` + +In the ECS World: + +- **Every graph is a graph.** The "root" graph is simply the one whose + `graphScope` has no parent. +- **Nesting is a component, not a type.** A node can carry a + `SubgraphStructure` component, which references another graph scope. That + scope contains its own entities — nodes, links, widgets, reroutes, groups — + all living in the same flat World. +- **One World per workflow.** All entities across all nesting levels coexist in + a single World, each tagged with a `graphScope` identifier. There are no + sub-worlds, no recursive containers. The fractal structure is encoded in the + data, not in the container hierarchy. +- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds + including `SubgraphEntityId`. Under unification, "subgraph" is not an entity + kind — it is a node with a component. The taxonomy becomes: Node, Link, + Widget, Slot, Reroute, Group. +- **ID counters remain global.** All entity IDs are allocated from a single + counter space, shared across all nesting levels. This preserves the current + `rootGraph.state` behavior and guarantees ID uniqueness across the entire + World. +- **Graph scope parentage is tracked.** The World maintains a scope registry: + each `graphId` maps to its parent `graphId` (or null for the root). This + enables the ancestor walk required by the acyclicity invariant and supports + queries like "all entities transitively contained by this graph." + +### The acyclicity invariant + +Self-similarity is beautiful, but recursion without a base case is catastrophe. +A subgraph node must not contain a graph that contains itself, directly or +through any chain of nesting. + +The current code handles this with a blunt instrument: +`Subgraph.MAX_NESTED_SUBGRAPHS = 1000`. This limits depth but does not prevent +cycles. A graph that references itself at depth 1 is just as broken as one that +does so at depth 1001. + +The proper invariant is structural: + +> The `graphScope` reference graph induced by `SubgraphStructure.graphId` must +> form a directed acyclic graph (DAG). Before creating or modifying a +> `SubgraphStructure`, the system must verify that the target `graphId` is not +> an ancestor of the containing graph's scope. + +This is a simple ancestor walk — from the proposed target graph, follow parent +references upward. If the containing graph appears in the chain, the operation +is rejected. The check runs on every mutation that creates or modifies a +`SubgraphStructure` component, not as an offline validation pass. A cycle in the +live graph is unrecoverable; prevention must be synchronous. + +--- + +## 2. Graph Boundary Model + +### How boundaries work today + +When a link crosses from a parent graph into a subgraph, the current system +interposes three layers of indirection: + +```mermaid +graph LR + subgraph Parent["Parent Graph"] + EN["External Node"] + SGN["SubgraphNode"] + end + + subgraph Magic["Boundary Infrastructure"] + SI["SubgraphInput + (slot-like object)"] + SIN["SubgraphInputNode + (virtual node, ID = -10)"] + end + + subgraph Interior["Subgraph Interior"] + IN["Internal Node"] + end + + EN -->|"Link 1 + (parent graph)"| SGN + SGN -.->|"maps to"| SI + SI -->|"owns"| SIN + SIN -->|"Link 2 + (subgraph, magic ID)"| IN + + style Magic fill:#3a2a1a,stroke:#5a3a2a,color:#e0e0e0 +``` + +Two separate links. A virtual node with a magic sentinel ID +(`SUBGRAPH_INPUT_ID = -10`). A slot-like object that is neither a slot nor a +node. Every link in the system carries a latent special case: _am I a boundary +link?_ Every pack/unpack operation must remap both links and reconcile both ID +spaces. + +This complexity exists because the boundary was never designed — it accreted. +The virtual nodes are wiring infrastructure with no domain semantics. The magic +IDs are an escape hatch from a type system that offers no legitimate way to +express "this connection crosses a scope boundary." + +### Boundaries as typed contracts + +A subgraph boundary is, mathematically, a function signature. A subgraph takes +typed inputs and produces typed outputs. The types come from the same vocabulary +used by all nodes: `"INT"`, `"FLOAT"`, `"MODEL"`, `"IMAGE"`, `"CONDITIONING"`, +and any custom type registered by extensions. + +The boundary model should say exactly this: + +``` +SubgraphStructure { + graphId: GraphId + interface: { + inputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }> + outputs: Array<{ name: string, type: ISlotType, slotId: SlotEntityId }> + } +} +``` + +The `interface` is the contract. Each entry declares a name, a type, and a +reference to the corresponding slot on the SubgraphNode. Inside the subgraph, +internal nodes connect to boundary slots through ordinary links — no virtual +nodes, no magic IDs, no special cases. A link is a link. A slot is a slot. + +```mermaid +graph LR + subgraph Parent["Parent Graph"] + EN["External Node"] + SGN["SubgraphNode + + SubgraphStructure"] + EN -->|"ordinary link"| SGN + end + + subgraph Interface["Typed Interface"] + I1["input: 'seed' + type: INT + slotId: S-42"] + O1["output: 'image' + type: IMAGE + slotId: S-43"] + end + + subgraph Interior["Subgraph (graphScope: G-2)"] + IN1["KSampler"] + IN2["VAEDecode"] + IN1 -->|"ordinary link"| IN2 + end + + SGN -.->|"interface.inputs[0]"| I1 + I1 -.->|"boundary link"| IN1 + IN2 -.->|"boundary link"| O1 + O1 -.->|"interface.outputs[0]"| SGN + + style Interface fill:#1a2a3a,stroke:#2a3a5a,color:#e0e0e0 +``` + +### Type-driven widget surfacing + +The existing type system already knows which types get widgets. When a node +definition declares an input of type `"INT"`, the widget registry +(`widgetStore.widgets`) maps that type to a number widget constructor. When the +type is `"MODEL"`, no widget constructor exists — the input is socket-only. + +This mechanism applies identically to subgraph interface inputs. If a subgraph +declares an input of type `"INT"`, the SubgraphNode gets an INT input slot, and +the type → widget mapping gives that slot a number widget. If the input is type +`"MODEL"`, the SubgraphNode gets a socket-only input. No special promotion +machinery is needed. The type system already does the work. + +This is the key insight that connects graph boundaries to widget promotion, and +it is the subject of Section 3. + +### Pack and unpack + +Under graph unification, packing and unpacking become operations on `graphScope` +tags rather than on class hierarchies: + +**Pack** (convert selection to subgraph): + +1. Create a new `graphId` +2. Move selected entities: change their `graphScope` to the new graph +3. For links that crossed the selection boundary: create boundary slot mappings + in `SubgraphStructure.interface`, infer types from the connected slots +4. Create a SubgraphNode in the parent scope with the `SubgraphStructure` + component + +**Unpack** (dissolve subgraph): + +1. Move entities back: change their `graphScope` to the parent +2. Reconnect boundary links directly (remove the SubgraphNode intermediary) +3. Delete the SubgraphNode + +The critical simplification: **no ID remapping.** Entities keep their IDs +throughout. Only the `graphScope` tag changes. The current system's +clone-remap-configure dance — separate logic for node IDs, link IDs, reroute +IDs, subgraph UUIDs — is eliminated entirely. + +--- + +## 3. Widget Promotion: Open Decision + +Widget promotion is the mechanism by which an interior widget surfaces on the +SubgraphNode in the parent graph. A user right-clicks a widget inside a subgraph +and selects "Promote to parent." The widget's value becomes controllable from the +outside. + +This is where the document presents two candidates for the ECS model. The team +must choose before Phase 3 of the migration. + +### Current mechanism + +The current system has three layers: + +1. **PromotionStore** (`src/stores/promotionStore.ts`): A ref-counted Pinia + store mapping `graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracks + which interior widgets are promoted and provides O(1) `isPromotedByAny()` + queries. + +2. **PromotedWidgetViewManager**: A reconciliation layer that maintains stable + `PromotedWidgetView` proxy widget objects, diffing against the store on each + update — a pattern analogous to virtual DOM reconciliation. + +3. **PromotedWidgetView**: A proxy widget on the SubgraphNode that mirrors the + interior widget's type, value, and options. Reads and writes delegate to the + original widget's entry in `WidgetValueStore`. + +Serialized as `properties.proxyWidgets` on the SubgraphNode. + +### Candidate A: Connections-only + +Promotion is not a separate mechanism. It is adding a typed input to the +subgraph's interface. + +When a user "promotes" widget X (type `INT`) on interior node N: + +1. A new entry is added to `SubgraphStructure.interface.inputs`: + `{ name: "seed", type: "INT", slotId: }` +2. The SubgraphNode gains a new input slot of type `INT`. The type → widget + mapping (`widgetStore`) creates an INT widget on that slot automatically. +3. Inside the subgraph, the interior node's widget input is replaced by a + connection from the boundary input — the same transformation that occurs + today when a user drags a link to a widget input (`forceInput` behavior). + +"Demoting" is the reverse: remove the interface input, restore the interior +widget to its standalone state. + +The PromotionStore, PromotedWidgetViewManager, and PromotedWidgetView are +eliminated entirely. The existing slot, link, and widget infrastructure handles +everything. Promotion becomes an operation on the subgraph's function signature, +not a parallel state management system. + +**Value flow under Candidate A:** + +```mermaid +sequenceDiagram + participant User + participant SW as SubgraphNode Widget (INT) + participant BS as Boundary Slot + participant IW as Interior Node Input + participant Exec as Execution + + User->>SW: sets value = 42 + Note over SW: normal widget, normal WidgetValueStore entry + SW->>BS: value carried by boundary link + BS->>IW: arrives as input connection value + Exec->>IW: reads input value (42) +``` + +### Candidate B: Simplified component promotion + +Promotion remains a first-class concept, simplified from three layers to one: + +- A `WidgetPromotion` component on a widget entity: + `{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }` +- The SubgraphNode's widget list includes promoted widget entity IDs directly +- Value reads/writes delegate to the source widget's `WidgetValue` component via + World lookup +- Serialized as `properties.proxyWidgets` (unchanged) + +This removes the ViewManager and proxy widget reconciliation but preserves the +concept of promotion as distinct from connection. + +### Tradeoff matrix + +| Dimension | A: Connections-Only | B: Simplified Promotion | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| New concepts | None — reuses slots, links, widgets | `WidgetPromotion` component | +| Code removed | PromotionStore, ViewManager, PromotedWidgetView, `_syncPromotions` | ViewManager, proxy reconciliation | +| Shared subgraph compat | ✅ Each instance has independent interface inputs with independent values | ⚠️ Promotion delegates to a source widget by entity ID — when multiple SubgraphNode instances share a definition, which instance's source widget is authoritative? | +| Dynamic widgets | ✅ Input type drives widget creation via existing registry | ⚠️ Must handle type changes in promotion component | +| Serialization | Interface inputs serialized as `SubgraphIO` entries | Separate `proxyWidgets` property | +| Backward-compatible loading | Migration: old `proxyWidgets` → interface inputs + boundary links | Direct — same serialization shape | +| UX consistency | Promoted widgets look like normal input widgets | Promoted widgets look like proxy widgets (distinct) | +| Widget ordering | Slot ordering (reorderable like any input) | Explicit promotion order (`movePromotion`) | +| Nested promotion | Adding interface inputs at each nesting level — simple mechanically, but N levels = N manual promote operations for the user | `disambiguatingSourceNodeId` complexity persists | + +### Constraints that hold regardless + +Whichever candidate is chosen: + +- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node + reference. This is settled (see Section 4). +- **The type → widget mapping is authoritative.** The widget registry + (`widgetStore.widgets`) is the single source of truth for which types produce + widgets. No parallel mechanism should duplicate this. +- **Backward-compatible loading is non-negotiable.** Existing workflows with + `proxyWidgets` must load correctly, indefinitely (see Section 4). +- **The design must not foreclose functional subgraphs.** A future where + subgraphs behave as pure functions — same inputs, same outputs, no + instance-specific state beyond inputs — must remain reachable. This is a + constraint, not a current requirement. + +### Recommendation and decision criteria + +**Lean toward A.** It eliminates an entire subsystem by recognizing a structural +truth: promotion is adding a typed input to a function signature. The type +system already handles widget creation for typed inputs. Building a parallel +mechanism for "promoted widgets" is building a second, narrower version of +something the system already does. + +The cost of A is a migration path for existing `proxyWidgets` serialization. On +load, the `SerializationSystem` converts `proxyWidgets` entries into interface +inputs and boundary links. This is a one-time ratchet conversion — once +loaded and re-saved, the workflow uses the new format. + +**Choose B if** the team determines that promoted widgets must remain +visually or behaviorally distinct from normal input widgets in ways the type → +widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds +the current release cycle's capacity. + +**Decision needed before** Phase 3 of the ECS migration, when systems are +introduced and the widget/connectivity architecture solidifies. + +--- + +## 4. Serialization Boundary + +### Principle + +The internal model and the serialization format are different things, designed +for different purposes. + +The World is optimized for runtime queries: branded IDs for type safety, flat +entity maps for O(1) lookup, component composition for flexible querying. The +serialization format is optimized for interchange: named keys for human +readability, nested structure for self-containment, positional widget value +arrays for compactness, backward compatibility across years of workflow files. + +These are not the same optimization targets. Conflating them — forcing the +internal model to mirror the wire format, or vice versa — creates a system that +is mediocre at both jobs. The `SerializationSystem` is the membrane between +these two worlds. It translates in both directions, and it is the only component +that knows about legacy format quirks. + +### Current serialization structure + +``` +ISerialisedGraph { + nodes: ISerialisedNode[] // widgets_values[], inputs[], outputs[] + links: SerialisedLLinkArray[] // [id, origin_id, origin_slot, target_id, target_slot, type] + reroutes: SerialisableReroute[] + groups: ISerialisedGroup[] + subgraphs: ExportedSubgraph[] // recursive: each contains its own nodes, links, etc. +} +``` + +Subgraphs nest recursively. Widget values are positional arrays on each node. +Link data uses tuple arrays for compactness. + +### What changes under graph unification + +**Internal model**: Flat. All entities across all nesting levels live in one +World, tagged with `graphScope`. No recursive containers. + +**Serialized format**: Nested. The `SerializationSystem` walks the scope tree +and produces the recursive `ExportedSubgraph` structure, matching the current +format exactly. Existing workflows, the ComfyUI backend, and third-party tools +see no change. + +| Direction | Format | Notes | +| --------------- | ------------------------------- | ---------------------------------------- | +| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree | +| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load | + +The "ratchet conversion" pattern: load any supported format, normalize to the +internal model. The system accepts old formats indefinitely but produces the +current format on save. + +### Widget identity at the boundary + +| Context | Identity | Example | +| -------------------- | ---------------------------------------------------------- | ---------------------------------- | +| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` | +| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget | + +On save: the `SerializationSystem` queries `WidgetIdentity.name` and +`WidgetValue.value`, produces the positional array ordered by widget creation +order. + +On load: widget values are matched by name against the node definition's input +specs, then assigned `WidgetEntityId`s from the global counter. + +This is the existing contract, preserved exactly. + +### Subgraph interface at the boundary + +Under graph unification, the subgraph's typed interface +(`SubgraphStructure.interface`) is serialized as the existing `SubgraphIO` +format: + +``` +SubgraphIO { + id: UUID + name: string + type: string // slot type (e.g. "INT", "MODEL") + linkIds?: LinkId[] +} +``` + +If Candidate A (connections-only promotion) is chosen: promoted widgets become +interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy +`proxyWidgets` data is converted to interface inputs and boundary links (ratchet +migration). On save, `proxyWidgets` is no longer written. + +If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be +serialized in its current format. + +### Backward-compatible loading contract + +This is a hard constraint with no expiration: + +1. **Any workflow saved by any prior version of ComfyUI must load + successfully.** The `SerializationSystem` is the sole custodian of legacy + format knowledge — positional widget arrays, magic link IDs, `SubgraphIO` + shapes, `proxyWidgets`, tuple-encoded links. + +2. **The rest of the system never sees legacy formats.** On load, all data is + normalized to ECS components. No other system, component, or query path needs + to know that `SUBGRAPH_INPUT_ID = -10` ever existed. + +3. **New format features are additive.** Old fields are never removed from the + accepted schema. They may be deprecated in documentation, but the parser + accepts them indefinitely. + +4. **Save format may evolve.** The output format can change (e.g., dropping + `proxyWidgets` in favor of interface inputs under Candidate A), but only when + the corresponding load-path migration is in place and validated. + +--- + +## 5. Impact on ADR 0008 + +This document proposes or surfaces the following changes to +[ADR 0008](../adr/0008-entity-component-system.md): + +| Area | Current ADR 0008 | Proposed Change | +| ------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component | +| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier | +| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities | +| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow | +| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation | +| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs | +| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) | +| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane | +| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely | + +These amendments should be applied to ADR 0008 and the related architecture +documents in a follow-up pass after team review of this document: + +- [ECS Target Architecture](ecs-target-architecture.md) — World Overview + diagram, Entity IDs diagram, component tables +- [ECS Migration Plan](ecs-migration-plan.md) — Phase 1c World type definition, + dependency graph +- [ECS Lifecycle Scenarios](ecs-lifecycle-scenarios.md) — unpack flow uses + `subgraphEntityId` +- [Proto-ECS Stores](proto-ecs-stores.md) — extraction map lists Subgraph as + distinct entity kind +- [Entity Interactions](entity-interactions.md) — entity table and overview + diagram list Subgraph separately diff --git a/docs/testing/README.md b/docs/testing/README.md index d4f7145fe9..82aca22960 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -29,7 +29,9 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al Our tests use the following frameworks and libraries: - [Vitest](https://vitest.dev/) - Test runner and assertion library -- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities +- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing +- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation +- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted) - [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing ## Getting Started diff --git a/eslint.config.ts b/eslint.config.ts index 0920b5a81a..e3af848c7f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -5,6 +5,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss' import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript' import { importX } from 'eslint-plugin-import-x' import oxlint from 'eslint-plugin-oxlint' +import testingLibrary from 'eslint-plugin-testing-library' // eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt) import eslintConfigPrettier from 'eslint-config-prettier' import { configs as storybookConfigs } from 'eslint-plugin-storybook' @@ -94,7 +95,8 @@ export default defineConfig([ projectService: { allowDefaultProject: [ 'vite.electron.config.mts', - 'vite.types.config.mts' + 'vite.types.config.mts', + 'apps/website/astro.config.ts' ] } } @@ -271,6 +273,20 @@ export default defineConfig([ ] } }, + { + files: ['**/*.test.ts'], + plugins: { 'testing-library': testingLibrary }, + rules: { + 'testing-library/prefer-screen-queries': 'error', + 'testing-library/no-container': 'error', + 'testing-library/no-node-access': 'error', + 'testing-library/no-wait-for-multiple-assertions': 'error', + 'testing-library/prefer-find-by': 'error', + 'testing-library/prefer-presence-queries': 'error', + 'testing-library/prefer-user-event': 'error', + 'testing-library/no-debugging-utils': 'error' + } + }, { files: ['scripts/**/*.js'], languageOptions: { diff --git a/knip.config.ts b/knip.config.ts index 93400d558a..ee97c50045 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -6,7 +6,6 @@ const config: KnipConfig = { entry: [ '{build,scripts}/**/*.{js,ts}', 'src/assets/css/style.css', - 'src/main.ts', 'src/scripts/ui/menu/index.ts', 'src/types/index.ts', 'src/storybook/mocks/**/*.ts' @@ -14,22 +13,31 @@ const config: KnipConfig = { project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**'] }, 'apps/desktop-ui': { - entry: ['src/main.ts', 'src/i18n.ts'], + entry: ['src/i18n.ts'], project: ['src/**/*.{js,ts,vue}'] }, 'packages/tailwind-utils': { project: ['src/**/*.{js,ts}'] }, 'packages/shared-frontend-utils': { - project: ['src/**/*.{js,ts}'], - entry: ['src/formatUtil.ts', 'src/networkUtil.ts'] + project: ['src/**/*.{js,ts}'] }, 'packages/registry-types': { project: ['src/**/*.{js,ts}'] }, 'packages/ingest-types': { - project: ['src/**/*.{js,ts}'], - entry: ['src/index.ts'] + project: ['src/**/*.{js,ts}'] + }, + 'apps/website': { + entry: [ + 'src/pages/**/*.astro', + 'src/layouts/**/*.astro', + 'src/components/**/*.vue', + 'src/styles/global.css', + 'astro.config.ts' + ], + project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'], + ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics'] } }, ignoreBinaries: ['python3', 'gh'], @@ -45,12 +53,7 @@ const config: KnipConfig = { ignore: [ // Auto generated API types 'src/workbench/extensions/manager/types/generatedManagerTypes.ts', - 'packages/registry-types/src/comfyRegistryTypes.ts', - 'packages/ingest-types/src/types.gen.ts', 'packages/ingest-types/src/zod.gen.ts', - 'packages/ingest-types/openapi-ts.config.ts', - // Used by a custom node (that should move off of this) - 'src/scripts/ui/components/splitButton.ts', // Used by stacked PR (feat/glsl-live-preview) 'src/renderer/glsl/useGLSLRenderer.ts', // Workflow files contain license names that knip misinterprets as binaries @@ -58,17 +61,8 @@ const config: KnipConfig = { // Pending integration in stacked PR 'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue', // Agent review check config, not part of the build - '.agents/checks/eslint.strict.config.js', - // Loaded via @plugin directive in CSS, not detected by knip - 'packages/design-system/src/css/lucideStrokePlugin.js' + '.agents/checks/eslint.strict.config.js' ], - compilers: { - // https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199 - css: (text: string) => - [...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)] - .map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1')) - .join('\n') - }, vite: { config: ['vite?(.*).config.mts'] }, diff --git a/lint-staged.config.ts b/lint-staged.config.ts index 4af68e9aac..0f436504d9 100644 --- a/lint-staged.config.ts +++ b/lint-staged.config.ts @@ -11,7 +11,7 @@ export default { './**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles), - './**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => { + './**/*.{ts,tsx,vue,mts,json,yaml,md}': (stagedFiles: string[]) => { const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck'] const hasBrowserTestsChanges = stagedFiles diff --git a/nx.json b/nx.json index 6753085766..fb974ab8c9 100644 --- a/nx.json +++ b/nx.json @@ -36,5 +36,6 @@ "targetName": "e2e" } } - ] + ], + "analytics": false } diff --git a/package.json b/package.json index aa947b874b..1722e0314c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.43.2", + "version": "1.43.7", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", @@ -44,7 +44,7 @@ "stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'", "stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'", "test:browser": "pnpm exec nx e2e", - "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser", + "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser", "test:unit": "nx run test", "typecheck": "vue-tsc --noEmit", "typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json", @@ -135,6 +135,9 @@ "@storybook/vue3": "catalog:", "@storybook/vue3-vite": "catalog:", "@tailwindcss/vite": "catalog:", + "@testing-library/jest-dom": "catalog:", + "@testing-library/user-event": "catalog:", + "@testing-library/vue": "catalog:", "@types/fs-extra": "catalog:", "@types/jsdom": "catalog:", "@types/node": "catalog:", @@ -153,6 +156,7 @@ "eslint-plugin-import-x": "catalog:", "eslint-plugin-oxlint": "catalog:", "eslint-plugin-storybook": "catalog:", + "eslint-plugin-testing-library": "catalog:", "eslint-plugin-unused-imports": "catalog:", "eslint-plugin-vue": "catalog:", "fast-check": "catalog:", @@ -177,9 +181,7 @@ "storybook": "catalog:", "stylelint": "catalog:", "tailwindcss": "catalog:", - "tailwindcss-primeui": "catalog:", "tsx": "catalog:", - "tw-animate-css": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "unplugin-icons": "catalog:", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index b919008772..ab40b3a5fc 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -12,7 +12,9 @@ "dependencies": { "@iconify-json/lucide": "catalog:", "@iconify/tailwind4": "catalog:", - "@iconify/utils": "catalog:" + "@iconify/utils": "catalog:", + "tailwindcss-primeui": "catalog:", + "tw-animate-css": "catalog:" }, "devDependencies": { "tailwindcss": "catalog:", diff --git a/packages/design-system/src/css/base.css b/packages/design-system/src/css/base.css new file mode 100644 index 0000000000..c33844801f --- /dev/null +++ b/packages/design-system/src/css/base.css @@ -0,0 +1,46 @@ +/* + * Design System Base — Brand tokens + fonts only. + * For marketing sites that don't use PrimeVue or the node editor. + * Import the full style.css instead for the desktop app. + */ + +@import './fonts.css'; + +@theme { + /* Font Families */ + --font-inter: 'Inter', sans-serif; + + /* Palette Colors */ + --color-charcoal-100: #55565e; + --color-charcoal-200: #494a50; + --color-charcoal-300: #3c3d42; + --color-charcoal-400: #313235; + --color-charcoal-500: #2d2e32; + --color-charcoal-600: #262729; + --color-charcoal-700: #202121; + --color-charcoal-800: #171718; + + --color-neutral-550: #636363; + + --color-ash-300: #bbbbbb; + --color-ash-500: #828282; + --color-ash-800: #444444; + + --color-smoke-100: #f3f3f3; + --color-smoke-200: #e9e9e9; + --color-smoke-300: #e1e1e1; + --color-smoke-400: #d9d9d9; + --color-smoke-500: #c5c5c5; + --color-smoke-600: #b4b4b4; + --color-smoke-700: #a0a0a0; + --color-smoke-800: #8a8a8a; + + --color-white: #ffffff; + --color-black: #000000; + + /* Brand Colors */ + --color-electric-400: #f0ff41; + --color-sapphire-700: #172dd7; + --color-brand-yellow: var(--color-electric-400); + --color-brand-blue: var(--color-sapphire-700); +} diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 5ced66c08c..d905b70033 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -15,7 +15,7 @@ @plugin "./lucideStrokePlugin.js"; /* Safelist dynamic comfy icons for node library folders */ -@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]"); +@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]"); /* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */ @source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]"); @@ -25,6 +25,9 @@ @theme { --shadow-interface: var(--interface-panel-box-shadow); + --text-2xs: 0.625rem; + --text-2xs--line-height: calc(1 / 0.625); + --text-xxs: 0.625rem; --text-xxs--line-height: calc(1 / 0.625); diff --git a/packages/design-system/src/icons/quiver-ai.svg b/packages/design-system/src/icons/quiver-ai.svg new file mode 100644 index 0000000000..2c1a31af36 --- /dev/null +++ b/packages/design-system/src/icons/quiver-ai.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 74845409fa..499745a6f1 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -631,3 +631,10 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean { mediaType === '3D' ) } + +export function formatTime(seconds: number): string { + if (isNaN(seconds) || seconds === 0) return '0:00' + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` +} diff --git a/playwright.config.ts b/playwright.config.ts index cf5d199370..d66c3fa1eb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, timeout: 15000, - grepInvert: /@mobile|@perf/ // Run all tests except those tagged with @mobile or @perf + grepInvert: /@mobile|@perf|@audit/ // Run all tests except those tagged with @mobile, @perf, or @audit }, { @@ -50,6 +50,17 @@ export default defineConfig({ fullyParallel: false }, + { + name: 'audit', + use: { + ...devices['Desktop Chrome'], + trace: 'retain-on-failure' + }, + timeout: 120_000, + grep: /@audit/, + fullyParallel: false + }, + { name: 'chromium-2x', use: { ...devices['Desktop Chrome'], deviceScaleFactor: 2 }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1452cc852b..9263634b75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@alloc/quick-lru': specifier: ^5.2.0 version: 5.2.0 + '@astrojs/vue': + specifier: ^5.0.0 + version: 5.1.4 '@comfyorg/comfyui-electron-types': specifier: 0.6.2 version: 0.6.2 @@ -37,17 +40,17 @@ catalogs: specifier: ^1.26.1 version: 1.26.1 '@nx/eslint': - specifier: 22.5.2 - version: 22.5.2 + specifier: 22.6.1 + version: 22.6.1 '@nx/playwright': - specifier: 22.5.2 - version: 22.5.2 + specifier: 22.6.1 + version: 22.6.1 '@nx/storybook': - specifier: 22.5.2 - version: 22.5.2 + specifier: 22.6.1 + version: 22.6.1 '@nx/vite': - specifier: 22.5.2 - version: 22.5.2 + specifier: 22.6.1 + version: 22.6.1 '@pinia/testing': specifier: ^1.0.3 version: 1.0.3 @@ -99,6 +102,15 @@ catalogs: '@tailwindcss/vite': specifier: ^4.2.0 version: 4.2.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1 + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0 '@tiptap/core': specifier: ^2.27.2 version: 2.27.2 @@ -138,6 +150,9 @@ catalogs: '@types/three': specifier: ^0.169.0 version: 0.169.0 + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1 '@vitejs/plugin-vue': specifier: ^6.0.0 version: 6.0.3 @@ -162,6 +177,9 @@ catalogs: algoliasearch: specifier: ^5.21.0 version: 5.21.0 + astro: + specifier: ^5.10.0 + version: 5.18.1 axios: specifier: ^1.13.5 version: 1.13.5 @@ -198,6 +216,9 @@ catalogs: eslint-plugin-storybook: specifier: ^10.2.10 version: 10.2.10 + eslint-plugin-testing-library: + specifier: ^7.16.1 + version: 7.16.1 eslint-plugin-unused-imports: specifier: ^4.3.0 version: 4.3.0 @@ -235,11 +256,11 @@ catalogs: specifier: ^0.7.3 version: 0.7.3 knip: - specifier: ^5.75.1 - version: 5.75.1 + specifier: ^6.0.1 + version: 6.0.1 lint-staged: specifier: ^16.2.7 - version: 16.2.7 + version: 16.4.0 markdown-table: specifier: ^3.0.4 version: 3.0.4 @@ -247,8 +268,8 @@ catalogs: specifier: ^2.71.0 version: 2.71.0 nx: - specifier: 22.5.2 - version: 22.5.2 + specifier: 22.6.1 + version: 22.6.1 oxfmt: specifier: ^0.40.0 version: 0.40.0 @@ -338,7 +359,7 @@ catalogs: version: 3.5.13 vue-component-type-helpers: specifier: ^3.2.1 - version: 3.2.5 + version: 3.2.6 vue-eslint-parser: specifier: ^10.4.0 version: 10.4.0 @@ -362,10 +383,10 @@ catalogs: version: 13.6.27 zod: specifier: ^3.23.8 - version: 3.24.1 + version: 3.25.76 zod-to-json-schema: specifier: ^3.24.1 - version: 3.24.1 + version: 3.25.1 zod-validation-error: specifier: ^3.3.0 version: 3.3.0 @@ -568,10 +589,10 @@ importers: version: 13.6.27 zod: specifier: 'catalog:' - version: 3.24.1 + version: 3.25.76 zod-validation-error: specifier: 'catalog:' - version: 3.3.0(zod@3.24.1) + version: 3.3.0(zod@3.25.76) devDependencies: '@eslint/js': specifier: 'catalog:' @@ -581,19 +602,19 @@ importers: version: 4.1.1(eslint@9.39.1(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.4.0(eslint@9.39.1(jiti@2.6.1)))(yaml-eslint-parser@1.3.0) '@lobehub/i18n-cli': specifier: 'catalog:' - version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0)(zod@3.24.1) + version: 1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0)(zod@3.25.76) '@nx/eslint': specifier: 'catalog:' - version: 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2) + version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1) '@nx/playwright': specifier: 'catalog:' - version: 22.5.2(@babel/traverse@7.29.0)(@playwright/test@1.58.1)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2) + version: 22.6.1(@babel/traverse@7.29.0)(@playwright/test@1.58.1)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1) '@nx/storybook': specifier: 'catalog:' - version: 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@nx/vite': specifier: 'catalog:' - version: 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) + version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) '@pinia/testing': specifier: 'catalog:' version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3))) @@ -618,6 +639,15 @@ importers: '@tailwindcss/vite': specifier: 'catalog:' version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.9.1 + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/vue': + specifier: 'catalog:' + version: 8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3)) '@types/fs-extra': specifier: 'catalog:' version: 11.0.4 @@ -672,6 +702,9 @@ importers: eslint-plugin-storybook: specifier: 'catalog:' version: 10.2.10(eslint@9.39.1(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + eslint-plugin-testing-library: + specifier: 'catalog:' + version: 7.16.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-unused-imports: specifier: 'catalog:' version: 4.3.0(@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) @@ -701,10 +734,10 @@ importers: version: 27.4.0 knip: specifier: 'catalog:' - version: 5.75.1(@types/node@24.10.4)(typescript@5.9.3) + version: 6.0.1 lint-staged: specifier: 'catalog:' - version: 16.2.7 + version: 16.4.0 markdown-table: specifier: 'catalog:' version: 3.0.4 @@ -713,7 +746,7 @@ importers: version: 2.71.0 nx: specifier: 'catalog:' - version: 22.5.2 + version: 22.6.1 oxfmt: specifier: 'catalog:' version: 0.40.0 @@ -744,15 +777,9 @@ importers: tailwindcss: specifier: 'catalog:' version: 4.2.0 - tailwindcss-primeui: - specifier: 'catalog:' - version: 0.6.1(tailwindcss@4.2.0) tsx: specifier: 'catalog:' version: 4.19.4 - tw-animate-css: - specifier: 'catalog:' - version: 1.3.8 typescript: specifier: 'catalog:' version: 5.9.3 @@ -788,7 +815,7 @@ importers: version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) vue-component-type-helpers: specifier: 'catalog:' - version: 3.2.5 + version: 3.2.6 vue-eslint-parser: specifier: 'catalog:' version: 10.4.0(eslint@9.39.1(jiti@2.6.1)) @@ -800,7 +827,7 @@ importers: version: 2.0.0 zod-to-json-schema: specifier: 'catalog:' - version: 3.24.1(zod@3.24.1) + version: 3.25.1(zod@3.25.76) apps/desktop-ui: dependencies: @@ -866,6 +893,34 @@ importers: specifier: 'catalog:' version: 3.2.5(typescript@5.9.3) + apps/website: + dependencies: + '@comfyorg/design-system': + specifier: workspace:* + version: link:../../packages/design-system + '@vercel/analytics': + specifier: 'catalog:' + version: 2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3)) + vue: + specifier: 'catalog:' + version: 3.5.13(typescript@5.9.3) + devDependencies: + '@astrojs/vue': + specifier: 'catalog:' + version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2) + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + astro: + specifier: 'catalog:' + version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2) + tailwindcss: + specifier: 'catalog:' + version: 4.2.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/design-system: dependencies: '@iconify-json/lucide': @@ -877,6 +932,12 @@ importers: '@iconify/utils': specifier: 'catalog:' version: 3.1.0 + tailwindcss-primeui: + specifier: 'catalog:' + version: 0.6.1(tailwindcss@4.2.0) + tw-animate-css: + specifier: 'catalog:' + version: 1.3.8 devDependencies: tailwindcss: specifier: 'catalog:' @@ -889,7 +950,7 @@ importers: dependencies: zod: specifier: 'catalog:' - version: 3.24.1 + version: 3.25.76 devDependencies: '@hey-api/openapi-ts': specifier: 0.93.0 @@ -994,6 +1055,9 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@asamuzakjp/css-color@4.1.1': resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} @@ -1003,6 +1067,30 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/internal-helpers@0.7.6': + resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} + + '@astrojs/markdown-remark@6.3.11': + resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} + + '@astrojs/prism@3.3.0': + resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/telemetry@3.3.0': + resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/vue@5.1.4': + resolution: {integrity: sha512-srE+3tgSnGG4FVr7Bs9JAgLcUAg1mtGrbBFdwlj++Y05Awwlc967WCcmOK6rnxQ6q5PcK5+WL2x2tKoWh5SN7A==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + peerDependencies: + astro: ^5.0.0 + vue: ^3.2.30 + '@atlaskit/pragmatic-drag-and-drop@1.3.1': resolution: {integrity: sha512-MptcLppK78B2eplL5fHk93kfCbZ6uCpt33YauBPrOwI5zcHYJhZGeaGEaAXoVAHnSJOdQUhy6kGVVC9qggz2Fg==} @@ -1532,8 +1620,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} '@babel/standalone@7.28.5': @@ -1562,6 +1650,10 @@ packages: '@cacheable/utils@2.3.2': resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==} + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + '@comfyorg/comfyui-electron-types@0.6.2': resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==} @@ -2286,6 +2378,159 @@ packages: '@iconify/utils@3.1.0': resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@internationalized/date@3.9.0': resolution: {integrity: sha512-yaN3brAnHRD+4KyyOsJyk49XUvj2wtbNACSqg0bz3u8t2VuzhC8Q5dfRnrSxjnnbDb+ienBnkn1TzQfE154vyg==} @@ -2325,14 +2570,6 @@ packages: resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} engines: {node: '>= 16'} - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2392,6 +2629,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@ltd/j-toml@1.38.0': + resolution: {integrity: sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -2447,111 +2687,111 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nx/cypress@22.5.2': - resolution: {integrity: sha512-zrBScx4nTJQnRrK/WdNNspsTviOPrBN4+5cTQZ4yZCn9eYasqMTBcKPaCRgFmsPvmA7bU4QbPUeC6u+7iHjlqw==} + '@nx/cypress@22.6.1': + resolution: {integrity: sha512-MkQ0YCYn0FPVJmvDuXQMVDldXMyY47DxikzX4PnqzSqGOWdVeW8rXbyUwl5pZCCxFQz0jNoHW1+wMti3BVlt8g==} peerDependencies: cypress: '>= 13 < 16' peerDependenciesMeta: cypress: optional: true - '@nx/devkit@22.5.2': - resolution: {integrity: sha512-5JuCMlU6AwXBKCoMWp1hTyTzjfB5vXL5khmoziZPmnaIBg+gX0Hp13YhWfO1C6/HuXS+i4mor7dtFHukPKWemQ==} + '@nx/devkit@22.6.1': + resolution: {integrity: sha512-/mwG9zWY1phsWvMKzP0yZ4pE6aH0kLH31DuCYj4eLbhuUu0STL3xSdjPPzhDHf71R4K3YnuvG97e2qiGDbG5Qw==} peerDependencies: nx: '>= 21 <= 23 || ^22.0.0-0' - '@nx/eslint@22.5.2': - resolution: {integrity: sha512-TDMaZzqJNZYB+6dOtzUPWBJHR5IrV0+i8k5domvHB+kPkynrIvgg8Ys5vRQ4p7j80p7L+KJhR9RgZ04llQ99Lw==} + '@nx/eslint@22.6.1': + resolution: {integrity: sha512-+0y+808naJCJoxlvTrWk9PZ8d+XE0QO9af9CAOXXKDugxBRkUZ5gHEXQ10xw1Z6DxZCHzJbPndorRwpR6C2/Sw==} peerDependencies: '@zkochan/js-yaml': 0.0.7 - eslint: ^8.0.0 || ^9.0.0 + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: '@zkochan/js-yaml': optional: true - '@nx/js@22.5.2': - resolution: {integrity: sha512-7FO+EYdYgR3tOY0h584jiVw8s4GVoqKZMMXldF7LciTSNS4aApO4Lz79B6gKnR4QGHExcRX/FBd0mznioXCNCQ==} + '@nx/js@22.6.1': + resolution: {integrity: sha512-Ikp2+B5TzO5/2KV/yc1r4zl6Cnaf0cPgJf+YlBrDUpKss6yrY4n5ORArbvEw9E1q2llVhA5C/zRd+mM8eEMmkQ==} peerDependencies: verdaccio: ^6.0.5 peerDependenciesMeta: verdaccio: optional: true - '@nx/nx-darwin-arm64@22.5.2': - resolution: {integrity: sha512-CPtgK/s4FQ0Y/6WmHpJccOTANve5UjlFajLp+S8Z538zHdc5a5MjJBcXo9oRzKNvhTHoGijr/fCMU2erMrYYtg==} + '@nx/nx-darwin-arm64@22.6.1': + resolution: {integrity: sha512-lixkEBGFdEsUiqEZg9LIyjfiTv12Sg1Es/yUgrdOQUAZu+5oiUPMoybyBwrvINl+fZw+PLh66jOmB4GSP2aUMQ==} cpu: [arm64] os: [darwin] - '@nx/nx-darwin-x64@22.5.2': - resolution: {integrity: sha512-YuFGIpmtMPbMM3QchJttlLFE5oNenE+3mRCWcMNrXPOixsw28flvYWhFcHE3CPV8q/E+Yg0FsOG+8u1p7eEgWg==} + '@nx/nx-darwin-x64@22.6.1': + resolution: {integrity: sha512-HvgtOtuWnEf0dpfWb05N0ptdFg040YgzsKFhXg6+qaBJg5Hg0e0AXPKaSgh2PCqCIDlKu40YtwVgF7KXxXAGlA==} cpu: [x64] os: [darwin] - '@nx/nx-freebsd-x64@22.5.2': - resolution: {integrity: sha512-Oy3jejPB7lszxAf4rdTpJfOBVgAUtkUZJCLTdGdnpveF/m3s9MN9DaeEXgUs0mMp1qV3Y0KE3KcVHqII54AoBQ==} + '@nx/nx-freebsd-x64@22.6.1': + resolution: {integrity: sha512-g2wUltGX+7/+mdTV5d6ODa0ylrNu/krgb9YdrsbhW6oZeXYm2LeLOAnYqIlL/Kx140NLrb5Kcz7bi7JrBAw4Ow==} cpu: [x64] os: [freebsd] - '@nx/nx-linux-arm-gnueabihf@22.5.2': - resolution: {integrity: sha512-38bZGStG6bZ+R7ZbGxvnDVjVrV6bRTsiX8rr3fmM/AkEfvgyhWgE3R+xqUHoJVM4PK0I2YlYoSjIny4gFeOBxQ==} + '@nx/nx-linux-arm-gnueabihf@22.6.1': + resolution: {integrity: sha512-TTqisFPAPrj35EihvzotBbajS+0bX++PQggmRVmDmGwSTrpySRJwZnKNHYDqP6s9tigDvkNJOJftK+GkBEFRRA==} cpu: [arm] os: [linux] - '@nx/nx-linux-arm64-gnu@22.5.2': - resolution: {integrity: sha512-D+tPXB0tkSuDPsuXvyQIsF3f3PBWfAwIe9FkBWtVoDVYqE+jbz+tVGsjQMNWGafLE4sC8ZQdjhsxyT8I53Anbw==} + '@nx/nx-linux-arm64-gnu@22.6.1': + resolution: {integrity: sha512-uIkPcanSTIcyh7/6LOoX0YpGO/7GkVhMRgyM9Mg/7ItFjCtRaeuPEPrJESsaNeB5zIVVhI4cXbGrM9NDnagiiw==} cpu: [arm64] os: [linux] libc: [glibc] - '@nx/nx-linux-arm64-musl@22.5.2': - resolution: {integrity: sha512-UbO527qqa8KLBi13uXto5SmxcZv1Smer7sPexJonshDlmrJsyvx5m8nm6tcSv04W5yQEL90vPlTux8dNvEDWrw==} + '@nx/nx-linux-arm64-musl@22.6.1': + resolution: {integrity: sha512-eqkG8s/7remiRZ1Lo2zIrFLSNsQ/0x9fAj++CV1nqFE+rfykPQhC48F8pqsq6tUQpI5HqRQEfQgv4CnFNpLR+w==} cpu: [arm64] os: [linux] libc: [musl] - '@nx/nx-linux-x64-gnu@22.5.2': - resolution: {integrity: sha512-wR6596Vr/Z+blUAmjLHG2TCQMs4O1oi9JXK1J/PoPeO9UqdHwStCJBAd61zDFSUYJe0x+dkeRQu96fE5BW8Kcg==} + '@nx/nx-linux-x64-gnu@22.6.1': + resolution: {integrity: sha512-6DhSupCcDa6BYzQ48qsMK4LIdIO+y4E+4xuUBkX2YTGOZh58gctELCv7Gi6/FhiC8rzVzM7hDcygOvHCGc30zA==} cpu: [x64] os: [linux] libc: [glibc] - '@nx/nx-linux-x64-musl@22.5.2': - resolution: {integrity: sha512-MBXOw4AH4FWl4orwVykj/e75awTNDePogrl3pXNX9NcQLdj6JzS4e2jaALQeRBQLxQzeFvFQV/W4PBzoPV6/NA==} + '@nx/nx-linux-x64-musl@22.6.1': + resolution: {integrity: sha512-QqtfaBhdfLRKGucpP8RSv7KJ51XRWpfUcXPhkb/1dKP/b9/Z0kpaCgczGHdrAtX9m6haWw+sQXYGxnStZIg/TQ==} cpu: [x64] os: [linux] libc: [musl] - '@nx/nx-win32-arm64-msvc@22.5.2': - resolution: {integrity: sha512-SaWSZkRH5uV8vP2lj6RRv+kw2IzaIDXkutReOXpooshIWZl9KjrQELNTCZTYyhLDsMlcyhSvLFlTiA4NkZ8udw==} + '@nx/nx-win32-arm64-msvc@22.6.1': + resolution: {integrity: sha512-8pTWXphY5IIgY3edZ5SfzP8yPjBqoAxRV5snAYDctF4e0OC1nDOUims70jLesMle8DTSWiHPSfbLVfp2HkU9WQ==} cpu: [arm64] os: [win32] - '@nx/nx-win32-x64-msvc@22.5.2': - resolution: {integrity: sha512-IK9Xd5Gh9ys4oun5ko8Uv8AEi2byN2FPXBsR1BLkt93SJ0bJVTdXGyEA+fWmEclLZIM0PiZj1KbCajVn9NEPtw==} + '@nx/nx-win32-x64-msvc@22.6.1': + resolution: {integrity: sha512-XMYrtsR5O39uNR4fVpFs65rVB09FyLXvUM735r2rO7IUWWHxHWTAgVcc+gqQaAchBPqR9f1q+3u2i1Inub3Cdw==} cpu: [x64] os: [win32] - '@nx/playwright@22.5.2': - resolution: {integrity: sha512-1ti4+Q5pmEoYtMZBqUwksgAoMvQe5dVeRlgwvb79Oux+UNZYWzjr9Y/HZbAIJpelNCgeA0grofDVBIQkR4c9Ew==} + '@nx/playwright@22.6.1': + resolution: {integrity: sha512-Gz5tmAo9SfZ8Q3SUv02eaSwrHYTizmFrZSL2tYuqYCAyJZ5LYsG7BFoFg6GadFbnLe0hpTXVxsKTW1lHPrjL5Q==} peerDependencies: '@playwright/test': ^1.36.0 peerDependenciesMeta: '@playwright/test': optional: true - '@nx/storybook@22.5.2': - resolution: {integrity: sha512-B3CZUOXdzoYJEkQqGASxAgKhndixvqK6tIUkUMWq2iarSJTv1nLXq4iu0h9GVFaNv791f6sr5Myhfu3i2RacLg==} + '@nx/storybook@22.6.1': + resolution: {integrity: sha512-JzHk4DFO6kGsmBh4kBOJu1T9fL9/5hY+ikAhBDwKee4rFJJwkJ0GInc49RpaXGCpNxLw7gJeyojFsZ+Q0GwBZQ==} peerDependencies: storybook: '>=7.0.0 <11.0.0' - '@nx/vite@22.5.2': - resolution: {integrity: sha512-E3zTMLfY203lW8RxH+j3U4mR9btSVwWw3mY8XslND0Png1AliZ4fH638moH/eRsJvWS454oSYp86m3FlouSkqA==} + '@nx/vite@22.6.1': + resolution: {integrity: sha512-UuqEY73iO4Z3CXVolmyLSRyKi02sm5jWuNIPxgQtxCf2uORfdDQ4ZxBod6rJeRJb/DhEUiJ5ijZkXukQGXIApg==} peerDependencies: vite: ^8.0.0 vitest: ^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 - '@nx/vitest@22.5.2': - resolution: {integrity: sha512-hKtKB5BInOwub7+3QOolZZvqv89pkaU2+CbN0iD3Wk2BzTfGY/dlcTvvgcEUDRCTMDn7G+G30cyq+nbz6Z+R6A==} + '@nx/vitest@22.6.1': + resolution: {integrity: sha512-p1gVpwvWqnvqMB+MMaszxR3pFIhAca0/wS7c6S7U8vvQ/C+KldbHIMGJxuTVgC0/2EEs98hoIlClm285F3hDdQ==} peerDependencies: vite: ^8.0.0 vitest: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 @@ -2561,8 +2801,8 @@ packages: vitest: optional: true - '@nx/workspace@22.5.2': - resolution: {integrity: sha512-XImJ2NhUXo/bgNkuF1NAdYJjSzhN/iXZYSdhSREPN6Bh/thhloAQfpOrgFkxgxgNZv3RvldnnYNmGeKqMEW2jg==} + '@nx/workspace@22.6.1': + resolution: {integrity: sha512-mrvGubfownO/m0CtFvtRwQxQm//UoBRgrT0WCxrQi5tGlgViTYFSDRumhy94EuU00OGiAHNa3yeXWFd72O981A==} '@one-ini/wasm@0.1.1': resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} @@ -2639,6 +2879,136 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-parser/binding-android-arm-eabi@0.120.0': + resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.120.0': + resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.120.0': + resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.120.0': + resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.120.0': + resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.120.0': + resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.120.0': + resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.120.0': + resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.115.0': resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2646,111 +3016,114 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} - '@oxc-resolver/binding-android-arm-eabi@11.15.0': - resolution: {integrity: sha512-Q+lWuFfq7whNelNJIP1dhXaVz4zO9Tu77GcQHyxDWh3MaCoO2Bisphgzmsh4ZoUe2zIchQh6OvQL99GlWHg9Tw==} + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.15.0': - resolution: {integrity: sha512-vbdBttesHR0W1oJaxgWVTboyMUuu+VnPsHXJ6jrXf4czELzB6GIg5DrmlyhAmFBhjwov+yJH/DfTnHS+2sDgOw==} + '@oxc-resolver/binding-android-arm64@11.19.1': + resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.15.0': - resolution: {integrity: sha512-R67lsOe1UzNjqVBCwCZX1rlItTsj/cVtBw4Uy19CvTicqEWvwaTn8t34zLD75LQwDDPCY3C8n7NbD+LIdw+ZoA==} + '@oxc-resolver/binding-darwin-arm64@11.19.1': + resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.15.0': - resolution: {integrity: sha512-77mya5F8WV0EtCxI0MlVZcqkYlaQpfNwl/tZlfg4jRsoLpFbaTeWv75hFm6TE84WULVlJtSgvf7DhoWBxp9+ZQ==} + '@oxc-resolver/binding-darwin-x64@11.19.1': + resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.15.0': - resolution: {integrity: sha512-X1Sz7m5PC+6D3KWIDXMUtux+0Imj6HfHGdBStSvgdI60OravzI1t83eyn6eN0LPTrynuPrUgjk7tOnOsBzSWHw==} + '@oxc-resolver/binding-freebsd-x64@11.19.1': + resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.15.0': - resolution: {integrity: sha512-L1x/wCaIRre+18I4cH/lTqSAymlV0k4HqfSYNNuI9oeL28Ks86lI6O5VfYL6sxxWYgjuWB98gNGo7tq7d4GarQ==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.15.0': - resolution: {integrity: sha512-abGXd/zMGa0tH8nKlAXdOnRy4G7jZmkU0J85kMKWns161bxIgGn/j7zxqh3DKEW98wAzzU9GofZMJ0P5YCVPVw==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.15.0': - resolution: {integrity: sha512-SVjjjtMW66Mza76PBGJLqB0KKyFTBnxmtDXLJPbL6ZPGSctcXVmujz7/WAc0rb9m2oV0cHQTtVjnq6orQnI/jg==} + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.15.0': - resolution: {integrity: sha512-JDv2/AycPF2qgzEiDeMJCcSzKNDm3KxNg0KKWipoKEMDFqfM7LxNwwSVyAOGmrYlE4l3dg290hOMsr9xG7jv9g==} + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.15.0': - resolution: {integrity: sha512-zbu9FhvBLW4KJxo7ElFvZWbSt4vP685Qc/Gyk/Ns3g2gR9qh2qWXouH8PWySy+Ko/qJ42+HJCLg+ZNcxikERfg==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.15.0': - resolution: {integrity: sha512-Kfleehe6B09C2qCnyIU01xLFqFXCHI4ylzkicfX/89j+gNHh9xyNdpEvit88Kq6i5tTGdavVnM6DQfOE2qNtlg==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.15.0': - resolution: {integrity: sha512-J7LPiEt27Tpm8P+qURDwNc8q45+n+mWgyys4/V6r5A8v5gDentHRGUx3iVk5NxdKhgoGulrzQocPTZVosq25Eg==} + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.15.0': - resolution: {integrity: sha512-+8/d2tAScPjVJNyqa7GPGnqleTB/XW9dZJQ2D/oIM3wpH3TG+DaFEXBbk4QFJ9K9AUGBhvQvWU2mQyhK/yYn3Q==} + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.15.0': - resolution: {integrity: sha512-xtvSzH7Nr5MCZI2FKImmOdTl9kzuQ51RPyLh451tvD2qnkg3BaqI9Ox78bTk57YJhlXPuxWSOL5aZhKAc9J6qg==} + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.15.0': - resolution: {integrity: sha512-14YL1zuXj06+/tqsuUZuzL0T425WA/I4nSVN1kBXeC5WHxem6lQ+2HGvG+crjeJEqHgZUT62YIgj88W+8E7eyg==} + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.15.0': - resolution: {integrity: sha512-/7Qli+1Wk93coxnrQaU8ySlICYN8HsgyIrzqjgIkQEpI//9eUeaeIHZptNl2fMvBGeXa7k2QgLbRNaBRgpnvMw==} + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.15.0': - resolution: {integrity: sha512-q5rn2eIMQLuc/AVGR2rQKb2EVlgreATGG8xXg8f4XbbYCVgpxaq+dgMbiPStyNywW1MH8VU2T09UEm30UtOQvg==} + '@oxc-resolver/binding-wasm32-wasi@11.19.1': + resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.15.0': - resolution: {integrity: sha512-yCAh2RWjU/8wWTxQDgGPgzV9QBv0/Ojb5ej1c/58iOjyTuy/J1ZQtYi2SpULjKmwIxLJdTiCHpMilauWimE31w==} + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-ia32-msvc@11.15.0': - resolution: {integrity: sha512-lmXKb6lvA6M6QIbtYfgjd+AryJqExZVSY2bfECC18OPu7Lv1mHFF171Mai5l9hG3r4IhHPPIwT10EHoilSCYeA==} + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} cpu: [ia32] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.15.0': - resolution: {integrity: sha512-HZsfne0s/tGOcJK9ZdTGxsNU2P/dH0Shf0jqrPvsC6wX0Wk+6AyhSpHFLQCnLOuFQiHHU0ePfM8iYsoJb5hHpQ==} + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} cpu: [x64] os: [win32] @@ -3404,6 +3777,9 @@ packages: '@rushstack/ts-command-line@5.3.1': resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sentry-internal/browser-utils@10.32.1': resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==} engines: {node: '>=18'} @@ -3505,9 +3881,34 @@ packages: pinia: optional: true + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@sparkjsdev/spark@0.1.10': resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==} @@ -3688,6 +4089,10 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -3698,6 +4103,16 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@testing-library/vue@8.1.0': + resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + '@tiptap/core@2.27.2': resolution: {integrity: sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==} peerDependencies: @@ -3895,6 +4310,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -3934,6 +4352,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -4078,6 +4499,9 @@ packages: resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -4186,6 +4610,49 @@ packages: peerDependencies: valibot: ^1.2.0 + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + + '@vitejs/plugin-vue-jsx@4.2.0': + resolution: {integrity: sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^8.0.0 + vue: ^3.0.0 + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^8.0.0 + vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.3': resolution: {integrity: sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4315,6 +4782,11 @@ packages: '@vue/devtools-api@7.7.9': resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + '@vue/devtools-core@7.7.9': + resolution: {integrity: sha512-48jrBSwG4GVQRvVeeXn9p9+dlx+ISgasM7SxZZKczseohB0cBz+ITKr4YbLWjmJdy45UHL7UMPlR4Y0CWTRcSQ==} + peerDependencies: + vue: ^3.0.0 + '@vue/devtools-core@8.0.5': resolution: {integrity: sha512-dpCw8nl0GDBuiL9SaY0mtDxoGIEmU38w+TQiYEPOLhW03VDC0lfNMYXS/qhl4I0YlysGp04NLY4UNn6xgD0VIQ==} peerDependencies: @@ -4611,6 +5083,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -4630,6 +5105,9 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -4671,6 +5149,11 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} + astro@5.18.1: + resolution: {integrity: sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -4699,6 +5182,10 @@ packages: axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + babel-plugin-const-enum@1.2.0: resolution: {integrity: sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==} peerDependencies: @@ -4754,6 +5241,9 @@ packages: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} engines: {node: 20 || >=22} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -4788,8 +5278,8 @@ packages: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -4876,6 +5366,12 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -4902,6 +5398,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -4981,6 +5481,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -4993,10 +5496,6 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@14.0.2: - resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} - engines: {node: '>=20'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -5012,6 +5511,9 @@ packages: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -5056,6 +5558,13 @@ packages: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.2: + resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -5094,6 +5603,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-functions-list@3.2.3: resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} engines: {node: '>=12 || >=16'} @@ -5193,6 +5705,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -5254,6 +5770,13 @@ packages: engines: {node: '>= 4.0.0'} hasBin: true + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5269,6 +5792,9 @@ packages: resolution: {integrity: sha512-7SCDfnQtBObcngVXNPZcnxGxqqPTK4UqeXeKAch+RGH5qpqadWbV9FmN71x9Bb4tTs0TNFb4FT/4Kz4P4Cjqcw==} engines: {node: '>=6.0.0'} + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -5335,6 +5861,10 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5405,6 +5935,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + error-stack-parser-es@0.1.5: + resolution: {integrity: sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -5420,6 +5953,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -5579,6 +6115,12 @@ packages: eslint: '>=8' storybook: ^10.2.10 + eslint-plugin-testing-library@7.16.1: + resolution: {integrity: sha512-/pCFzJuro/wOq6FCp43DwS3fpCr0hM2gByJAxi8bPdL7DXKdmH3p3BEpVKQkneUxT6T2XbUXG4J+c5GSAm12lA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-unused-imports@4.3.0: resolution: {integrity: sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==} peerDependencies: @@ -5681,8 +6223,12 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} @@ -5738,8 +6284,8 @@ packages: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} @@ -5809,6 +6355,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -5818,6 +6368,13 @@ packages: debug: optional: true + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5911,17 +6468,24 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5992,6 +6556,9 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + h3@1.15.10: + resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==} + happy-dom@20.0.11: resolution: {integrity: sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==} engines: {node: '>=20.0.0'} @@ -6030,6 +6597,36 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -6047,6 +6644,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier-terser@6.1.0: resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} engines: {node: '>=12'} @@ -6056,9 +6656,15 @@ packages: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} @@ -6074,6 +6680,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -6107,6 +6717,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -6146,6 +6759,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -6309,6 +6929,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -6518,13 +7142,14 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knip@5.75.1: - resolution: {integrity: sha512-raguBFxTUO5JKrv8rtC8wrOtzrDwWp/fOu1F1GhrHD1F3TD2fqI1Z74JB+PyFZubL+RxqOkhGStdPAvaaXSOWQ==} - engines: {node: '>=18.18.0'} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + knip@6.0.1: + resolution: {integrity: sha512-qk5m+w6IYEqfRG5546DXZJYl5AXsgFfDD6ULaDvkubqNtLye79sokBg3usURrWFjASMeQtvX19TfldU3jHkMNA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - peerDependencies: - '@types/node': '>=18' - typescript: '>=5.0.4 <7' known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} @@ -6717,8 +7342,8 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} - lint-staged@16.2.7: - resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} engines: {node: '>=20.17'} hasBin: true @@ -6838,6 +7463,9 @@ packages: mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -6868,6 +7496,9 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -7017,20 +7648,16 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimatch@10.2.1: resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} engines: {node: 20 || >=22} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -7086,10 +7713,6 @@ packages: resolution: {integrity: sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==} engines: {node: '>=18.2.0'} - nano-spawn@2.0.0: - resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} - engines: {node: '>=20.17'} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7108,6 +7731,13 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -7131,8 +7761,8 @@ packages: node-html-parser@5.4.2: resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==} - node-machine-id@1.1.12: - resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -7150,11 +7780,15 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nx@22.5.2: - resolution: {integrity: sha512-s7dd2BZQOremv1AYhxwBY6NzJV9ETa6/OJ/zau/ulbLnHu8E5UAv+EjMC80m3qP3nob5OXnWiITKM9CcOHy6qw==} + nx@22.6.1: + resolution: {integrity: sha512-b4eo52o5aCVt3oG6LPYvD2Cul3JFBMgr2p9OjMBIo6oU6QfSR693H2/UuUMepLtO6jcIniPKOcIrf6Ue8aXAww==} hasBin: true peerDependencies: '@swc-node/register': ^1.11.1 @@ -7178,6 +7812,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -7201,6 +7839,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -7215,6 +7856,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -7254,8 +7901,12 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-resolver@11.15.0: - resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==} + oxc-parser@0.120.0: + resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.19.1: + resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} oxfmt@0.40.0: resolution: {integrity: sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==} @@ -7280,6 +7931,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -7288,6 +7943,14 @@ packages: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -7316,6 +7979,13 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -7340,6 +8010,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -7374,6 +8048,9 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7389,11 +8066,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - pinia@3.0.4: resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} peerDependencies: @@ -7484,6 +8156,10 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + primeicons@7.0.0: resolution: {integrity: sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==} @@ -7491,6 +8167,10 @@ packages: resolution: {integrity: sha512-7UMOIJvdFz4jQyhC76yhNdSlHtXvVpmE2JSo2ndUTBWjWJOkYyT562rQ4ayO+bMdJLtzBGqgY64I9ZfEvNd7vQ==} engines: {node: '>=12.11.0'} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -7501,6 +8181,13 @@ packages: promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.0: resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} @@ -7633,6 +8320,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} @@ -7708,6 +8398,15 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -7731,6 +8430,18 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + reka-ui@2.5.0: resolution: {integrity: sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==} peerDependencies: @@ -7749,6 +8460,13 @@ packages: remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} @@ -7792,6 +8510,18 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7902,6 +8632,10 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -7910,6 +8644,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -7940,6 +8677,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -7956,8 +8696,8 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smol-toml@1.5.2: - resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} + smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} engines: {node: '>= 18'} source-map-js@1.2.1: @@ -7978,6 +8718,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -8059,6 +8802,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -8075,6 +8821,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -8192,6 +8942,9 @@ packages: three@0.170.0: resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -8206,8 +8959,8 @@ packages: resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==} engines: {node: '>=12.20.0'} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} tinyglobby@0.2.15: @@ -8275,6 +9028,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -8291,6 +9047,16 @@ packages: ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -8372,17 +9138,27 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + unbash@2.2.0: + resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -8411,15 +9187,37 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} @@ -8485,6 +9283,68 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unstorage@1.17.4: + resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.2.2: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true @@ -8524,6 +9384,9 @@ packages: typescript: optional: true + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -8554,6 +9417,16 @@ packages: peerDependencies: vite: ^8.0.0 + vite-plugin-inspect@0.8.9: + resolution: {integrity: sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: ^8.0.0 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + vite-plugin-inspect@11.3.3: resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} engines: {node: '>=14'} @@ -8564,6 +9437,12 @@ packages: '@nuxt/kit': optional: true + vite-plugin-vue-devtools@7.7.9: + resolution: {integrity: sha512-08DvePf663SxqLFJeMVNW537zzVyakp9KIrI2K7lwgaTqA5R/ydN/N2K8dgZO34tg/Qmw0ch84fOKoBtCEdcGg==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: ^8.0.0 + vite-plugin-vue-devtools@8.0.5: resolution: {integrity: sha512-p619BlKFOqQXJ6uDWS1vUPQzuJOD6xJTfftj57JXBGoBD/yeQCowR7pnWcr/FEX4/HVkFbreI6w2uuGBmQOh6A==} engines: {node: '>=v14.21.3'} @@ -8618,6 +9497,14 @@ packages: yaml: optional: true + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.0.16: resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8670,8 +9557,8 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} - vue-component-type-helpers@3.2.5: - resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==} + vue-component-type-helpers@3.2.6: + resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -8756,6 +9643,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -8821,6 +9711,10 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -8925,6 +9819,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8964,16 +9861,34 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} zip-dir@2.0.0: resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==} - zod-to-json-schema@3.24.1: - resolution: {integrity: sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: - zod: ^3.24.1 + zod: ^3.25 || ^4 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 zod-validation-error@3.3.0: resolution: {integrity: sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==} @@ -8981,11 +9896,11 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.24.1: - resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} @@ -9101,7 +10016,9 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 + + '@antfu/utils@0.7.10': {} '@asamuzakjp/css-color@4.1.1': dependencies: @@ -9121,9 +10038,81 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.7.6': {} + + '@astrojs/markdown-remark@6.3.11': + dependencies: + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/prism': 3.3.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + shiki: 3.23.0 + smol-toml: 1.6.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.3.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.0': + dependencies: + ci-info: 4.4.0 + debug: 4.4.3 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)': + dependencies: + '@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vue/compiler-sfc': 3.5.28 + astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + vue: 3.5.13(typescript@5.9.3) + transitivePeerDependencies: + - '@nuxt/kit' + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + '@atlaskit/pragmatic-drag-and-drop@1.3.1': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 bind-event-listener: 3.0.0 raf-schd: 4.0.3 @@ -9819,7 +10808,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.2': {} '@babel/standalone@7.28.5': {} @@ -9860,6 +10849,10 @@ snapshots: hashery: 1.3.0 keyv: 5.5.5 + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + '@comfyorg/comfyui-electron-types@0.6.2': {} '@csstools/color-helpers@5.1.0': {} @@ -10080,7 +11073,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -10106,7 +11099,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -10565,6 +11558,103 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.0 + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@internationalized/date@3.9.0': dependencies: '@swc/helpers': 0.5.17 @@ -10622,12 +11712,6 @@ snapshots: '@intlify/shared@9.14.5': {} - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.1': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10698,7 +11782,7 @@ snapshots: - react-devtools-core - utf-8-validate - '@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0)(zod@3.24.1)': + '@lobehub/i18n-cli@1.26.1(@types/react@19.1.9)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0)(zod@3.25.76)': dependencies: '@lobehub/cli-ui': 1.13.0(@types/react@19.1.9) '@yutengjing/eld': 0.0.2 @@ -10717,7 +11801,7 @@ snapshots: json-stable-stringify: 1.3.0 just-diff: 6.0.2 lodash-es: 4.17.23 - openai: 4.104.0(ws@8.19.0)(zod@3.24.1) + openai: 4.104.0(ws@8.19.0)(zod@3.25.76) p-map: 7.0.4 pangu: 4.0.7 react: 19.2.4 @@ -10743,6 +11827,8 @@ snapshots: - ws - zod + '@ltd/j-toml@1.38.0': {} + '@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)': dependencies: '@types/mdx': 2.0.13 @@ -10838,13 +11924,13 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + fastq: 1.20.1 - '@nx/cypress@22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)(typescript@5.9.3)': + '@nx/cypress@22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(typescript@5.9.3)': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/eslint': 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/eslint': 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) detect-port: 1.6.1 semver: 7.7.4 @@ -10862,21 +11948,21 @@ snapshots: - typescript - verdaccio - '@nx/devkit@22.5.2(nx@22.5.2)': + '@nx/devkit@22.6.1(nx@22.6.1)': dependencies: '@zkochan/js-yaml': 0.0.7 ejs: 3.1.10 enquirer: 2.3.6 - minimatch: 10.1.1 - nx: 22.5.2 + minimatch: 10.2.4 + nx: 22.6.1 semver: 7.7.4 tslib: 2.8.1 yargs-parser: 21.1.1 - '@nx/eslint@22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)': + '@nx/eslint@22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) eslint: 9.39.1(jiti@2.6.1) semver: 7.7.4 tslib: 2.8.1 @@ -10892,7 +11978,7 @@ snapshots: - supports-color - verdaccio - '@nx/js@22.5.2(@babel/traverse@7.29.0)(nx@22.5.2)': + '@nx/js@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) @@ -10900,9 +11986,9 @@ snapshots: '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) '@babel/preset-env': 7.29.0(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@babel/runtime': 7.28.6 - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/workspace': 22.5.2 + '@babel/runtime': 7.29.2 + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/workspace': 22.6.1 '@zkochan/js-yaml': 0.0.7 babel-plugin-const-enum: 1.2.0(@babel/core@7.29.0) babel-plugin-macros: 3.1.0 @@ -10928,42 +12014,42 @@ snapshots: - nx - supports-color - '@nx/nx-darwin-arm64@22.5.2': + '@nx/nx-darwin-arm64@22.6.1': optional: true - '@nx/nx-darwin-x64@22.5.2': + '@nx/nx-darwin-x64@22.6.1': optional: true - '@nx/nx-freebsd-x64@22.5.2': + '@nx/nx-freebsd-x64@22.6.1': optional: true - '@nx/nx-linux-arm-gnueabihf@22.5.2': + '@nx/nx-linux-arm-gnueabihf@22.6.1': optional: true - '@nx/nx-linux-arm64-gnu@22.5.2': + '@nx/nx-linux-arm64-gnu@22.6.1': optional: true - '@nx/nx-linux-arm64-musl@22.5.2': + '@nx/nx-linux-arm64-musl@22.6.1': optional: true - '@nx/nx-linux-x64-gnu@22.5.2': + '@nx/nx-linux-x64-gnu@22.6.1': optional: true - '@nx/nx-linux-x64-musl@22.5.2': + '@nx/nx-linux-x64-musl@22.6.1': optional: true - '@nx/nx-win32-arm64-msvc@22.5.2': + '@nx/nx-win32-arm64-msvc@22.6.1': optional: true - '@nx/nx-win32-x64-msvc@22.5.2': + '@nx/nx-win32-x64-msvc@22.6.1': optional: true - '@nx/playwright@22.5.2(@babel/traverse@7.29.0)(@playwright/test@1.58.1)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)': + '@nx/playwright@22.6.1(@babel/traverse@7.29.0)(@playwright/test@1.58.1)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/eslint': 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) - minimatch: 10.1.1 + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/eslint': 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) + minimatch: 10.2.4 tslib: 2.8.1 optionalDependencies: '@playwright/test': 1.58.1 @@ -10978,12 +12064,12 @@ snapshots: - supports-color - verdaccio - '@nx/storybook@22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@nx/storybook@22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: - '@nx/cypress': 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2)(typescript@5.9.3) - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/eslint': 22.5.2(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) + '@nx/cypress': 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(typescript@5.9.3) + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/eslint': 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) semver: 7.7.4 storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -11001,11 +12087,11 @@ snapshots: - typescript - verdaccio - '@nx/vite@22.5.2(@babel/traverse@7.29.0)(nx@22.5.2)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': + '@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) - '@nx/vitest': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) + '@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) ajv: 8.18.0 enquirer: 2.3.6 @@ -11025,10 +12111,10 @@ snapshots: - typescript - verdaccio - '@nx/vitest@22.5.2(@babel/traverse@7.29.0)(nx@22.5.2)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': + '@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) - '@nx/js': 22.5.2(@babel/traverse@7.29.0)(nx@22.5.2) + '@nx/devkit': 22.6.1(nx@22.6.1) + '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) semver: 7.7.4 tslib: 2.8.1 @@ -11045,13 +12131,13 @@ snapshots: - typescript - verdaccio - '@nx/workspace@22.5.2': + '@nx/workspace@22.6.1': dependencies: - '@nx/devkit': 22.5.2(nx@22.5.2) + '@nx/devkit': 22.6.1(nx@22.6.1) '@zkochan/js-yaml': 0.0.7 chalk: 4.1.2 enquirer: 2.3.6 - nx: 22.5.2 + nx: 22.6.1 picomatch: 4.0.2 semver: 7.7.4 tslib: 2.8.1 @@ -11139,70 +12225,136 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} - '@oxc-project/runtime@0.115.0': {} + '@oslojs/encoding@1.1.0': {} - '@oxc-project/types@0.115.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.15.0': + '@oxc-parser/binding-android-arm-eabi@0.120.0': optional: true - '@oxc-resolver/binding-android-arm64@11.15.0': + '@oxc-parser/binding-android-arm64@0.120.0': optional: true - '@oxc-resolver/binding-darwin-arm64@11.15.0': + '@oxc-parser/binding-darwin-arm64@0.120.0': optional: true - '@oxc-resolver/binding-darwin-x64@11.15.0': + '@oxc-parser/binding-darwin-x64@0.120.0': optional: true - '@oxc-resolver/binding-freebsd-x64@11.15.0': + '@oxc-parser/binding-freebsd-x64@0.120.0': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.15.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.15.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.15.0': + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.15.0': + '@oxc-parser/binding-linux-arm64-musl@0.120.0': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.15.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.15.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.15.0': + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.15.0': + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.15.0': + '@oxc-parser/binding-linux-x64-gnu@0.120.0': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.15.0': + '@oxc-parser/binding-linux-x64-musl@0.120.0': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.15.0': + '@oxc-parser/binding-openharmony-arm64@0.120.0': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.15.0': + '@oxc-parser/binding-wasm32-wasi@0.120.0': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.15.0': + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': optional: true - '@oxc-resolver/binding-win32-ia32-msvc@11.15.0': + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.15.0': + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + optional: true + + '@oxc-project/runtime@0.115.0': {} + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.120.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + optional: true + + '@oxc-resolver/binding-android-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.19.1': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true '@oxfmt/binding-android-arm-eabi@0.40.0': @@ -11608,6 +12760,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@sec-ant/readable-stream@0.4.1': {} + '@sentry-internal/browser-utils@10.32.1': dependencies: '@sentry/core': 10.32.1 @@ -11712,8 +12866,43 @@ snapshots: optionalDependencies: pinia: 3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)) + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.34.48': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@sparkjsdev/spark@0.1.10': dependencies: fflate: 0.8.2 @@ -11814,7 +13003,7 @@ snapshots: storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.3) - vue-component-type-helpers: 3.2.5 + vue-component-type-helpers: 3.2.6 '@swc/helpers@0.5.17': dependencies: @@ -11905,7 +13094,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -11913,6 +13102,17 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.4 @@ -11926,6 +13126,15 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@testing-library/vue@8.1.0(@vue/compiler-sfc@3.5.28)(vue@3.5.13(typescript@5.9.3))': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 9.3.4 + '@vue/test-utils': 2.4.6 + vue: 3.5.13(typescript@5.9.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.28 + '@tiptap/core@2.27.2(@tiptap/pm@2.27.2)': dependencies: '@tiptap/pm': 2.27.2 @@ -12137,6 +13346,10 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 25.0.3 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/jsdom@21.1.7': dependencies: '@types/node': 25.0.3 @@ -12178,6 +13391,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node-fetch@2.6.13': dependencies: '@types/node': 25.0.3 @@ -12373,6 +13590,8 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -12436,6 +13655,28 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))': + optionalDependencies: + react: 19.2.4 + vue: 3.5.13(typescript@5.9.3) + vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3)) + + '@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.9 + '@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vue: 3.5.13(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + dependencies: + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vue: 3.5.13(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 @@ -12661,6 +13902,18 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.9 + '@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + dependencies: + '@vue/devtools-kit': 7.7.9 + '@vue/devtools-shared': 7.7.9 + mitt: 3.0.1 + nanoid: 5.1.5 + pathe: 2.0.3 + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vue: 3.5.13(typescript@5.9.3) + transitivePeerDependencies: + - vite + '@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.0.5 @@ -12985,6 +14238,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -12997,7 +14254,6 @@ snapshots: dependencies: call-bound: 1.0.4 is-array-buffer: 3.0.5 - optional: true array-includes@3.1.9: dependencies: @@ -13011,6 +14267,8 @@ snapshots: math-intrinsics: 1.1.0 optional: true + array-iterate@2.0.1: {} + array-union@2.1.0: {} array.prototype.findlastindex@1.2.6: @@ -13069,6 +14327,108 @@ snapshots: astral-regex@2.0.0: {} + astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.53.5) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.6.4 + diff: 8.0.3 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.3 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.1 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.3 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.0 + svgo: 4.0.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.4 + vfile: 6.0.3 + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + async-function@1.0.0: optional: true @@ -13085,13 +14445,12 @@ snapshots: automation-events@7.1.11: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 tslib: 2.8.1 available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - optional: true axios@1.13.5: dependencies: @@ -13101,6 +14460,8 @@ snapshots: transitivePeerDependencies: - debug + axobject-query@4.1.0: {} + babel-plugin-const-enum@1.2.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -13112,7 +14473,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 cosmiconfig: 7.1.0 resolve: 1.22.11 @@ -13167,6 +14528,8 @@ snapshots: balanced-match@4.0.3: {} + base-64@1.0.0: {} + base64-arraybuffer@1.0.2: {} base64-js@1.5.1: {} @@ -13202,7 +14565,7 @@ snapshots: widest-line: 5.0.0 wrap-ansi: 9.0.2 - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -13221,7 +14584,7 @@ snapshots: broker-factory@3.1.7: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 fast-unique-numbers: 9.0.22 tslib: 2.8.1 worker-factory: 7.0.43 @@ -13317,6 +14680,10 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} character-parser@2.2.0: @@ -13349,6 +14716,8 @@ snapshots: dependencies: readdirp: 5.0.0 + ci-info@4.4.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -13417,14 +14786,14 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} commander@11.1.0: {} commander@13.1.0: {} - commander@14.0.2: {} - commander@14.0.3: {} commander@2.20.3: {} @@ -13433,6 +14802,8 @@ snapshots: comment-parser@1.4.1: {} + common-ancestor-path@1.0.1: {} + compare-versions@6.1.1: {} concat-map@0.0.1: {} @@ -13480,6 +14851,10 @@ snapshots: convert-to-spaces@2.0.1: {} + cookie-es@1.2.2: {} + + cookie@1.1.1: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 @@ -13522,6 +14897,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + css-functions-list@3.2.3: {} css-select@4.3.0: @@ -13623,6 +15002,27 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -13655,7 +15055,6 @@ snapshots: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - optional: true defu@6.1.4: {} @@ -13674,6 +15073,12 @@ snapshots: transitivePeerDependencies: - supports-color + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 + + devalue@5.6.4: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -13690,6 +15095,8 @@ snapshots: unescape-js: 1.1.4 utf8: 3.0.0 + dlv@1.1.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -13760,6 +15167,8 @@ snapshots: dotenv@17.3.1: {} + dset@3.1.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13818,6 +15227,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@0.1.5: {} + error-stack-parser-es@1.0.5: {} es-abstract@1.24.1: @@ -13882,6 +15293,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: @@ -13989,7 +15412,7 @@ snapshots: eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.6 stable-hash-x: 0.2.0 optionalDependencies: unrs-resolver: 1.11.1 @@ -14008,7 +15431,7 @@ snapshots: debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash-x: 0.2.0 tinyglobby: 0.2.15 @@ -14056,7 +15479,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.4 semver: 7.7.4 stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 @@ -14081,7 +15504,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -14109,6 +15532,15 @@ snapshots: - supports-color - typescript + eslint-plugin-testing-library@7.16.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/scope-manager': 8.56.0 + '@typescript-eslint/utils': 8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -14179,7 +15611,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -14231,7 +15663,22 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 expect-type@1.3.0: {} @@ -14245,27 +15692,27 @@ snapshots: extendable-media-recorder-wav-encoder-broker@7.0.119: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 broker-factory: 3.1.7 extendable-media-recorder-wav-encoder-worker: 8.0.116 tslib: 2.8.1 extendable-media-recorder-wav-encoder-worker@8.0.116: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 tslib: 2.8.1 worker-factory: 7.0.43 extendable-media-recorder-wav-encoder@7.0.129: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 extendable-media-recorder-wav-encoder-broker: 7.0.119 extendable-media-recorder-wav-encoder-worker: 8.0.116 tslib: 2.8.1 extendable-media-recorder@9.2.27: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 media-encoder-host: 9.0.20 multi-buffer-data-view: 6.0.22 recorder-audio-worklet: 6.0.48 @@ -14293,14 +15740,14 @@ snapshots: fast-unique-numbers@9.0.22: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 tslib: 2.8.1 fast-uri@3.1.0: {} fastest-levenshtein@1.0.16: {} - fastq@1.19.1: + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -14401,12 +15848,21 @@ snapshots: flatted@3.3.3: {} + flattie@1.1.1: {} + follow-redirects@1.15.11: {} + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + for-each@0.3.5: dependencies: is-callable: 1.2.7 - optional: true foreground-child@3.3.1: dependencies: @@ -14472,8 +15928,7 @@ snapshots: is-callable: 1.2.7 optional: true - functions-have-names@1.2.3: - optional: true + functions-have-names@1.2.3: {} fuse.js@7.0.0: {} @@ -14504,6 +15959,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -14511,7 +15971,7 @@ snapshots: get-intrinsic: 1.3.0 optional: true - get-tsconfig@4.10.1: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -14524,6 +15984,8 @@ snapshots: nypm: 0.6.5 pathe: 2.0.3 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -14543,7 +16005,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.3 path-scurry: 2.0.2 @@ -14604,14 +16066,25 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + h3@1.15.10: + dependencies: + cookie-es: 1.2.2 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.3 + uncrypto: 0.1.3 + happy-dom@20.0.11: dependencies: '@types/node': 20.19.27 '@types/whatwg-mimetype': 3.0.2 whatwg-mimetype: 3.0.0 - has-bigints@1.1.0: - optional: true + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -14640,6 +16113,93 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + he@1.2.0: {} hookable@5.5.3: {} @@ -14654,6 +16214,8 @@ snapshots: html-escaper@2.0.2: {} + html-escaper@3.0.3: {} + html-minifier-terser@6.1.0: dependencies: camel-case: 4.1.2 @@ -14666,6 +16228,8 @@ snapshots: html-tags@3.3.1: {} + html-void-elements@3.0.0: {} + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -14673,6 +16237,8 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-cache-semantics@4.2.0: {} + http-parser-js@0.5.10: {} http-proxy-agent@7.0.2: @@ -14696,6 +16262,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -14719,6 +16287,8 @@ snapshots: import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -14770,14 +16340,19 @@ snapshots: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 - optional: true + + iron-webcrypto@1.2.1: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 get-intrinsic: 1.3.0 - optional: true is-arrayish@0.2.1: {} @@ -14793,7 +16368,6 @@ snapshots: is-bigint@1.1.0: dependencies: has-bigints: 1.1.0 - optional: true is-binary-path@2.1.0: dependencies: @@ -14803,14 +16377,12 @@ snapshots: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - optional: true is-bun-module@2.0.0: dependencies: semver: 7.7.4 - is-callable@1.2.7: - optional: true + is-callable@1.2.7: {} is-core-module@2.16.1: dependencies: @@ -14827,7 +16399,6 @@ snapshots: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - optional: true is-docker@2.2.1: {} @@ -14885,10 +16456,9 @@ snapshots: is-language-code@3.1.0: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 - is-map@2.0.3: - optional: true + is-map@2.0.3: {} is-negative-zero@2.0.3: optional: true @@ -14899,7 +16469,6 @@ snapshots: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - optional: true is-number@7.0.0: {} @@ -14920,26 +16489,24 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - is-set@2.0.3: - optional: true + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 - optional: true + + is-stream@4.0.1: {} is-string@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - optional: true is-symbol@1.1.1: dependencies: call-bound: 1.0.4 has-symbols: 1.1.0 safe-regex-test: 1.1.0 - optional: true is-typed-array@1.1.15: dependencies: @@ -14950,8 +16517,7 @@ snapshots: is-unicode-supported@2.1.0: {} - is-weakmap@2.0.2: - optional: true + is-weakmap@2.0.2: {} is-weakref@1.1.1: dependencies: @@ -14962,7 +16528,6 @@ snapshots: dependencies: call-bound: 1.0.4 get-intrinsic: 1.3.0 - optional: true is-what@4.1.16: {} @@ -15014,7 +16579,7 @@ snapshots: async: 3.2.5 chalk: 4.1.2 filelist: 1.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 jest-diff@30.2.0: dependencies: @@ -15160,22 +16725,25 @@ snapshots: kind-of@6.0.3: {} - knip@5.75.1(@types/node@24.10.4)(typescript@5.9.3): + kleur@3.0.3: {} + + knip@6.0.1: dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.10.4 fast-glob: 3.3.3 formatly: 0.3.0 + get-tsconfig: 4.13.6 jiti: 2.6.1 - js-yaml: 4.1.1 minimist: 1.2.8 - oxc-resolver: 11.15.0 + oxc-parser: 0.120.0 + oxc-resolver: 11.19.1 picocolors: 1.1.1 picomatch: 4.0.3 - smol-toml: 1.5.2 + smol-toml: 1.6.0 strip-json-comments: 5.0.3 - typescript: 5.9.3 - zod: 4.2.1 + unbash: 2.2.0 + yaml: 2.8.2 + zod: 4.3.6 known-css-properties@0.37.0: {} @@ -15310,21 +16878,20 @@ snapshots: linkifyjs@4.3.2: {} - lint-staged@16.2.7: + lint-staged@16.4.0: dependencies: - commander: 14.0.2 + commander: 14.0.3 listr2: 9.0.5 - micromatch: 4.0.8 - nano-spawn: 2.0.0 - pidtree: 0.6.0 + picomatch: 4.0.3 string-argv: 0.3.2 + tinyexec: 1.0.4 yaml: 2.8.2 listr2@9.0.5: dependencies: cli-truncate: 5.1.1 colorette: 2.0.20 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 log-update: 6.1.0 rfdc: 1.4.1 wrap-ansi: 9.0.2 @@ -15433,6 +17000,12 @@ snapshots: mathml-tag-names@2.1.3: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -15530,6 +17103,18 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -15556,7 +17141,7 @@ snapshots: media-encoder-host-broker@8.0.19: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 broker-factory: 3.1.7 fast-unique-numbers: 9.0.22 media-encoder-host-worker: 10.0.19 @@ -15564,14 +17149,14 @@ snapshots: media-encoder-host-worker@10.0.19: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 extendable-media-recorder-wav-encoder-broker: 7.0.119 tslib: 2.8.1 worker-factory: 7.0.43 media-encoder-host@9.0.20: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 media-encoder-host-broker: 8.0.19 media-encoder-host-worker: 10.0.19 tslib: 2.8.1 @@ -15797,21 +17382,17 @@ snapshots: min-indent@1.0.1: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - minimatch@10.2.1: dependencies: brace-expansion: 5.0.2 - minimatch@10.2.2: + minimatch@10.2.4: dependencies: brace-expansion: 5.0.2 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@5.1.6: dependencies: @@ -15846,7 +17427,7 @@ snapshots: acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.6.1 + ufo: 1.6.3 modern-tar@0.7.3: {} @@ -15858,11 +17439,9 @@ snapshots: multi-buffer-data-view@6.0.22: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 tslib: 2.8.1 - nano-spawn@2.0.0: {} - nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -15871,6 +17450,12 @@ snapshots: natural-compare@1.4.0: {} + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -15889,7 +17474,7 @@ snapshots: css-select: 4.3.0 he: 1.2.0 - node-machine-id@1.1.12: {} + node-mock-http@1.0.4: {} node-releases@2.0.27: {} @@ -15903,12 +17488,18 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 - nx@22.5.2: + nx@22.6.1: dependencies: + '@ltd/j-toml': 1.38.0 '@napi-rs/wasm-runtime': 0.2.4 '@yarnpkg/lockfile': 1.1.0 '@yarnpkg/parsers': 3.0.2 @@ -15928,8 +17519,7 @@ snapshots: jest-diff: 30.2.0 jsonc-parser: 3.2.0 lines-and-columns: 2.0.3 - minimatch: 10.1.1 - node-machine-id: 1.1.12 + minimatch: 10.2.4 npm-run-path: 4.0.1 open: 8.4.2 ora: 5.3.0 @@ -15946,16 +17536,16 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 optionalDependencies: - '@nx/nx-darwin-arm64': 22.5.2 - '@nx/nx-darwin-x64': 22.5.2 - '@nx/nx-freebsd-x64': 22.5.2 - '@nx/nx-linux-arm-gnueabihf': 22.5.2 - '@nx/nx-linux-arm64-gnu': 22.5.2 - '@nx/nx-linux-arm64-musl': 22.5.2 - '@nx/nx-linux-x64-gnu': 22.5.2 - '@nx/nx-linux-x64-musl': 22.5.2 - '@nx/nx-win32-arm64-msvc': 22.5.2 - '@nx/nx-win32-x64-msvc': 22.5.2 + '@nx/nx-darwin-arm64': 22.6.1 + '@nx/nx-darwin-x64': 22.6.1 + '@nx/nx-freebsd-x64': 22.6.1 + '@nx/nx-linux-arm-gnueabihf': 22.6.1 + '@nx/nx-linux-arm64-gnu': 22.6.1 + '@nx/nx-linux-arm64-musl': 22.6.1 + '@nx/nx-linux-x64-gnu': 22.6.1 + '@nx/nx-linux-x64-musl': 22.6.1 + '@nx/nx-win32-arm64-msvc': 22.6.1 + '@nx/nx-win32-x64-msvc': 22.6.1 transitivePeerDependencies: - debug @@ -15963,12 +17553,16 @@ snapshots: dependencies: citty: 0.2.1 pathe: 2.0.3 - tinyexec: 1.0.2 + tinyexec: 1.0.4 object-assign@4.1.1: {} - object-inspect@1.13.4: - optional: true + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 object-keys@1.1.1: {} @@ -15980,7 +17574,6 @@ snapshots: es-object-atoms: 1.1.1 has-symbols: 1.1.0 object-keys: 1.1.1 - optional: true object.fromentries@2.0.8: dependencies: @@ -16007,6 +17600,12 @@ snapshots: obug@2.1.1: {} + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + ohash@2.0.11: {} once@1.4.0: @@ -16021,6 +17620,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -16043,7 +17650,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(ws@8.19.0)(zod@3.24.1): + openai@4.104.0(ws@8.19.0)(zod@3.25.76): dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 @@ -16054,7 +17661,7 @@ snapshots: node-fetch: 2.7.0 optionalDependencies: ws: 8.19.0 - zod: 3.24.1 + zod: 3.25.76 transitivePeerDependencies: - encoding @@ -16087,28 +17694,53 @@ snapshots: safe-push-apply: 1.0.0 optional: true - oxc-resolver@11.15.0: + oxc-parser@0.120.0: + dependencies: + '@oxc-project/types': 0.120.0 optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.15.0 - '@oxc-resolver/binding-android-arm64': 11.15.0 - '@oxc-resolver/binding-darwin-arm64': 11.15.0 - '@oxc-resolver/binding-darwin-x64': 11.15.0 - '@oxc-resolver/binding-freebsd-x64': 11.15.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.15.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.15.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.15.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.15.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.15.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.15.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.15.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.15.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.15.0 - '@oxc-resolver/binding-linux-x64-musl': 11.15.0 - '@oxc-resolver/binding-openharmony-arm64': 11.15.0 - '@oxc-resolver/binding-wasm32-wasi': 11.15.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.15.0 - '@oxc-resolver/binding-win32-ia32-msvc': 11.15.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.15.0 + '@oxc-parser/binding-android-arm-eabi': 0.120.0 + '@oxc-parser/binding-android-arm64': 0.120.0 + '@oxc-parser/binding-darwin-arm64': 0.120.0 + '@oxc-parser/binding-darwin-x64': 0.120.0 + '@oxc-parser/binding-freebsd-x64': 0.120.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 + '@oxc-parser/binding-linux-arm64-musl': 0.120.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-musl': 0.120.0 + '@oxc-parser/binding-openharmony-arm64': 0.120.0 + '@oxc-parser/binding-wasm32-wasi': 0.120.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 + '@oxc-parser/binding-win32-x64-msvc': 0.120.0 + + oxc-resolver@11.19.1: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.19.1 + '@oxc-resolver/binding-android-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-x64': 11.19.1 + '@oxc-resolver/binding-freebsd-x64': 11.19.1 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 + '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-musl': 11.19.1 + '@oxc-resolver/binding-openharmony-arm64': 11.19.1 + '@oxc-resolver/binding-wasm32-wasi': 11.19.1 + '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 + '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 + '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 oxfmt@0.40.0: dependencies: @@ -16170,12 +17802,23 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 p-map@7.0.4: {} + p-queue@8.1.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 6.1.4 + + p-timeout@6.1.4: {} + package-json-from-dist@1.0.1: {} package-json@10.0.1: @@ -16207,6 +17850,17 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse-ms@4.0.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -16228,6 +17882,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -16254,6 +17910,8 @@ snapshots: perfect-debounce@2.0.0: {} + piccolore@0.1.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -16262,8 +17920,6 @@ snapshots: picomatch@4.0.3: {} - pidtree@0.6.0: {} - pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)): dependencies: '@vue/devtools-api': 7.7.9 @@ -16291,8 +17947,7 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - possible-typed-array-names@1.1.0: - optional: true + possible-typed-array-names@1.1.0: {} postcss-html@1.8.0: dependencies: @@ -16363,6 +18018,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + primeicons@7.0.0: {} primevue@4.2.5(vue@3.5.13(typescript@5.9.3)): @@ -16374,6 +18033,8 @@ snapshots: transitivePeerDependencies: - vue + prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} progress@2.0.3: {} @@ -16382,6 +18043,13 @@ snapshots: dependencies: asap: 2.0.6 + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.1.0: {} + prosemirror-changeset@2.4.0: dependencies: prosemirror-transform: 1.11.0 @@ -16591,6 +18259,8 @@ snapshots: queue-microtask@1.2.3: {} + radix3@1.1.2: {} + raf-schd@4.0.3: {} rc9@2.1.2: @@ -16655,12 +18325,12 @@ snapshots: recorder-audio-worklet-processor@5.0.35: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 tslib: 2.8.1 recorder-audio-worklet@6.0.48: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 broker-factory: 3.1.7 fast-unique-numbers: 9.0.22 recorder-audio-worklet-processor: 5.0.35 @@ -16692,6 +18362,16 @@ snapshots: regenerate@1.4.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -16700,7 +18380,6 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 set-function-name: 2.0.2 - optional: true regexpu-core@6.4.0: dependencies: @@ -16725,6 +18404,31 @@ snapshots: dependencies: jsesc: 3.1.0 + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + reka-ui@2.5.0(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)): dependencies: '@floating-ui/dom': 1.7.4 @@ -16773,6 +18477,21 @@ snapshots: transitivePeerDependencies: - supports-color + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -16812,6 +18531,31 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -16910,7 +18654,6 @@ snapshots: call-bound: 1.0.4 es-errors: 1.3.0 is-regex: 1.2.1 - optional: true sax@1.4.4: {} @@ -16950,7 +18693,6 @@ snapshots: es-errors: 1.3.0 functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 - optional: true set-proto@1.0.0: dependencies: @@ -16961,17 +18703,59 @@ snapshots: setimmediate@1.0.5: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - optional: true side-channel-map@1.0.1: dependencies: @@ -16979,7 +18763,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 object-inspect: 1.13.4 - optional: true side-channel-weakmap@1.0.2: dependencies: @@ -16988,7 +18771,6 @@ snapshots: get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1 - optional: true side-channel@1.1.0: dependencies: @@ -16997,7 +18779,6 @@ snapshots: side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - optional: true siginfo@2.0.0: {} @@ -17011,6 +18792,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + slash@3.0.0: {} slice-ansi@4.0.0: @@ -17029,7 +18812,7 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smol-toml@1.5.2: {} + smol-toml@1.6.0: {} source-map-js@1.2.1: {} @@ -17047,6 +18830,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + speakingurl@14.0.1: {} sprintf-js@1.0.3: {} @@ -17063,7 +18848,7 @@ snapshots: standardized-audio-context@25.3.77: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 automation-events: 7.1.11 tslib: 2.8.1 @@ -17073,7 +18858,6 @@ snapshots: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 - optional: true storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: @@ -17159,6 +18943,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -17171,6 +18960,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@4.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -17234,7 +19025,7 @@ snapshots: subscribable-things@2.1.53: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 rxjs-interop: 2.0.0 tslib: 2.8.1 @@ -17327,6 +19118,8 @@ snapshots: three@0.170.0: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -17337,7 +19130,7 @@ snapshots: tinyest@0.1.2: {} - tinyexec@1.0.2: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: dependencies: @@ -17398,6 +19191,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + trough@2.2.0: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -17408,6 +19203,10 @@ snapshots: ts-map@1.0.3: {} + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -17434,7 +19233,7 @@ snapshots: tsx@4.19.4: dependencies: esbuild: 0.25.5 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -17513,10 +19312,14 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.6.1: {} + ufo@1.6.3: {} uint8array-extras@1.5.0: {} + ultrahtml@1.6.0: {} + + unbash@2.2.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -17525,6 +19328,8 @@ snapshots: which-boxed-primitive: 1.1.1 optional: true + uncrypto@0.1.3: {} + undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -17546,6 +19351,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -17556,14 +19363,43 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unifont@0.7.4: + dependencies: + css-tree: 3.1.0 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 @@ -17661,6 +19497,17 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unstorage@1.17.4: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.10 + lru-cache: 11.2.6 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -17700,6 +19547,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -17781,6 +19633,22 @@ snapshots: pathe: 0.2.0 vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.3.0(rollup@4.53.5) + debug: 4.4.3 + error-stack-parser-es: 0.1.5 + fs-extra: 11.3.2 + open: 10.2.0 + perfect-debounce: 1.0.0 + picocolors: 1.1.1 + sirv: 3.0.2 + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + transitivePeerDependencies: + - rollup + - supports-color + vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): dependencies: ansis: 4.2.0 @@ -17811,6 +19679,22 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)): + dependencies: + '@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vue/devtools-kit': 7.7.9 + '@vue/devtools-shared': 7.7.9 + execa: 9.6.1 + sirv: 3.0.2 + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + transitivePeerDependencies: + - '@nuxt/kit' + - rollup + - supports-color + - vue + vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)): dependencies: '@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) @@ -17903,6 +19787,10 @@ snapshots: tsx: 4.19.4 yaml: 2.8.2 + vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + optionalDependencies: + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.16 @@ -17920,7 +19808,7 @@ snapshots: picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) @@ -17960,7 +19848,7 @@ snapshots: vue-component-type-helpers@2.2.12: {} - vue-component-type-helpers@3.2.5: {} + vue-component-type-helpers@3.2.6: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)): dependencies: @@ -18046,6 +19934,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + web-streams-polyfill@4.0.0-beta.3: {} web-vitals@4.2.4: {} @@ -18093,7 +19983,6 @@ snapshots: is-number-object: 1.1.1 is-string: 1.1.1 is-symbol: 1.1.1 - optional: true which-builtin-type@1.2.1: dependencies: @@ -18118,7 +20007,8 @@ snapshots: is-set: 2.0.3 is-weakmap: 2.0.2 is-weakset: 2.0.4 - optional: true + + which-pm-runs@1.1.0: {} which-typed-array@1.1.20: dependencies: @@ -18129,7 +20019,6 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-tostringtag: 1.0.2 - optional: true which@1.3.1: dependencies: @@ -18163,7 +20052,7 @@ snapshots: worker-factory@7.0.43: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 fast-unique-numbers: 9.0.22 tslib: 2.8.1 @@ -18224,6 +20113,8 @@ snapshots: xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -18257,6 +20148,14 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zip-dir@2.0.0: @@ -18264,17 +20163,22 @@ snapshots: async: 3.2.5 jszip: 3.10.1 - zod-to-json-schema@3.24.1(zod@3.24.1): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: - zod: 3.24.1 + zod: 3.25.76 - zod-validation-error@3.3.0(zod@3.24.1): + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: - zod: 3.24.1 + typescript: 5.9.3 + zod: 3.25.76 - zod@3.24.1: {} + zod-validation-error@3.3.0(zod@3.25.76): + dependencies: + zod: 3.25.76 - zod@4.2.1: {} + zod@3.25.76: {} + + zod@4.3.6: {} zustand@5.0.11(@types/react@19.1.9)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 127998bfba..f94512d0d2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: '@alloc/quick-lru': ^5.2.0 + '@astrojs/vue': ^5.0.0 '@comfyorg/comfyui-electron-types': 0.6.2 '@eslint/js': ^9.39.1 '@formkit/auto-animate': ^0.9.0 @@ -13,10 +14,10 @@ catalog: '@iconify/utils': ^3.1.0 '@intlify/eslint-plugin-vue-i18n': ^4.1.1 '@lobehub/i18n-cli': ^1.26.1 - '@nx/eslint': 22.5.2 - '@nx/playwright': 22.5.2 - '@nx/storybook': 22.5.2 - '@nx/vite': 22.5.2 + '@nx/eslint': 22.6.1 + '@nx/playwright': 22.6.1 + '@nx/storybook': 22.6.1 + '@nx/vite': 22.6.1 '@pinia/testing': ^1.0.3 '@playwright/test': ^1.58.1 '@primeuix/forms': 0.0.2 @@ -34,6 +35,9 @@ catalog: '@storybook/vue3': ^10.2.10 '@storybook/vue3-vite': ^10.2.10 '@tailwindcss/vite': ^4.2.0 + '@testing-library/jest-dom': ^6.9.1 + '@testing-library/user-event': ^14.6.1 + '@testing-library/vue': ^8.1.0 '@tiptap/core': ^2.27.2 '@tiptap/extension-link': ^2.27.2 '@tiptap/extension-table': ^2.27.2 @@ -47,6 +51,7 @@ catalog: '@types/node': ^24.1.0 '@types/semver': ^7.7.0 '@types/three': ^0.169.0 + '@vercel/analytics': ^2.0.1 '@vitejs/plugin-vue': ^6.0.0 '@vitest/coverage-v8': ^4.0.16 '@vitest/ui': ^4.0.16 @@ -55,6 +60,7 @@ catalog: '@vueuse/integrations': ^14.2.0 '@webgpu/types': ^0.1.66 algoliasearch: ^5.21.0 + astro: ^5.10.0 axios: ^1.13.5 cross-env: ^10.1.0 cva: 1.0.0-beta.4 @@ -67,6 +73,7 @@ catalog: eslint-plugin-import-x: ^4.16.1 eslint-plugin-oxlint: 1.55.0 eslint-plugin-storybook: ^10.2.10 + eslint-plugin-testing-library: ^7.16.1 eslint-plugin-unused-imports: ^4.3.0 eslint-plugin-vue: ^10.6.2 fast-check: ^4.5.3 @@ -79,11 +86,11 @@ catalog: jsdom: ^27.4.0 jsonata: ^2.1.0 jsondiffpatch: ^0.7.3 - knip: ^5.75.1 + knip: ^6.0.1 lint-staged: ^16.2.7 markdown-table: ^3.0.4 mixpanel-browser: ^2.71.0 - nx: 22.5.2 + nx: 22.6.1 oxfmt: ^0.40.0 oxlint: ^1.55.0 oxlint-tsgolint: ^0.17.0 diff --git a/scripts/perf-report.ts b/scripts/perf-report.ts index 299fa5f64c..12b78ca5ec 100644 --- a/scripts/perf-report.ts +++ b/scripts/perf-report.ts @@ -22,6 +22,7 @@ interface PerfMeasurement { layoutDurationMs: number taskDurationMs: number heapDeltaBytes: number + heapUsedBytes: number domNodes: number jsHeapTotalBytes: number scriptDurationMs: number @@ -43,22 +44,46 @@ const HISTORY_DIR = 'temp/perf-history' type MetricKey = | 'styleRecalcs' + | 'styleRecalcDurationMs' | 'layouts' + | 'layoutDurationMs' | 'taskDurationMs' | 'domNodes' | 'scriptDurationMs' | 'eventListeners' | 'totalBlockingTimeMs' | 'frameDurationMs' -const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [ - { key: 'styleRecalcs', label: 'style recalcs', unit: '' }, - { key: 'layouts', label: 'layouts', unit: '' }, + | 'heapUsedBytes' + +interface MetricDef { + key: MetricKey + label: string + unit: string + /** Minimum absolute delta to consider meaningful (effect size gate) */ + minAbsDelta?: number +} + +const REPORTED_METRICS: MetricDef[] = [ + { key: 'layoutDurationMs', label: 'layout duration', unit: 'ms' }, + { + key: 'styleRecalcDurationMs', + label: 'style recalc duration', + unit: 'ms' + }, + { key: 'layouts', label: 'layout count', unit: '', minAbsDelta: 5 }, + { + key: 'styleRecalcs', + label: 'style recalc count', + unit: '', + minAbsDelta: 5 + }, { key: 'taskDurationMs', label: 'task duration', unit: 'ms' }, - { key: 'domNodes', label: 'DOM nodes', unit: '' }, { key: 'scriptDurationMs', label: 'script duration', unit: 'ms' }, - { key: 'eventListeners', label: 'event listeners', unit: '' }, { key: 'totalBlockingTimeMs', label: 'TBT', unit: 'ms' }, - { key: 'frameDurationMs', label: 'frame duration', unit: 'ms' } + { key: 'frameDurationMs', label: 'frame duration', unit: 'ms' }, + { key: 'heapUsedBytes', label: 'heap used', unit: 'bytes' }, + { key: 'domNodes', label: 'DOM nodes', unit: '', minAbsDelta: 5 }, + { key: 'eventListeners', label: 'event listeners', unit: '', minAbsDelta: 5 } ] function groupByName( @@ -134,7 +159,9 @@ function computeCV(stats: MetricStats): number { } function formatValue(value: number, unit: string): string { - return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}` + if (unit === 'ms') return `${value.toFixed(0)}ms` + if (unit === 'bytes') return formatBytes(value) + return `${value.toFixed(0)}` } function formatDelta(pct: number | null): string { @@ -159,6 +186,21 @@ function meanMetric(samples: PerfMeasurement[], key: MetricKey): number | null { return values.reduce((sum, v) => sum + v, 0) / values.length } +function medianMetric( + samples: PerfMeasurement[], + key: MetricKey +): number | null { + const values = samples + .map((s) => getMetricValue(s, key)) + .filter((v): v is number => v !== null) + .sort((a, b) => a - b) + if (values.length === 0) return null + const mid = Math.floor(values.length / 2) + return values.length % 2 === 0 + ? (values[mid - 1] + values[mid]) / 2 + : values[mid] +} + function formatBytes(bytes: number): string { if (Math.abs(bytes) < 1024) return `${bytes} B` if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` @@ -173,7 +215,7 @@ function renderFullReport( const lines: string[] = [] const baselineGroups = groupByName(baseline.measurements) const tableHeader = [ - '| Metric | Baseline | PR (n=3) | Δ | Sig |', + '| Metric | Baseline | PR (median) | Δ | Sig |', '|--------|----------|----------|---|-----|' ] @@ -183,36 +225,38 @@ function renderFullReport( for (const [testName, prSamples] of prGroups) { const baseSamples = baselineGroups.get(testName) - for (const { key, label, unit } of REPORTED_METRICS) { - const prMean = meanMetric(prSamples, key) - if (prMean === null) continue + for (const { key, label, unit, minAbsDelta } of REPORTED_METRICS) { + // Use median for PR values — robust to outlier runs in CI + const prVal = medianMetric(prSamples, key) + if (prVal === null) continue const histStats = getHistoricalStats(historical, testName, key) const cv = computeCV(histStats) if (!baseSamples?.length) { allRows.push( - `| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |` + `| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |` ) continue } - const baseVal = meanMetric(baseSamples, key) + const baseVal = medianMetric(baseSamples, key) if (baseVal === null) { allRows.push( - `| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |` + `| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new | — |` ) continue } + const absDelta = prVal - baseVal const deltaPct = baseVal === 0 - ? prMean === 0 + ? prVal === 0 ? 0 : null - : ((prMean - baseVal) / baseVal) * 100 - const z = zScore(prMean, histStats) - const sig = classifyChange(z, cv) + : ((prVal - baseVal) / baseVal) * 100 + const z = zScore(prVal, histStats) + const sig = classifyChange(z, cv, absDelta, minAbsDelta) - const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |` + const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |` allRows.push(row) if (isNoteworthy(sig)) { flaggedRows.push(row) @@ -299,7 +343,7 @@ function renderColdStartReport( const lines: string[] = [] const baselineGroups = groupByName(baseline.measurements) lines.push( - `> ℹ️ Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`, + `> ℹ️ Collecting baseline variance data (${historicalCount}/15 runs). Significance will appear after 2 main branch runs.`, '', '| Metric | Baseline | PR | Δ |', '|--------|----------|-----|---|' @@ -309,31 +353,31 @@ function renderColdStartReport( const baseSamples = baselineGroups.get(testName) for (const { key, label, unit } of REPORTED_METRICS) { - const prMean = meanMetric(prSamples, key) - if (prMean === null) continue + const prVal = medianMetric(prSamples, key) + if (prVal === null) continue if (!baseSamples?.length) { lines.push( - `| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |` + `| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |` ) continue } - const baseVal = meanMetric(baseSamples, key) + const baseVal = medianMetric(baseSamples, key) if (baseVal === null) { lines.push( - `| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |` + `| ${testName}: ${label} | — | ${formatValue(prVal, unit)} | new |` ) continue } const deltaPct = baseVal === 0 - ? prMean === 0 + ? prVal === 0 ? 0 : null - : ((prMean - baseVal) / baseVal) * 100 + : ((prVal - baseVal) / baseVal) * 100 lines.push( - `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |` + `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prVal, unit)} | ${formatDelta(deltaPct)} |` ) } } @@ -352,14 +396,10 @@ function renderNoBaselineReport( ) for (const [testName, prSamples] of prGroups) { for (const { key, label, unit } of REPORTED_METRICS) { - const prMean = meanMetric(prSamples, key) - if (prMean === null) continue - lines.push(`| ${testName}: ${label} | ${formatValue(prMean, unit)} |`) + const prVal = medianMetric(prSamples, key) + if (prVal === null) continue + lines.push(`| ${testName}: ${label} | ${formatValue(prVal, unit)} |`) } - const heapMean = - prSamples.reduce((sum, s) => sum + (s.heapDeltaBytes ?? 0), 0) / - prSamples.length - lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`) } return lines } diff --git a/scripts/perf-stats.test.ts b/scripts/perf-stats.test.ts index 8b43fa3aea..36b7f8b5bb 100644 --- a/scripts/perf-stats.test.ts +++ b/scripts/perf-stats.test.ts @@ -99,6 +99,21 @@ describe('classifyChange', () => { expect(classifyChange(2, 10)).toBe('neutral') expect(classifyChange(-2, 10)).toBe('neutral') }) + + it('returns neutral when absDelta below minAbsDelta despite high z', () => { + // z=7.2 but only 1 unit change with minAbsDelta=5 + expect(classifyChange(7.2, 10, 1, 5)).toBe('neutral') + expect(classifyChange(-7.2, 10, -1, 5)).toBe('neutral') + }) + + it('returns regression when absDelta meets minAbsDelta', () => { + expect(classifyChange(3, 10, 10, 5)).toBe('regression') + }) + + it('ignores effect size gate when minAbsDelta not provided', () => { + expect(classifyChange(3, 10)).toBe('regression') + expect(classifyChange(3, 10, 1)).toBe('regression') + }) }) describe('formatSignificance', () => { diff --git a/scripts/perf-stats.ts b/scripts/perf-stats.ts index da1a5ed856..74dd2721f9 100644 --- a/scripts/perf-stats.ts +++ b/scripts/perf-stats.ts @@ -31,12 +31,28 @@ export function zScore(value: number, stats: MetricStats): number | null { export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy' +/** + * Classify a metric change as regression/improvement/neutral/noisy. + * + * Uses both statistical significance (z-score) and practical significance + * (effect size gate via minAbsDelta) to reduce false positives from + * integer-quantized metrics with near-zero variance. + */ export function classifyChange( z: number | null, - historicalCV: number + historicalCV: number, + absDelta?: number, + minAbsDelta?: number ): Significance { if (historicalCV > 50) return 'noisy' if (z === null) return 'neutral' + + // Effect size gate: require minimum absolute change for count metrics + // to avoid flagging e.g. 11→12 style recalcs as z=7.2 regression. + if (minAbsDelta !== undefined && absDelta !== undefined) { + if (Math.abs(absDelta) < minAbsDelta) return 'neutral' + } + if (z > 2) return 'regression' if (z < -2) return 'improvement' return 'neutral' diff --git a/src/App.vue b/src/App.vue index cfb13600db..acc8fdb237 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,23 +7,17 @@ diff --git a/src/assets/palettes/arc.json b/src/assets/palettes/arc.json index 756491ae5f..3ba1a529ed 100644 --- a/src/assets/palettes/arc.json +++ b/src/assets/palettes/arc.json @@ -34,9 +34,7 @@ "CLEAR_BACKGROUND_COLOR": "#2b2f38", "NODE_TITLE_COLOR": "#b2b7bd", "NODE_SELECTED_TITLE_COLOR": "#FFF", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#AAA", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#2b2f38", "NODE_DEFAULT_BGCOLOR": "#242730", "NODE_DEFAULT_BOXCOLOR": "#6e7581", @@ -45,7 +43,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)", - "DEFAULT_GROUP_FONT": 22, "WIDGET_BGCOLOR": "#2b2f38", "WIDGET_OUTLINE_COLOR": "#6e7581", "WIDGET_TEXT_COLOR": "#DDD", diff --git a/src/assets/palettes/dark.json b/src/assets/palettes/dark.json index 1809aab438..9fe83bbfdd 100644 --- a/src/assets/palettes/dark.json +++ b/src/assets/palettes/dark.json @@ -25,10 +25,8 @@ "CLEAR_BACKGROUND_COLOR": "#141414", "NODE_TITLE_COLOR": "#999", "NODE_SELECTED_TITLE_COLOR": "#FFF", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#AAA", "NODE_TEXT_HIGHLIGHT_COLOR": "#FFF", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#333", "NODE_DEFAULT_BGCOLOR": "#353535", "NODE_DEFAULT_BOXCOLOR": "#666", @@ -37,7 +35,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)", - "DEFAULT_GROUP_FONT": 24, "WIDGET_BGCOLOR": "#222", "WIDGET_OUTLINE_COLOR": "#666", "WIDGET_TEXT_COLOR": "#DDD", diff --git a/src/assets/palettes/github.json b/src/assets/palettes/github.json index 91a0a62efd..133e4155d5 100644 --- a/src/assets/palettes/github.json +++ b/src/assets/palettes/github.json @@ -34,9 +34,7 @@ "CLEAR_BACKGROUND_COLOR": "#040506", "NODE_TITLE_COLOR": "#999", "NODE_SELECTED_TITLE_COLOR": "#e5eaf0", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#bcc2c8", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#161b22", "NODE_DEFAULT_BGCOLOR": "#13171d", "NODE_DEFAULT_BOXCOLOR": "#30363d", @@ -45,7 +43,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)", - "DEFAULT_GROUP_FONT": 24, "WIDGET_BGCOLOR": "#161b22", "WIDGET_OUTLINE_COLOR": "#30363d", "WIDGET_TEXT_COLOR": "#bcc2c8", diff --git a/src/assets/palettes/light.json b/src/assets/palettes/light.json index 37d1048d91..3f29e8803c 100644 --- a/src/assets/palettes/light.json +++ b/src/assets/palettes/light.json @@ -26,10 +26,8 @@ "CLEAR_BACKGROUND_COLOR": "lightgray", "NODE_TITLE_COLOR": "#222", "NODE_SELECTED_TITLE_COLOR": "#000", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#444", "NODE_TEXT_HIGHLIGHT_COLOR": "#1e293b", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#F7F7F7", "NODE_DEFAULT_BGCOLOR": "#F5F5F5", "NODE_DEFAULT_BOXCOLOR": "#CCC", @@ -38,7 +36,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.1)", - "DEFAULT_GROUP_FONT": 24, "WIDGET_BGCOLOR": "#D4D4D4", "WIDGET_OUTLINE_COLOR": "#999", "WIDGET_TEXT_COLOR": "#222", diff --git a/src/assets/palettes/nord.json b/src/assets/palettes/nord.json index 89f7fad743..9cf0f62d51 100644 --- a/src/assets/palettes/nord.json +++ b/src/assets/palettes/nord.json @@ -34,9 +34,7 @@ "CLEAR_BACKGROUND_COLOR": "#212732", "NODE_TITLE_COLOR": "#999", "NODE_SELECTED_TITLE_COLOR": "#e5eaf0", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#bcc2c8", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#2e3440", "NODE_DEFAULT_BGCOLOR": "#161b22", "NODE_DEFAULT_BOXCOLOR": "#545d70", @@ -45,7 +43,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)", - "DEFAULT_GROUP_FONT": 24, "WIDGET_BGCOLOR": "#2e3440", "WIDGET_OUTLINE_COLOR": "#545d70", "WIDGET_TEXT_COLOR": "#bcc2c8", diff --git a/src/assets/palettes/solarized.json b/src/assets/palettes/solarized.json index 4b456c4034..3b9a96594b 100644 --- a/src/assets/palettes/solarized.json +++ b/src/assets/palettes/solarized.json @@ -19,9 +19,7 @@ "litegraph_base": { "NODE_TITLE_COLOR": "#fdf6e3", "NODE_SELECTED_TITLE_COLOR": "#A9D400", - "NODE_TEXT_SIZE": 14, "NODE_TEXT_COLOR": "#657b83", - "NODE_SUBTEXT_SIZE": 12, "NODE_DEFAULT_COLOR": "#094656", "NODE_DEFAULT_BGCOLOR": "#073642", "NODE_DEFAULT_BOXCOLOR": "#839496", @@ -30,7 +28,6 @@ "NODE_BYPASS_BGCOLOR": "#FF00FF", "NODE_ERROR_COLOUR": "#E00", "DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)", - "DEFAULT_GROUP_FONT": 24, "WIDGET_BGCOLOR": "#002b36", "WIDGET_OUTLINE_COLOR": "#839496", "WIDGET_TEXT_COLOR": "#fdf6e3", diff --git a/src/components/LiteGraphCanvasSplitterOverlay.vue b/src/components/LiteGraphCanvasSplitterOverlay.vue index f4b5742cc7..64de67f2e5 100644 --- a/src/components/LiteGraphCanvasSplitterOverlay.vue +++ b/src/components/LiteGraphCanvasSplitterOverlay.vue @@ -18,15 +18,20 @@ + - +
+ + +
@@ -129,6 +141,7 @@ import ErrorOverlay from '@/components/error/ErrorOverlay.vue' import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' import LoginButton from '@/components/topbar/LoginButton.vue' +import StatusBadge from '@/components/common/StatusBadge.vue' import Button from '@/components/ui/button/Button.vue' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags' @@ -206,12 +219,7 @@ const actionbarContainerClass = computed(() => { ) } - const borderClass = - !isActionbarFloating.value && hasAnyError.value - ? 'border-destructive-background-hover' - : 'border-interface-stroke' - - return cn(base, 'px-2', borderClass) + return cn(base, 'px-2', 'border-interface-stroke') }) const isIntegratedTabBar = computed( () => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy' @@ -254,7 +262,19 @@ const shouldShowRedDot = computed((): boolean => { return shouldShowConflictRedDot.value }) -const { hasAnyError } = storeToRefs(executionErrorStore) +const { hasAnyError, isErrorOverlayOpen } = storeToRefs(executionErrorStore) + +const isErrorsTabEnabled = computed(() => + settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') +) + +const showErrorIndicatorOnPanelButton = computed( + () => + isErrorsTabEnabled.value && + hasAnyError.value && + !isRightSidePanelOpen.value && + !isErrorOverlayOpen.value +) // Right side panel toggle const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore) diff --git a/src/components/actionbar/BatchCountEdit.test.ts b/src/components/actionbar/BatchCountEdit.test.ts index 2614184c54..f3c5d9bcff 100644 --- a/src/components/actionbar/BatchCountEdit.test.ts +++ b/src/components/actionbar/BatchCountEdit.test.ts @@ -1,7 +1,7 @@ import { createTestingPinia } from '@pinia/testing' -import { mount } 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 { nextTick } from 'vue' import { createI18n } from 'vue-i18n' import { useQueueSettingsStore } from '@/stores/queueStore' @@ -33,7 +33,7 @@ const i18n = createI18n({ } }) -function createWrapper(initialBatchCount = 1) { +function renderComponent(initialBatchCount = 1) { const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false, @@ -44,7 +44,9 @@ function createWrapper(initialBatchCount = 1) { } }) - const wrapper = mount(BatchCountEdit, { + const user = userEvent.setup() + + render(BatchCountEdit, { global: { plugins: [pinia, i18n], directives: { @@ -55,44 +57,42 @@ function createWrapper(initialBatchCount = 1) { const queueSettingsStore = useQueueSettingsStore() - return { wrapper, queueSettingsStore } + return { user, queueSettingsStore } } describe('BatchCountEdit', () => { it('doubles the current batch count when increment is clicked', async () => { - const { wrapper, queueSettingsStore } = createWrapper(3) + const { user, queueSettingsStore } = renderComponent(3) - await wrapper.get('button[aria-label="Increment"]').trigger('click') + await user.click(screen.getByRole('button', { name: 'Increment' })) expect(queueSettingsStore.batchCount).toBe(6) }) it('halves the current batch count when decrement is clicked', async () => { - const { wrapper, queueSettingsStore } = createWrapper(9) + const { user, queueSettingsStore } = renderComponent(9) - await wrapper.get('button[aria-label="Decrement"]').trigger('click') + await user.click(screen.getByRole('button', { name: 'Decrement' })) expect(queueSettingsStore.batchCount).toBe(4) }) it('clamps typed values to queue limits on blur', async () => { - const { wrapper, queueSettingsStore } = createWrapper(2) - const input = wrapper.get('input') + const { user, queueSettingsStore } = renderComponent(2) + const input = screen.getByRole('textbox', { name: 'Batch Count' }) - await input.setValue('999') - await input.trigger('blur') - await nextTick() + await user.clear(input) + await user.type(input, '999') + await user.tab() expect(queueSettingsStore.batchCount).toBe(maxBatchCount) - expect((input.element as HTMLInputElement).value).toBe( - String(maxBatchCount) - ) + expect(input).toHaveValue(String(maxBatchCount)) - await input.setValue('0') - await input.trigger('blur') - await nextTick() + await user.clear(input) + await user.type(input, '0') + await user.tab() expect(queueSettingsStore.batchCount).toBe(1) - expect((input.element as HTMLInputElement).value).toBe('1') + expect(input).toHaveValue('1') }) }) diff --git a/src/components/actionbar/ComfyActionbar.test.ts b/src/components/actionbar/ComfyActionbar.test.ts index f0a5237b58..6593f92277 100644 --- a/src/components/actionbar/ComfyActionbar.test.ts +++ b/src/components/actionbar/ComfyActionbar.test.ts @@ -1,5 +1,5 @@ import { createTestingPinia } from '@pinia/testing' -import { mount } from '@vue/test-utils' +import { render } from '@testing-library/vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -20,15 +20,15 @@ const configureSettings = ( }) } -const mountActionbar = (showRunProgressBar: boolean) => { +const renderActionbar = (showRunProgressBar: boolean) => { const topMenuContainer = document.createElement('div') document.body.appendChild(topMenuContainer) const pinia = createTestingPinia({ createSpy: vi.fn }) configureSettings(pinia, showRunProgressBar) - const wrapper = mount(ComfyActionbar, { - attachTo: document.body, + render(ComfyActionbar, { + container: document.body.appendChild(document.createElement('div')), props: { topMenuContainer, queueOverlayExpanded: false @@ -57,10 +57,7 @@ const mountActionbar = (showRunProgressBar: boolean) => { } }) - return { - wrapper, - topMenuContainer - } + return { topMenuContainer } } describe('ComfyActionbar', () => { @@ -70,31 +67,33 @@ describe('ComfyActionbar', () => { }) it('teleports inline progress when run progress bar is enabled', async () => { - const { wrapper, topMenuContainer } = mountActionbar(true) + const { topMenuContainer } = renderActionbar(true) try { await nextTick() + /* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */ expect( topMenuContainer.querySelector('[data-testid="queue-inline-progress"]') ).not.toBeNull() + /* eslint-enable testing-library/no-node-access */ } finally { - wrapper.unmount() topMenuContainer.remove() } }) it('does not teleport inline progress when run progress bar is disabled', async () => { - const { wrapper, topMenuContainer } = mountActionbar(false) + const { topMenuContainer } = renderActionbar(false) try { await nextTick() + /* eslint-disable testing-library/no-node-access -- Teleport target verification requires scoping to the container element */ expect( topMenuContainer.querySelector('[data-testid="queue-inline-progress"]') ).toBeNull() + /* eslint-enable testing-library/no-node-access */ } finally { - wrapper.unmount() topMenuContainer.remove() } }) diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index 62f59c665a..9ee2df72b4 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil' import ComfyRunButton from './ComfyRunButton' -const { - topMenuContainer, - queueOverlayExpanded = false, - hasAnyError = false -} = defineProps<{ +const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{ topMenuContainer?: HTMLElement | null queueOverlayExpanded?: boolean - hasAnyError?: boolean }>() const emit = defineEmits<{ @@ -440,12 +435,7 @@ const panelClass = computed(() => isDragging.value && 'pointer-events-none select-none', isDocked.value ? 'static border-none bg-transparent p-0' - : [ - 'fixed shadow-interface', - hasAnyError - ? 'border-destructive-background-hover' - : 'border-interface-stroke' - ] + : ['fixed shadow-interface', 'border-interface-stroke'] ) ) diff --git a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts index c01e862495..db6f4d0df1 100644 --- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts +++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.test.ts @@ -1,5 +1,4 @@ import { createTestingPinia } from '@pinia/testing' -import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' @@ -14,6 +13,8 @@ import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import ComfyQueueButton from './ComfyQueueButton.vue' @@ -78,38 +79,43 @@ function createTask(id: string, status: JobStatus): TaskItemImpl { return new TaskItemImpl(job) } -function createWrapper() { - const pinia = createTestingPinia({ createSpy: vi.fn }) +const stubs = { + BatchCountEdit: BatchCountEditStub, + DropdownMenuRoot: { template: '
' }, + DropdownMenuTrigger: { template: '
' }, + DropdownMenuPortal: { template: '
' }, + DropdownMenuContent: { template: '
' }, + DropdownMenuItem: { template: '
' } +} - return mount(ComfyQueueButton, { +function renderQueueButton() { + const pinia = createTestingPinia({ createSpy: vi.fn }) + const user = userEvent.setup() + + const result = render(ComfyQueueButton, { global: { plugins: [pinia, i18n], directives: { tooltip: () => {} }, - stubs: { - BatchCountEdit: BatchCountEditStub, - DropdownMenuRoot: { template: '
' }, - DropdownMenuTrigger: { template: '
' }, - DropdownMenuPortal: { template: '
' }, - DropdownMenuContent: { template: '
' }, - DropdownMenuItem: { template: '
' } - } + stubs } }) + + return { ...result, user } } describe('ComfyQueueButton', () => { it('renders the batch count control before the run button', () => { - const wrapper = createWrapper() - const controls = wrapper.get('.queue-button-group').element.children + renderQueueButton() + const controls = screen.getAllByTestId(/batch-count-edit|queue-button/) - expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit') - expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button') + expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit') + expect(controls[1]).toHaveAttribute('data-testid', 'queue-button') }) it('keeps the run instant presentation while idle even with active jobs', async () => { - const wrapper = createWrapper() + renderQueueButton() const queueSettingsStore = useQueueSettingsStore() const queueStore = useQueueStore() @@ -117,29 +123,27 @@ describe('ComfyQueueButton', () => { queueStore.runningTasks = [createTask('run-1', 'in_progress')] await nextTick() - const queueButton = wrapper.get('[data-testid="queue-button"]') + const queueButton = screen.getByTestId('queue-button') - expect(queueButton.text()).toContain('Run (Instant)') - expect(queueButton.attributes('data-variant')).toBe('primary') - expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) + expect(queueButton).toHaveTextContent('Run (Instant)') + expect(queueButton).toHaveAttribute('data-variant', 'primary') }) it('switches to stop presentation when instant mode is armed', async () => { - const wrapper = createWrapper() + renderQueueButton() const queueSettingsStore = useQueueSettingsStore() queueSettingsStore.mode = 'instant-running' await nextTick() - const queueButton = wrapper.get('[data-testid="queue-button"]') + const queueButton = screen.getByTestId('queue-button') - expect(queueButton.text()).toContain('Stop Run (Instant)') - expect(queueButton.attributes('data-variant')).toBe('destructive') - expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true) + expect(queueButton).toHaveTextContent('Stop Run (Instant)') + expect(queueButton).toHaveAttribute('data-variant', 'destructive') }) it('disarms instant mode without interrupting even when jobs are active', async () => { - const wrapper = createWrapper() + const { user } = renderQueueButton() const queueSettingsStore = useQueueSettingsStore() const queueStore = useQueueStore() const commandStore = useCommandStore() @@ -148,33 +152,26 @@ describe('ComfyQueueButton', () => { queueStore.runningTasks = [createTask('run-1', 'in_progress')] await nextTick() - await wrapper.get('[data-testid="queue-button"]').trigger('click') + await user.click(screen.getByTestId('queue-button')) await nextTick() expect(queueSettingsStore.mode).toBe('instant-idle') - const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]') - expect(queueButtonWhileStopping.text()).toContain('Run (Instant)') - expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary') - expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) + const queueButton = screen.getByTestId('queue-button') + expect(queueButton).toHaveTextContent('Run (Instant)') + expect(queueButton).toHaveAttribute('data-variant', 'primary') expect(commandStore.execute).not.toHaveBeenCalled() - - const queueButton = wrapper.get('[data-testid="queue-button"]') - expect(queueSettingsStore.mode).toBe('instant-idle') - expect(queueButton.text()).toContain('Run (Instant)') - expect(queueButton.attributes('data-variant')).toBe('primary') - expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true) }) it('activates instant running mode when queueing again', async () => { - const wrapper = createWrapper() + const { user } = renderQueueButton() const queueSettingsStore = useQueueSettingsStore() const commandStore = useCommandStore() queueSettingsStore.mode = 'instant-idle' await nextTick() - await wrapper.get('[data-testid="queue-button"]').trigger('click') + await user.click(screen.getByTestId('queue-button')) await nextTick() expect(queueSettingsStore.mode).toBe('instant-running') diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue index c217f43daa..5d7cf2dff9 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue @@ -21,7 +21,7 @@ /> {{ item.label }} - + { mockState.settingView = false }) - function mountComponent() { - return mount(BuilderFooterToolbar, { + function renderComponent() { + const user = userEvent.setup() + + render(BuilderFooterToolbar, { global: { plugins: [i18n], stubs: { Button: false } } }) - } - function getButtons(wrapper: ReturnType) { - const buttons = wrapper.findAll('button') - return { - exit: buttons[0], - back: buttons[1], - next: buttons[2] - } + return { user } } it('disables back on the first step', () => { mockState.mode = 'builder:inputs' - const { back } = getButtons(mountComponent()) - expect(back.attributes('disabled')).toBeDefined() + renderComponent() + expect(screen.getByRole('button', { name: /back/i })).toBeDisabled() }) it('enables back on the second step', () => { mockState.mode = 'builder:arrange' - const { back } = getButtons(mountComponent()) - expect(back.attributes('disabled')).toBeUndefined() + renderComponent() + expect(screen.getByRole('button', { name: /back/i })).toBeEnabled() }) it('disables next on the setDefaultView step', () => { mockState.settingView = true - const { next } = getButtons(mountComponent()) - expect(next.attributes('disabled')).toBeDefined() + renderComponent() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() }) it('disables next on arrange step when no outputs', () => { mockState.mode = 'builder:arrange' mockHasOutputs.value = false - const { next } = getButtons(mountComponent()) - expect(next.attributes('disabled')).toBeDefined() + renderComponent() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() }) it('enables next on inputs step', () => { mockState.mode = 'builder:inputs' - const { next } = getButtons(mountComponent()) - expect(next.attributes('disabled')).toBeUndefined() + renderComponent() + expect(screen.getByRole('button', { name: /next/i })).toBeEnabled() }) it('calls setMode on back click', async () => { mockState.mode = 'builder:arrange' - const { back } = getButtons(mountComponent()) - await back.trigger('click') + const { user } = renderComponent() + await user.click(screen.getByRole('button', { name: /back/i })) expect(mockSetMode).toHaveBeenCalledWith('builder:outputs') }) it('calls setMode on next click from inputs step', async () => { mockState.mode = 'builder:inputs' - const { next } = getButtons(mountComponent()) - await next.trigger('click') + const { user } = renderComponent() + await user.click(screen.getByRole('button', { name: /next/i })) expect(mockSetMode).toHaveBeenCalledWith('builder:outputs') }) it('opens default view dialog on next click from arrange step', async () => { mockState.mode = 'builder:arrange' - const { next } = getButtons(mountComponent()) - await next.trigger('click') + const { user } = renderComponent() + await user.click(screen.getByRole('button', { name: /next/i })) expect(mockSetMode).toHaveBeenCalledWith('builder:arrange') expect(mockShowDialog).toHaveBeenCalledOnce() }) it('calls exitBuilder on exit button click', async () => { - const { exit } = getButtons(mountComponent()) - await exit.trigger('click') + const { user } = renderComponent() + await user.click(screen.getByRole('button', { name: /exit app builder/i })) expect(mockExitBuilder).toHaveBeenCalledOnce() }) }) diff --git a/src/components/builder/VueNodeSwitchPopup.vue b/src/components/builder/VueNodeSwitchPopup.vue new file mode 100644 index 0000000000..0e2ff58c48 --- /dev/null +++ b/src/components/builder/VueNodeSwitchPopup.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/common/BadgePill.test.ts b/src/components/common/BadgePill.test.ts index 2d864154e0..c7db8f85e2 100644 --- a/src/components/common/BadgePill.test.ts +++ b/src/components/common/BadgePill.test.ts @@ -1,89 +1,103 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' import { describe, expect, it } from 'vitest' import BadgePill from './BadgePill.vue' describe('BadgePill', () => { it('renders text content', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Test Badge' } }) - expect(wrapper.text()).toBe('Test Badge') + expect(screen.getByText('Test Badge')).toBeInTheDocument() }) it('renders icon when provided', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { icon: 'icon-[comfy--credits]', text: 'Credits' } }) - expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true) + expect(screen.getByTestId('badge-icon')).toHaveClass( + 'icon-[comfy--credits]' + ) }) it('applies iconClass to icon', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { icon: 'icon-[comfy--credits]', iconClass: 'text-amber-400' } }) - const icon = wrapper.find('i') - expect(icon.classes()).toContain('text-amber-400') + expect(screen.getByTestId('badge-icon')).toHaveClass('text-amber-400') }) it('uses default border color when no borderStyle', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Default' } }) - expect(wrapper.attributes('style')).toContain( - 'border-color: var(--border-color)' + expect(screen.getByTestId('badge-pill')).toHaveAttribute( + 'style', + expect.stringContaining('border-color: var(--border-color)') ) }) it('applies solid border color when borderStyle is a color', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Colored', borderStyle: '#f59e0b' } }) - expect(wrapper.attributes('style')).toContain('border-color: #f59e0b') + expect(screen.getByTestId('badge-pill')).toHaveAttribute( + 'style', + expect.stringContaining('border-color: #f59e0b') + ) }) it('applies gradient border when borderStyle contains linear-gradient', () => { const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)' - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Gradient', borderStyle: gradient } }) - const element = wrapper.element as HTMLElement + const element = screen.getByTestId('badge-pill') as HTMLElement expect(element.style.borderColor).toBe('transparent') expect(element.style.backgroundOrigin).toBe('border-box') expect(element.style.backgroundClip).toBe('padding-box, border-box') }) it('applies filled style with background and text color', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Filled', borderStyle: '#f59e0b', filled: true } }) - const style = wrapper.attributes('style') - expect(style).toContain('border-color: #f59e0b') - expect(style).toContain('background-color: #f59e0b33') - expect(style).toContain('color: #f59e0b') + const pill = screen.getByTestId('badge-pill') + expect(pill).toHaveAttribute( + 'style', + expect.stringContaining('border-color: #f59e0b') + ) + expect(pill).toHaveAttribute( + 'style', + expect.stringContaining('background-color: #f59e0b33') + ) + expect(pill).toHaveAttribute( + 'style', + expect.stringContaining('color: #f59e0b') + ) }) it('has foreground text when not filled', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Not Filled', borderStyle: '#f59e0b' } }) - expect(wrapper.classes()).toContain('text-foreground') + expect(screen.getByTestId('badge-pill')).toHaveClass('text-foreground') }) it('does not have foreground text class when filled', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { props: { text: 'Filled', borderStyle: '#f59e0b', filled: true } }) - expect(wrapper.classes()).not.toContain('text-foreground') + expect(screen.getByTestId('badge-pill')).not.toHaveClass('text-foreground') }) it('renders slot content', () => { - const wrapper = mount(BadgePill, { + render(BadgePill, { slots: { default: 'Slot Content' } }) - expect(wrapper.text()).toBe('Slot Content') + expect(screen.getByText('Slot Content')).toBeInTheDocument() }) }) diff --git a/src/components/common/BadgePill.vue b/src/components/common/BadgePill.vue index 77c13e6f84..c3f23fadff 100644 --- a/src/components/common/BadgePill.vue +++ b/src/components/common/BadgePill.vue @@ -1,5 +1,6 @@ diff --git a/src/components/common/ColorCustomizationSelector.test.ts b/src/components/common/ColorCustomizationSelector.test.ts index c98b5c4313..165c03667c 100644 --- a/src/components/common/ColorCustomizationSelector.test.ts +++ b/src/components/common/ColorCustomizationSelector.test.ts @@ -1,8 +1,9 @@ -import { mount } from '@vue/test-utils' +import { render } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import ColorPicker from 'primevue/colorpicker' import PrimeVue from 'primevue/config' import SelectButton from 'primevue/selectbutton' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createApp, nextTick } from 'vue' import ColorCustomizationSelector from './ColorCustomizationSelector.vue' @@ -14,13 +15,17 @@ describe('ColorCustomizationSelector', () => { ] beforeEach(() => { - // Setup PrimeVue const app = createApp({}) app.use(PrimeVue) }) - const mountComponent = (props = {}) => { - return mount(ColorCustomizationSelector, { + function renderComponent( + props: Record = {}, + callbacks: { 'onUpdate:modelValue'?: (value: string | null) => void } = {} + ) { + const user = userEvent.setup() + + const result = render(ColorCustomizationSelector, { global: { plugins: [PrimeVue], components: { SelectButton, ColorPicker } @@ -28,102 +33,123 @@ describe('ColorCustomizationSelector', () => { props: { modelValue: null, colorOptions, - ...props + ...props, + ...callbacks } }) + + return { ...result, user } + } + + /** PrimeVue SelectButton renders toggle buttons with aria-pressed */ + function getToggleButtons(container: Element) { + return container.querySelectorAll( // eslint-disable-line testing-library/no-node-access -- PrimeVue SelectButton renders toggle buttons without standard ARIA radiogroup roles + '[data-pc-name="pctogglebutton"]' + ) } it('renders predefined color options and custom option', () => { - const wrapper = mountComponent() - const selectButton = wrapper.findComponent(SelectButton) - - expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1) - expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom') + const { container } = renderComponent() + expect(getToggleButtons(container)).toHaveLength(colorOptions.length + 1) }) it('initializes with predefined color when provided', async () => { - const wrapper = mountComponent({ - modelValue: '#0d6efd' - }) - + const { container } = renderComponent({ modelValue: '#0d6efd' }) await nextTick() - const selectButton = wrapper.findComponent(SelectButton) - expect(selectButton.props('modelValue')).toEqual({ - name: 'Blue', - value: '#0d6efd' - }) + + const buttons = getToggleButtons(container) + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true') }) it('initializes with custom color when non-predefined color provided', async () => { - const wrapper = mountComponent({ - modelValue: '#123456' - }) - + const { container } = renderComponent({ modelValue: '#123456' }) await nextTick() - const selectButton = wrapper.findComponent(SelectButton) - const colorPicker = wrapper.findComponent(ColorPicker) - expect(selectButton.props('modelValue').name).toBe('_custom') - expect(colorPicker.props('modelValue')).toBe('123456') + const buttons = getToggleButtons(container) + const customButton = buttons[buttons.length - 1] + expect(customButton).toHaveAttribute('aria-pressed', 'true') + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker uses readonly input preview with no ARIA role + const colorPreview = container.querySelector( + '.p-colorpicker-preview' + ) as HTMLInputElement | null + expect(colorPreview).not.toBeNull() }) it('shows color picker when custom option is selected', async () => { - const wrapper = mountComponent() - const selectButton = wrapper.findComponent(SelectButton) + const { container, user } = renderComponent() - // Select custom option - await selectButton.setValue({ name: '_custom', value: '' }) + const buttons = getToggleButtons(container) + await user.click(buttons[buttons.length - 1]) - expect(wrapper.findComponent(ColorPicker).exists()).toBe(true) + expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue ColorPicker internal DOM + container.querySelector('[data-pc-name="colorpicker"]') + ).not.toBeNull() }) it('emits update when predefined color is selected', async () => { - const wrapper = mountComponent() - const selectButton = wrapper.findComponent(SelectButton) + const onUpdate = vi.fn() + const { container, user } = renderComponent( + {}, + { 'onUpdate:modelValue': onUpdate } + ) - await selectButton.setValue(colorOptions[0]) + const buttons = getToggleButtons(container) + await user.click(buttons[0]) - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd']) + expect(onUpdate).toHaveBeenCalledWith('#0d6efd') }) it('emits update when custom color is changed', async () => { - const wrapper = mountComponent() - const selectButton = wrapper.findComponent(SelectButton) + const onUpdate = vi.fn() + const { container, user } = renderComponent( + {}, + { 'onUpdate:modelValue': onUpdate } + ) - // Select custom option - await selectButton.setValue({ name: '_custom', value: '' }) + // Custom is already selected by default (modelValue: null) + // Select Blue first, then switch to custom so onUpdate fires for Blue + const buttons = getToggleButtons(container) + await user.click(buttons[0]) // Select Blue + expect(onUpdate).toHaveBeenCalledWith('#0d6efd') - // Change custom color - const colorPicker = wrapper.findComponent(ColorPicker) - await colorPicker.setValue('ff0000') + onUpdate.mockClear() + await user.click(buttons[buttons.length - 1]) // Switch to custom - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000']) + // When switching to custom, the custom color value inherits from Blue ('0d6efd') + // and the watcher on customColorValue emits the update + expect(onUpdate).toHaveBeenCalledWith('#0d6efd') }) it('inherits color from previous selection when switching to custom', async () => { - const wrapper = mountComponent() - const selectButton = wrapper.findComponent(SelectButton) + const onUpdate = vi.fn() + const { container, user } = renderComponent( + {}, + { 'onUpdate:modelValue': onUpdate } + ) - // First select a predefined color - await selectButton.setValue(colorOptions[0]) + const buttons = getToggleButtons(container) - // Then switch to custom - await selectButton.setValue({ name: '_custom', value: '' }) + // First select Blue + await user.click(buttons[0]) + expect(onUpdate).toHaveBeenCalledWith('#0d6efd') - const colorPicker = wrapper.findComponent(ColorPicker) - expect(colorPicker.props('modelValue')).toBe('0d6efd') + onUpdate.mockClear() + + // Then switch to custom — inherits the Blue color + await user.click(buttons[buttons.length - 1]) + + // The customColorValue watcher fires with the inherited Blue value + expect(onUpdate).toHaveBeenCalledWith('#0d6efd') }) it('handles null modelValue correctly', async () => { - const wrapper = mountComponent({ - modelValue: null - }) - + const { container } = renderComponent({ modelValue: null }) await nextTick() - const selectButton = wrapper.findComponent(SelectButton) - expect(selectButton.props('modelValue')).toEqual({ - name: '_custom', - value: '' - }) + + const buttons = getToggleButtons(container) + const customButton = buttons[buttons.length - 1] + expect(customButton).toHaveAttribute('aria-pressed', 'true') }) }) diff --git a/src/components/common/EditableText.test.ts b/src/components/common/EditableText.test.ts index df1ea5152f..a12a236918 100644 --- a/src/components/common/EditableText.test.ts +++ b/src/components/common/EditableText.test.ts @@ -1,140 +1,120 @@ -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import PrimeVue from 'primevue/config' import InputText from 'primevue/inputtext' -import { beforeAll, describe, expect, it } from 'vitest' -import { createApp } from 'vue' +import { describe, expect, it, vi } from 'vitest' import EditableText from './EditableText.vue' describe('EditableText', () => { - beforeAll(() => { - // Create a Vue app instance for PrimeVue - const app = createApp({}) - app.use(PrimeVue) - }) + function renderComponent( + props: { modelValue: string; isEditing?: boolean }, + callbacks: { + onEdit?: (...args: unknown[]) => void + onCancel?: (...args: unknown[]) => void + } = {} + ) { + const user = userEvent.setup() - // @ts-expect-error fixme ts strict error - const mountComponent = (props, options = {}) => { - return mount(EditableText, { + render(EditableText, { global: { plugins: [PrimeVue], components: { InputText } }, - props, - ...options + props: { + ...props, + ...(callbacks.onEdit && { onEdit: callbacks.onEdit }), + ...(callbacks.onCancel && { onCancel: callbacks.onCancel }) + } }) + + return { user } } it('renders span with modelValue when not editing', () => { - const wrapper = mountComponent({ - modelValue: 'Test Text', - isEditing: false - }) - expect(wrapper.find('span').text()).toBe('Test Text') - expect(wrapper.findComponent(InputText).exists()).toBe(false) + renderComponent({ modelValue: 'Test Text', isEditing: false }) + expect(screen.getByText('Test Text')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('renders input with modelValue when editing', () => { - const wrapper = mountComponent({ - modelValue: 'Test Text', - isEditing: true - }) - expect(wrapper.find('span').exists()).toBe(false) - expect(wrapper.findComponent(InputText).props()['modelValue']).toBe( - 'Test Text' - ) + renderComponent({ modelValue: 'Test Text', isEditing: true }) + expect(screen.queryByText('Test Text')).not.toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('Test Text') }) it('emits edit event when input is submitted', async () => { - const wrapper = mountComponent({ - modelValue: 'Test Text', - isEditing: true - }) - await wrapper.findComponent(InputText).setValue('New Text') - await wrapper.findComponent(InputText).trigger('keydown.enter') - // Blur event should have been triggered - expect(wrapper.findComponent(InputText).element).not.toBe( - document.activeElement + const onEdit = vi.fn() + const { user } = renderComponent( + { modelValue: 'Test Text', isEditing: true }, + { onEdit } ) + + const input = screen.getByRole('textbox') + await user.clear(input) + await user.type(input, 'New Text') + await user.keyboard('{Enter}') + + expect(onEdit).toHaveBeenCalledWith('New Text') }) it('finishes editing on blur', async () => { - const wrapper = mountComponent({ - modelValue: 'Test Text', - isEditing: true - }) - await wrapper.findComponent(InputText).trigger('blur') - expect(wrapper.emitted('edit')).toBeTruthy() - // @ts-expect-error fixme ts strict error - expect(wrapper.emitted('edit')[0]).toEqual(['Test Text']) + const onEdit = vi.fn() + renderComponent({ modelValue: 'Test Text', isEditing: true }, { onEdit }) + + await fireEvent.blur(screen.getByRole('textbox')) + + expect(onEdit).toHaveBeenCalledWith('Test Text') }) it('cancels editing on escape key', async () => { - const wrapper = mountComponent({ - modelValue: 'Original Text', - isEditing: true - }) - - // Change the input value - await wrapper.findComponent(InputText).setValue('Modified Text') - - // Press escape - await wrapper.findComponent(InputText).trigger('keydown.escape') - - // Should emit cancel event - expect(wrapper.emitted('cancel')).toBeTruthy() - - // Should NOT emit edit event - expect(wrapper.emitted('edit')).toBeFalsy() - - // Input value should be reset to original - expect(wrapper.findComponent(InputText).props()['modelValue']).toBe( - 'Original Text' + const onEdit = vi.fn() + const onCancel = vi.fn() + const { user } = renderComponent( + { modelValue: 'Original Text', isEditing: true }, + { onEdit, onCancel } ) + + const input = screen.getByRole('textbox') + await user.clear(input) + await user.type(input, 'Modified Text') + await user.keyboard('{Escape}') + + expect(onCancel).toHaveBeenCalled() + expect(onEdit).not.toHaveBeenCalled() + expect(input).toHaveValue('Original Text') }) - it('does not save changes when escape is pressed and blur occurs', async () => { - const wrapper = mountComponent({ - modelValue: 'Original Text', - isEditing: true - }) + it('does not save changes when escape is pressed', async () => { + const onEdit = vi.fn() + const onCancel = vi.fn() + const { user } = renderComponent( + { modelValue: 'Original Text', isEditing: true }, + { onEdit, onCancel } + ) - // Change the input value - await wrapper.findComponent(InputText).setValue('Modified Text') + const input = screen.getByRole('textbox') + await user.clear(input) + await user.type(input, 'Modified Text') + // Escape triggers cancelEditing → blur internally, so no separate blur needed + await user.keyboard('{Escape}') - // Press escape (which triggers blur internally) - await wrapper.findComponent(InputText).trigger('keydown.escape') - - // Manually trigger blur to simulate the blur that happens after escape - await wrapper.findComponent(InputText).trigger('blur') - - // Should emit cancel but not edit - expect(wrapper.emitted('cancel')).toBeTruthy() - expect(wrapper.emitted('edit')).toBeFalsy() + expect(onCancel).toHaveBeenCalled() + expect(onEdit).not.toHaveBeenCalled() }) it('saves changes on enter but not on escape', async () => { - // Test Enter key saves changes - const enterWrapper = mountComponent({ - modelValue: 'Original Text', - isEditing: true - }) - await enterWrapper.findComponent(InputText).setValue('Saved Text') - await enterWrapper.findComponent(InputText).trigger('keydown.enter') - // Trigger blur that happens after enter - await enterWrapper.findComponent(InputText).trigger('blur') - expect(enterWrapper.emitted('edit')).toBeTruthy() - // @ts-expect-error fixme ts strict error - expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text']) + const onEditEnter = vi.fn() + const { user: userEnter } = renderComponent( + { modelValue: 'Original Text', isEditing: true }, + { onEdit: onEditEnter } + ) - // Test Escape key cancels changes with a fresh wrapper - const escapeWrapper = mountComponent({ - modelValue: 'Original Text', - isEditing: true - }) - await escapeWrapper.findComponent(InputText).setValue('Cancelled Text') - await escapeWrapper.findComponent(InputText).trigger('keydown.escape') - expect(escapeWrapper.emitted('cancel')).toBeTruthy() - expect(escapeWrapper.emitted('edit')).toBeFalsy() + const enterInput = screen.getByRole('textbox') + await userEnter.clear(enterInput) + await userEnter.type(enterInput, 'Saved Text') + await userEnter.keyboard('{Enter}') + + expect(onEditEnter).toHaveBeenCalledWith('Saved Text') }) }) diff --git a/src/components/common/FormRadioGroup.test.ts b/src/components/common/FormRadioGroup.test.ts index 2ec26e9af0..fed772736a 100644 --- a/src/components/common/FormRadioGroup.test.ts +++ b/src/components/common/FormRadioGroup.test.ts @@ -1,241 +1,210 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' -import RadioButton from 'primevue/radiobutton' -import { beforeAll, describe, expect, it } from 'vitest' -import { createApp } from 'vue' +import { describe, expect, it } from 'vitest' +import type { ComponentProps } from 'vue-component-type-helpers' import type { SettingOption } from '@/platform/settings/types' import FormRadioGroup from './FormRadioGroup.vue' -import type { ComponentProps } from 'vue-component-type-helpers' + +type FormRadioGroupProps = ComponentProps describe('FormRadioGroup', () => { - beforeAll(() => { - const app = createApp({}) - app.use(PrimeVue) - }) - - type FormRadioGroupProps = ComponentProps - const mountComponent = (props: FormRadioGroupProps, options = {}) => { - return mount(FormRadioGroup, { - global: { - plugins: [PrimeVue], - components: { RadioButton } - }, - props, - ...options + function renderComponent(props: FormRadioGroupProps) { + return render(FormRadioGroup, { + global: { plugins: [PrimeVue] }, + props }) } describe('normalizedOptions computed property', () => { it('handles string array options', () => { - const wrapper = mountComponent({ + renderComponent({ modelValue: 'option1', options: ['option1', 'option2', 'option3'], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(3) + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(3) - expect(radioButtons[0].props('value')).toBe('option1') - expect(radioButtons[1].props('value')).toBe('option2') - expect(radioButtons[2].props('value')).toBe('option3') + expect(radios[0]).toHaveAttribute('value', 'option1') + expect(radios[1]).toHaveAttribute('value', 'option2') + expect(radios[2]).toHaveAttribute('value', 'option3') - const labels = wrapper.findAll('label') - expect(labels[0].text()).toBe('option1') - expect(labels[1].text()).toBe('option2') - expect(labels[2].text()).toBe('option3') + expect(screen.getByText('option1')).toBeInTheDocument() + expect(screen.getByText('option2')).toBeInTheDocument() + expect(screen.getByText('option3')).toBeInTheDocument() }) it('handles SettingOption array', () => { - const options: SettingOption[] = [ - { text: 'Small', value: 'sm' }, - { text: 'Medium', value: 'md' }, - { text: 'Large', value: 'lg' } - ] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'md', - options, + options: [ + { text: 'Small', value: 'sm' }, + { text: 'Medium', value: 'md' }, + { text: 'Large', value: 'lg' } + ] satisfies SettingOption[], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(3) + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(3) - expect(radioButtons[0].props('value')).toBe('sm') - expect(radioButtons[1].props('value')).toBe('md') - expect(radioButtons[2].props('value')).toBe('lg') + expect(radios[0]).toHaveAttribute('value', 'sm') + expect(radios[1]).toHaveAttribute('value', 'md') + expect(radios[2]).toHaveAttribute('value', 'lg') - const labels = wrapper.findAll('label') - expect(labels[0].text()).toBe('Small') - expect(labels[1].text()).toBe('Medium') - expect(labels[2].text()).toBe('Large') + expect(screen.getByText('Small')).toBeInTheDocument() + expect(screen.getByText('Medium')).toBeInTheDocument() + expect(screen.getByText('Large')).toBeInTheDocument() }) it('handles SettingOption with undefined value (uses text as value)', () => { - const options: SettingOption[] = [ - { text: 'Option A', value: undefined }, - { text: 'Option B' } - ] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'Option A', - options, + options: [ + { text: 'Option A', value: undefined }, + { text: 'Option B' } + ] satisfies SettingOption[], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - - expect(radioButtons[0].props('value')).toBe('Option A') - expect(radioButtons[1].props('value')).toBe('Option B') + const radios = screen.getAllByRole('radio') + expect(radios[0]).toHaveAttribute('value', 'Option A') + expect(radios[1]).toHaveAttribute('value', 'Option B') }) it('handles custom object with optionLabel and optionValue', () => { - const options = [ - { name: 'First Option', id: '1' }, - { name: 'Second Option', id: '2' }, - { name: 'Third Option', id: '3' } - ] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 2, - options, + options: [ + { name: 'First Option', id: '1' }, + { name: 'Second Option', id: '2' }, + { name: 'Third Option', id: '3' } + ], optionLabel: 'name', optionValue: 'id', id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(3) + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(3) - expect(radioButtons[0].props('value')).toBe('1') - expect(radioButtons[1].props('value')).toBe('2') - expect(radioButtons[2].props('value')).toBe('3') + expect(radios[0]).toHaveAttribute('value', '1') + expect(radios[1]).toHaveAttribute('value', '2') + expect(radios[2]).toHaveAttribute('value', '3') - const labels = wrapper.findAll('label') - expect(labels[0].text()).toBe('First Option') - expect(labels[1].text()).toBe('Second Option') - expect(labels[2].text()).toBe('Third Option') + expect(screen.getByText('First Option')).toBeInTheDocument() + expect(screen.getByText('Second Option')).toBeInTheDocument() + expect(screen.getByText('Third Option')).toBeInTheDocument() }) it('handles mixed array with strings and SettingOptions', () => { - const options: (string | SettingOption)[] = [ - 'Simple String', - { text: 'Complex Option', value: 'complex' }, - 'Another String' - ] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'complex', - options, + options: [ + 'Simple String', + { text: 'Complex Option', value: 'complex' }, + 'Another String' + ] as (string | SettingOption)[], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(3) + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(3) - expect(radioButtons[0].props('value')).toBe('Simple String') - expect(radioButtons[1].props('value')).toBe('complex') - expect(radioButtons[2].props('value')).toBe('Another String') + expect(radios[0]).toHaveAttribute('value', 'Simple String') + expect(radios[1]).toHaveAttribute('value', 'complex') + expect(radios[2]).toHaveAttribute('value', 'Another String') - const labels = wrapper.findAll('label') - expect(labels[0].text()).toBe('Simple String') - expect(labels[1].text()).toBe('Complex Option') - expect(labels[2].text()).toBe('Another String') + expect(screen.getByText('Simple String')).toBeInTheDocument() + expect(screen.getByText('Complex Option')).toBeInTheDocument() + expect(screen.getByText('Another String')).toBeInTheDocument() }) it('handles empty options array', () => { - const wrapper = mountComponent({ + renderComponent({ modelValue: null, options: [], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(0) + expect(screen.queryAllByRole('radio')).toHaveLength(0) }) it('handles undefined options gracefully', () => { - const wrapper = mountComponent({ + renderComponent({ modelValue: null, options: undefined, id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(0) + expect(screen.queryAllByRole('radio')).toHaveLength(0) }) it('handles object with missing properties gracefully', () => { - const options = [{ label: 'Option 1', val: 'opt1' }] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'opt1', - options, + options: [{ label: 'Option 1', val: 'opt1' }], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - expect(radioButtons).toHaveLength(1) - - const labels = wrapper.findAll('label') - expect(labels[0].text()).toBe('Unknown') + expect(screen.getAllByRole('radio')).toHaveLength(1) + expect(screen.getByText('Unknown')).toBeInTheDocument() }) }) describe('component functionality', () => { - it('sets correct input-id and name attributes', () => { - const options = ['A', 'B'] - - const wrapper = mountComponent({ + it('sets correct id and name attributes on inputs', () => { + renderComponent({ modelValue: 'A', - options, + options: ['A', 'B'], id: 'my-radio-group' }) - const radioButtons = wrapper.findAllComponents(RadioButton) + const radios = screen.getAllByRole('radio') - expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A') - expect(radioButtons[0].props('name')).toBe('my-radio-group') - expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B') - expect(radioButtons[1].props('name')).toBe('my-radio-group') + expect(radios[0]).toHaveAttribute('id', 'my-radio-group-A') + expect(radios[0]).toHaveAttribute('name', 'my-radio-group') + expect(radios[1]).toHaveAttribute('id', 'my-radio-group-B') + expect(radios[1]).toHaveAttribute('name', 'my-radio-group') }) it('associates labels with radio buttons correctly', () => { - const options = ['Yes', 'No'] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'Yes', - options, + options: ['Yes', 'No'], id: 'confirm-radio' }) - const labels = wrapper.findAll('label') - - expect(labels[0].attributes('for')).toBe('confirm-radio-Yes') - expect(labels[1].attributes('for')).toBe('confirm-radio-No') + expect(screen.getByText('Yes')).toHaveAttribute( + 'for', + 'confirm-radio-Yes' + ) + expect(screen.getByText('No')).toHaveAttribute('for', 'confirm-radio-No') }) it('sets aria-describedby attribute correctly', () => { - const options: SettingOption[] = [ - { text: 'Option 1', value: 'opt1' }, - { text: 'Option 2', value: 'opt2' } - ] - - const wrapper = mountComponent({ + renderComponent({ modelValue: 'opt1', - options, + options: [ + { text: 'Option 1', value: 'opt1' }, + { text: 'Option 2', value: 'opt2' } + ] satisfies SettingOption[], id: 'test-radio' }) - const radioButtons = wrapper.findAllComponents(RadioButton) - - expect(radioButtons[0].attributes('aria-describedby')).toBe( + const radios = screen.getAllByRole('radio') + // PrimeVue RadioButton places aria-describedby on its root
, not the + // eslint-disable-next-line testing-library/no-node-access + expect(radios[0].closest('[aria-describedby]')).toHaveAttribute( + 'aria-describedby', 'Option 1-label' ) - expect(radioButtons[1].attributes('aria-describedby')).toBe( + // eslint-disable-next-line testing-library/no-node-access + expect(radios[1].closest('[aria-describedby]')).toHaveAttribute( + 'aria-describedby', 'Option 2-label' ) }) diff --git a/src/components/common/ImageLightbox.test.ts b/src/components/common/ImageLightbox.test.ts index 144b3669d2..1648a5017f 100644 --- a/src/components/common/ImageLightbox.test.ts +++ b/src/components/common/ImageLightbox.test.ts @@ -1,6 +1,6 @@ -import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' -import type { VueWrapper } from '@vue/test-utils' -import { afterEach, describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import ImageLightbox from './ImageLightbox.vue' @@ -13,49 +13,39 @@ const i18n = createI18n({ fallbackWarn: false }) -function findCloseButton() { - const el = document.body.querySelector('[aria-label="g.close"]') - return el ? new DOMWrapper(el) : null -} - describe(ImageLightbox, () => { - let wrapper: VueWrapper - - afterEach(() => { - wrapper.unmount() - }) - - function mountComponent(props: { src: string; alt?: string }, open = true) { - wrapper = mount(ImageLightbox, { + function renderComponent(props: { src: string; alt?: string }, open = true) { + const user = userEvent.setup() + const onUpdate = vi.fn() + const result = render(ImageLightbox, { global: { plugins: [i18n] }, - props: { ...props, modelValue: open }, - attachTo: document.body + props: { + ...props, + modelValue: open, + 'onUpdate:modelValue': onUpdate + } }) - return wrapper + return { ...result, user, onUpdate } } it('renders the image with correct src and alt when open', async () => { - mountComponent({ src: '/test.png', alt: 'Test image' }) - await flushPromises() - const img = document.body.querySelector('img') - expect(img).toBeTruthy() - expect(img?.getAttribute('src')).toBe('/test.png') - expect(img?.getAttribute('alt')).toBe('Test image') + renderComponent({ src: '/test.png', alt: 'Test image' }) + const img = await screen.findByRole('img') + expect(img).toHaveAttribute('src', '/test.png') + expect(img).toHaveAttribute('alt', 'Test image') }) - it('does not render dialog content when closed', async () => { - mountComponent({ src: '/test.png' }, false) - await flushPromises() - expect(document.body.querySelector('img')).toBeNull() + it('does not render dialog content when closed', () => { + renderComponent({ src: '/test.png' }, false) + expect(screen.queryByRole('img')).not.toBeInTheDocument() }) it('emits update:modelValue false when close button is clicked', async () => { - mountComponent({ src: '/test.png' }) - await flushPromises() - const closeButton = findCloseButton() - expect(closeButton).toBeTruthy() - await closeButton!.trigger('click') - await flushPromises() - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false]) + const { user, onUpdate } = renderComponent({ src: '/test.png' }) + const closeButton = await screen.findByLabelText('g.close') + await user.click(closeButton) + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(false) + }) }) }) diff --git a/src/components/common/NotificationPopup.test.ts b/src/components/common/NotificationPopup.test.ts new file mode 100644 index 0000000000..e494410808 --- /dev/null +++ b/src/components/common/NotificationPopup.test.ts @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils' +import type { ComponentProps } from 'vue-component-type-helpers' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import NotificationPopup from './NotificationPopup.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { g: { close: 'Close' } } + } +}) + +function mountPopup( + props: ComponentProps = { + title: 'Test' + }, + slots: Record = {} +) { + return mount(NotificationPopup, { + global: { plugins: [i18n] }, + props, + slots + }) +} + +describe('NotificationPopup', () => { + it('renders title', () => { + const wrapper = mountPopup({ title: 'Hello World' }) + expect(wrapper.text()).toContain('Hello World') + }) + + it('has role="status" for accessibility', () => { + const wrapper = mountPopup() + expect(wrapper.find('[role="status"]').exists()).toBe(true) + }) + + it('renders subtitle when provided', () => { + const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' }) + expect(wrapper.text()).toContain('v1.2.3') + }) + + it('renders icon when provided', () => { + const wrapper = mountPopup({ + title: 'T', + icon: 'icon-[lucide--rocket]' + }) + expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true) + }) + + 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) + }) + + it('renders default slot content', () => { + const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' }) + expect(wrapper.text()).toContain('Body text here') + }) + + it('renders footer slots', () => { + const wrapper = mountPopup( + { title: 'T' }, + { 'footer-start': 'Left side', 'footer-end': 'Right side' } + ) + expect(wrapper.text()).toContain('Left side') + expect(wrapper.text()).toContain('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') + }) +}) diff --git a/src/components/common/NotificationPopup.vue b/src/components/common/NotificationPopup.vue new file mode 100644 index 0000000000..d39158d9d7 --- /dev/null +++ b/src/components/common/NotificationPopup.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/components/common/TreeExplorerTreeNode.test.ts b/src/components/common/TreeExplorerTreeNode.test.ts index d00d3e401e..d8543e535b 100644 --- a/src/components/common/TreeExplorerTreeNode.test.ts +++ b/src/components/common/TreeExplorerTreeNode.test.ts @@ -1,5 +1,5 @@ import { createTestingPinia } from '@pinia/testing' -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' import Badge from 'primevue/badge' import PrimeVue from 'primevue/config' import InputText from 'primevue/inputtext' @@ -12,7 +12,6 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes' -// Create a mock i18n instance const i18n = createI18n({ legacy: false, locale: 'en', @@ -33,7 +32,6 @@ describe('TreeExplorerTreeNode', () => { const mockHandleEditLabel = vi.fn() beforeAll(() => { - // Create a Vue app instance for PrimeVuePrimeVue const app = createApp({}) app.use(PrimeVue) vi.useFakeTimers() @@ -44,7 +42,7 @@ describe('TreeExplorerTreeNode', () => { }) it('renders correctly', () => { - const wrapper = mount(TreeExplorerTreeNode, { + render(TreeExplorerTreeNode, { props: { node: mockNode }, global: { components: { EditableText, Badge }, @@ -55,18 +53,16 @@ describe('TreeExplorerTreeNode', () => { } }) - expect(wrapper.find('.tree-node').exists()).toBe(true) - expect(wrapper.find('.tree-folder').exists()).toBe(true) - expect(wrapper.find('.tree-leaf').exists()).toBe(false) - expect(wrapper.findComponent(EditableText).props('modelValue')).toBe( - 'Test Node' - ) - // @ts-expect-error fixme ts strict error - expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3') + const treeNode = screen.getByTestId('tree-node-1') + expect(treeNode).toBeInTheDocument() + expect(treeNode).toHaveClass('tree-folder') + expect(treeNode).not.toHaveClass('tree-leaf') + expect(screen.getByText('Test Node')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() }) - it('makes node label editable when renamingEditingNode matches', async () => { - const wrapper = mount(TreeExplorerTreeNode, { + it('makes node label editable when isEditingLabel is true', () => { + render(TreeExplorerTreeNode, { props: { node: { ...mockNode, @@ -82,14 +78,13 @@ describe('TreeExplorerTreeNode', () => { } }) - const editableText = wrapper.findComponent(EditableText) - expect(editableText.props('isEditing')).toBe(true) + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('triggers handleEditLabel callback when editing is finished', async () => { const handleEditLabelMock = vi.fn() - const wrapper = mount(TreeExplorerTreeNode, { + render(TreeExplorerTreeNode, { props: { node: { ...mockNode, @@ -103,8 +98,9 @@ describe('TreeExplorerTreeNode', () => { } }) - const editableText = wrapper.findComponent(EditableText) - editableText.vm.$emit('edit', 'New Node Name') + // Trigger blur on the input to finish editing (fires the 'edit' event) + await fireEvent.blur(screen.getByRole('textbox')) + expect(handleEditLabelMock).toHaveBeenCalledOnce() }) }) diff --git a/src/components/common/TreeExplorerV2.vue b/src/components/common/TreeExplorerV2.vue index 598ec00e17..1257a994f3 100644 --- a/src/components/common/TreeExplorerV2.vue +++ b/src/components/common/TreeExplorerV2.vue @@ -8,7 +8,7 @@ :get-children=" (item) => (item.children?.length ? item.children : undefined) " - class="m-0 min-w-0 p-0 pb-2" + class="m-0 min-w-0 p-0 px-2 pb-2" > > diff --git a/src/components/common/UrlInput.test.ts b/src/components/common/UrlInput.test.ts index 9c34c11c55..2c3e830bab 100644 --- a/src/components/common/UrlInput.test.ts +++ b/src/components/common/UrlInput.test.ts @@ -1,64 +1,66 @@ -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import PrimeVue from 'primevue/config' import IconField from 'primevue/iconfield' import InputIcon from 'primevue/inputicon' import InputText from 'primevue/inputtext' -import { beforeEach, describe, expect, it } from 'vitest' -import { createApp, nextTick } from 'vue' +import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import UrlInput from './UrlInput.vue' import type { ComponentProps } from 'vue-component-type-helpers' describe('UrlInput', () => { - beforeEach(() => { - const app = createApp({}) - app.use(PrimeVue) - }) - - const mountComponent = ( + function renderComponent( props: ComponentProps & { placeholder?: string disabled?: boolean - }, - options = {} - ) => { - return mount(UrlInput, { + 'onUpdate:modelValue'?: (value: string) => void + } + ) { + const user = userEvent.setup() + + const result = render(UrlInput, { global: { plugins: [PrimeVue], components: { IconField, InputIcon, InputText } }, - props, - ...options + props }) + + return { ...result, user } } it('passes through additional attributes to input element', () => { - const wrapper = mountComponent({ + renderComponent({ modelValue: '', placeholder: 'Enter URL', disabled: true }) - expect(wrapper.find('input').attributes('disabled')).toBe('') + expect(screen.getByRole('textbox')).toBeDisabled() }) it('emits update:modelValue on blur', async () => { - const wrapper = mountComponent({ + const onUpdate = vi.fn() + const { user } = renderComponent({ modelValue: '', - placeholder: 'Enter URL' + placeholder: 'Enter URL', + 'onUpdate:modelValue': onUpdate }) - const input = wrapper.find('input') - await input.setValue('https://test.com/') - await input.trigger('blur') + const input = screen.getByRole('textbox') + await user.type(input, 'https://test.com/') + expect(onUpdate).not.toHaveBeenCalled() - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([ - 'https://test.com/' - ]) + await user.tab() + + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(onUpdate).toHaveBeenCalledWith('https://test.com/') }) it('renders spinner when validation is loading', async () => { - const wrapper = mountComponent({ + const { container, rerender } = renderComponent({ modelValue: '', placeholder: 'Enter URL', validateUrlFn: () => @@ -67,43 +69,46 @@ describe('UrlInput', () => { }) }) - await wrapper.setProps({ modelValue: 'https://test.com' }) + await rerender({ modelValue: 'https://test.com' }) await nextTick() await nextTick() - expect(wrapper.find('.pi-spinner').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-spinner class with no ARIA role + expect(container.querySelector('.pi-spinner')).not.toBeNull() }) it('renders check icon when validation is valid', async () => { - const wrapper = mountComponent({ + const { container, rerender } = renderComponent({ modelValue: '', placeholder: 'Enter URL', validateUrlFn: () => Promise.resolve(true) }) - await wrapper.setProps({ modelValue: 'https://test.com' }) + await rerender({ modelValue: 'https://test.com' }) await nextTick() await nextTick() - expect(wrapper.find('.pi-check').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role + expect(container.querySelector('.pi-check')).not.toBeNull() }) it('renders cross icon when validation is invalid', async () => { - const wrapper = mountComponent({ + const { container, rerender } = renderComponent({ modelValue: '', placeholder: 'Enter URL', validateUrlFn: () => Promise.resolve(false) }) - await wrapper.setProps({ modelValue: 'https://test.com' }) + await rerender({ modelValue: 'https://test.com' }) await nextTick() await nextTick() - expect(wrapper.find('.pi-times').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-times class with no ARIA role + expect(container.querySelector('.pi-times')).not.toBeNull() }) it('validates on mount', async () => { - const wrapper = mountComponent({ + const { container } = renderComponent({ modelValue: 'https://test.com', validateUrlFn: () => Promise.resolve(true) }) @@ -111,12 +116,13 @@ describe('UrlInput', () => { await nextTick() await nextTick() - expect(wrapper.find('.pi-check').exists()).toBe(true) + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role + expect(container.querySelector('.pi-check')).not.toBeNull() }) it('triggers validation when clicking the validation icon', async () => { let validationCount = 0 - const wrapper = mountComponent({ + const { container, user } = renderComponent({ modelValue: 'https://test.com', validateUrlFn: () => { validationCount++ @@ -129,7 +135,9 @@ describe('UrlInput', () => { await nextTick() // Click the validation icon - await wrapper.find('.pi-check').trigger('click') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon uses pi-check class with no ARIA role + const icon = container.querySelector('.pi-check')! + await user.click(icon) await nextTick() await nextTick() @@ -138,7 +146,7 @@ describe('UrlInput', () => { it('prevents multiple simultaneous validations', async () => { let validationCount = 0 - const wrapper = mountComponent({ + const { container, rerender, user } = renderComponent({ modelValue: '', validateUrlFn: () => { validationCount++ @@ -148,14 +156,16 @@ describe('UrlInput', () => { } }) - await wrapper.setProps({ modelValue: 'https://test.com' }) + await rerender({ modelValue: 'https://test.com' }) await nextTick() await nextTick() // Trigger multiple validations in quick succession - await wrapper.find('.pi-spinner').trigger('click') - await wrapper.find('.pi-spinner').trigger('click') - await wrapper.find('.pi-spinner').trigger('click') + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue InputIcon + const spinner = container.querySelector('.pi-spinner')! + await user.click(spinner) + await user.click(spinner) + await user.click(spinner) await nextTick() await nextTick() @@ -165,55 +175,49 @@ describe('UrlInput', () => { describe('input cleaning functionality', () => { it('trims whitespace when user types', async () => { - const wrapper = mountComponent({ + renderComponent({ modelValue: '', placeholder: 'Enter URL' }) - const input = wrapper.find('input') + const input = screen.getByRole('textbox') - // Test leading whitespace - await input.setValue(' https://leading-space.com') - await input.trigger('input') + // The component strips whitespace on input via handleInput + // We use fireEvent.input to simulate the input event handler directly + await fireEvent.update(input, ' https://leading-space.com') await nextTick() - expect(input.element.value).toBe('https://leading-space.com') + expect(input).toHaveValue('https://leading-space.com') - // Test trailing whitespace - await input.setValue('https://trailing-space.com ') - await input.trigger('input') + await fireEvent.update(input, 'https://trailing-space.com ') await nextTick() - expect(input.element.value).toBe('https://trailing-space.com') + expect(input).toHaveValue('https://trailing-space.com') - // Test both leading and trailing whitespace - await input.setValue(' https://both-spaces.com ') - await input.trigger('input') + await fireEvent.update(input, ' https://both-spaces.com ') await nextTick() - expect(input.element.value).toBe('https://both-spaces.com') + expect(input).toHaveValue('https://both-spaces.com') - // Test whitespace in the middle of the URL - await input.setValue('https:// middle-space.com') - await input.trigger('input') + await fireEvent.update(input, 'https:// middle-space.com') await nextTick() - expect(input.element.value).toBe('https://middle-space.com') + expect(input).toHaveValue('https://middle-space.com') }) it('trims whitespace when value set externally', async () => { - const wrapper = mountComponent({ + const { rerender } = renderComponent({ modelValue: ' https://initial-value.com ', placeholder: 'Enter URL' }) - const input = wrapper.find('input') + const input = screen.getByRole('textbox') // Check initial value is trimmed - expect(input.element.value).toBe('https://initial-value.com') + expect(input).toHaveValue('https://initial-value.com') // Update props with whitespace - await wrapper.setProps({ modelValue: ' https://updated-value.com ' }) + await rerender({ modelValue: ' https://updated-value.com ' }) await nextTick() // Check updated value is trimmed - expect(input.element.value).toBe('https://updated-value.com') + expect(input).toHaveValue('https://updated-value.com') }) }) }) diff --git a/src/components/common/UserAvatar.test.ts b/src/components/common/UserAvatar.test.ts index 0b5df70523..e4628beab6 100644 --- a/src/components/common/UserAvatar.test.ts +++ b/src/components/common/UserAvatar.test.ts @@ -1,10 +1,9 @@ import type { ComponentProps } from 'vue-component-type-helpers' -import { mount } from '@vue/test-utils' -import Avatar from 'primevue/avatar' +import { fireEvent, render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' -import { beforeEach, describe, expect, it } from 'vitest' -import { createApp, nextTick } from 'vue' +import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' import UserAvatar from './UserAvatar.vue' @@ -24,85 +23,73 @@ const i18n = createI18n({ }) describe('UserAvatar', () => { - beforeEach(() => { - const app = createApp({}) - app.use(PrimeVue) - }) - - const mountComponent = (props: ComponentProps = {}) => { - return mount(UserAvatar, { + function renderComponent(props: ComponentProps = {}) { + return render(UserAvatar, { global: { - plugins: [PrimeVue, i18n], - components: { Avatar } + plugins: [PrimeVue, i18n] }, props }) } - it('renders correctly with photo Url', async () => { - const wrapper = mountComponent({ + it('renders correctly with photo Url', () => { + renderComponent({ photoUrl: 'https://example.com/avatar.jpg' }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.exists()).toBe(true) - expect(avatar.props('image')).toBe('https://example.com/avatar.jpg') - expect(avatar.props('icon')).toBeNull() + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + 'https://example.com/avatar.jpg' + ) + expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument() }) it('renders with default icon when no photo Url is provided', () => { - const wrapper = mountComponent({ + renderComponent({ photoUrl: undefined }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.exists()).toBe(true) - expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('icon-[lucide--user]') + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByTestId('avatar-icon')).toBeInTheDocument() }) it('renders with default icon when provided photo Url is null', () => { - const wrapper = mountComponent({ + renderComponent({ photoUrl: null }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.exists()).toBe(true) - expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('icon-[lucide--user]') + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByTestId('avatar-icon')).toBeInTheDocument() }) it('falls back to icon when image fails to load', async () => { - const wrapper = mountComponent({ + renderComponent({ photoUrl: 'https://example.com/broken-image.jpg' }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.props('icon')).toBeNull() + const img = screen.getByRole('img') + expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument() - // Simulate image load error - avatar.vm.$emit('error') + await fireEvent.error(img) await nextTick() - expect(avatar.props('icon')).toBe('icon-[lucide--user]') + expect(screen.getByTestId('avatar-icon')).toBeInTheDocument() }) it('uses provided ariaLabel', () => { - const wrapper = mountComponent({ + renderComponent({ photoUrl: 'https://example.com/avatar.jpg', ariaLabel: 'Custom Label' }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.attributes('aria-label')).toBe('Custom Label') + expect(screen.getByLabelText('Custom Label')).toBeInTheDocument() }) it('falls back to i18n translation when no ariaLabel is provided', () => { - const wrapper = mountComponent({ + renderComponent({ photoUrl: 'https://example.com/avatar.jpg' }) - const avatar = wrapper.findComponent(Avatar) - expect(avatar.attributes('aria-label')).toBe('User Avatar') + expect(screen.getByLabelText('User Avatar')).toBeInTheDocument() }) }) diff --git a/src/components/common/UserAvatar.vue b/src/components/common/UserAvatar.vue index 8e68f4fc65..48a7add134 100644 --- a/src/components/common/UserAvatar.vue +++ b/src/components/common/UserAvatar.vue @@ -3,7 +3,12 @@ class="aspect-square bg-interface-panel-selected-surface" :image="photoUrl ?? undefined" :icon="hasAvatar ? undefined : 'icon-[lucide--user]'" - :pt:icon:class="{ 'size-4': !hasAvatar }" + :pt="{ + icon: { + class: { 'size-4': !hasAvatar }, + 'data-testid': 'avatar-icon' + } + }" shape="circle" :aria-label="ariaLabel ?? $t('auth.login.userAvatar')" @error="handleImageError" diff --git a/src/components/common/UserCredit.test.ts b/src/components/common/UserCredit.test.ts index 7fcb42f497..352010dfb9 100644 --- a/src/components/common/UserCredit.test.ts +++ b/src/components/common/UserCredit.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 { createI18n } from 'vue-i18n' @@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({ const mockIsFetchingBalance = vi.hoisted(() => ({ value: false })) -vi.mock('@/stores/firebaseAuthStore', () => ({ - useFirebaseAuthStore: vi.fn(() => ({ +vi.mock('@/stores/authStore', () => ({ + useAuthStore: vi.fn(() => ({ balance: mockBalance.value, isFetchingBalance: mockIsFetchingBalance.value })) @@ -50,19 +50,19 @@ describe('UserCredit', () => { mockIsFetchingBalance.value = false }) - const mountComponent = (props = {}) => { + const renderComponent = (props = {}) => { const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: enMessages } }) - return mount(UserCredit, { + return render(UserCredit, { props, global: { plugins: [i18n], stubs: { - Skeleton: true, + Skeleton: { template: '
' }, Tag: true } } @@ -77,8 +77,8 @@ describe('UserCredit', () => { currency: 'usd' } - const wrapper = mountComponent() - expect(wrapper.text()).toContain('Credits') + renderComponent() + expect(screen.getByText(/Credits/)).toBeInTheDocument() }) it('uses effective_balance_micros when zero', () => { @@ -88,8 +88,8 @@ describe('UserCredit', () => { currency: 'usd' } - const wrapper = mountComponent() - expect(wrapper.text()).toContain('0') + renderComponent() + expect(screen.getByText(/\b0\b/)).toBeInTheDocument() }) it('uses effective_balance_micros when negative', () => { @@ -99,8 +99,8 @@ describe('UserCredit', () => { currency: 'usd' } - const wrapper = mountComponent() - expect(wrapper.text()).toContain('-') + renderComponent() + expect(screen.getByText((text) => text.includes('-'))).toBeInTheDocument() }) it('falls back to amount_micros when effective_balance_micros is missing', () => { @@ -109,8 +109,8 @@ describe('UserCredit', () => { currency: 'usd' } as typeof mockBalance.value - const wrapper = mountComponent() - expect(wrapper.text()).toContain('Credits') + renderComponent() + expect(screen.getByText(/Credits/)).toBeInTheDocument() }) it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => { @@ -118,8 +118,8 @@ describe('UserCredit', () => { currency: 'usd' } as typeof mockBalance.value - const wrapper = mountComponent() - expect(wrapper.text()).toContain('0') + renderComponent() + expect(screen.getByText(/\b0\b/)).toBeInTheDocument() }) }) @@ -127,8 +127,8 @@ describe('UserCredit', () => { it('shows skeleton when loading', () => { mockIsFetchingBalance.value = true - const wrapper = mountComponent() - expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true) + renderComponent() + expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0) }) }) }) diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue index 987406ede5..f23e5b42d8 100644 --- a/src/components/common/UserCredit.vue +++ b/src/components/common/UserCredit.vue @@ -30,14 +30,14 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { formatCreditsFromCents } from '@/base/credits/comfyCredits' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' const { textClass, showCreditsOnly } = defineProps<{ textClass?: string showCreditsOnly?: boolean }>() -const authStore = useFirebaseAuthStore() +const authStore = useAuthStore() const balanceLoading = computed(() => authStore.isFetchingBalance) const { t, locale } = useI18n() diff --git a/src/components/common/WaveAudioPlayer.stories.ts b/src/components/common/WaveAudioPlayer.stories.ts new file mode 100644 index 0000000000..55e32cd6da --- /dev/null +++ b/src/components/common/WaveAudioPlayer.stories.ts @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import WaveAudioPlayer from './WaveAudioPlayer.vue' + +const meta: Meta = { + title: 'Components/Audio/WaveAudioPlayer', + component: WaveAudioPlayer, + tags: ['autodocs'], + parameters: { layout: 'centered' } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + src: '/assets/audio/sample.wav', + barCount: 40, + height: 32 + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export const BottomAligned: Story = { + args: { + src: '/assets/audio/sample.wav', + barCount: 40, + height: 48, + align: 'bottom' + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} + +export const Expanded: Story = { + args: { + src: '/assets/audio/sample.wav', + variant: 'expanded', + barCount: 80, + height: 120 + }, + decorators: [ + (story) => ({ + components: { story }, + template: + '
' + }) + ] +} diff --git a/src/components/common/WaveAudioPlayer.vue b/src/components/common/WaveAudioPlayer.vue new file mode 100644 index 0000000000..c462dffb6f --- /dev/null +++ b/src/components/common/WaveAudioPlayer.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/components/curve/WidgetCurve.vue b/src/components/curve/WidgetCurve.vue index 33c9381b1e..7a67d981ca 100644 --- a/src/components/curve/WidgetCurve.vue +++ b/src/components/curve/WidgetCurve.vue @@ -22,19 +22,21 @@ :model-value="effectiveCurve.points" :disabled="isDisabled" :interpolation="effectiveCurve.interpolation" + :histogram="histogram" @update:model-value="onPointsChange" />
diff --git a/src/components/dialog/content/signin/SignUpForm.vue b/src/components/dialog/content/signin/SignUpForm.vue index 9d642a0371..f8b8d4aebb 100644 --- a/src/components/dialog/content/signin/SignUpForm.vue +++ b/src/components/dialog/content/signin/SignUpForm.vue @@ -54,12 +54,12 @@ import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { signUpSchema } from '@/schemas/signInSchema' import type { SignUpData } from '@/schemas/signInSchema' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' import PasswordFields from './PasswordFields.vue' const { t } = useI18n() -const authStore = useFirebaseAuthStore() +const authStore = useAuthStore() const loading = computed(() => authStore.loading) const emit = defineEmits<{ diff --git a/src/components/error/ErrorOverlay.vue b/src/components/error/ErrorOverlay.vue index be599a3995..36faa3f98a 100644 --- a/src/components/error/ErrorOverlay.vue +++ b/src/components/error/ErrorOverlay.vue @@ -9,7 +9,7 @@ role="alert" aria-live="assertive" data-testid="error-overlay" - class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-interface-stroke bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out" + class="pointer-events-auto flex w-80 min-w-72 flex-col overflow-hidden rounded-lg border border-destructive-background bg-comfy-menu-bg shadow-interface transition-colors duration-200 ease-in-out" >
@@ -27,10 +27,10 @@
-
+
  • @@ -46,13 +46,21 @@
    - -
@@ -79,7 +87,59 @@ const rightSidePanelStore = useRightSidePanelStore() const canvasStore = useCanvasStore() const { totalErrorCount, isErrorOverlayOpen } = storeToRefs(executionErrorStore) -const { groupedErrorMessages } = useErrorGroups(ref(''), t) +const { allErrorGroups, missingModelGroups } = useErrorGroups(ref(''), t) + +const singleErrorType = computed(() => { + const types = new Set(allErrorGroups.value.map((g) => g.type)) + return types.size === 1 ? [...types][0] : null +}) + +const friendlyMessageMap: Record string> = { + missing_node: () => t('errorOverlay.missingNodes'), + swap_nodes: () => t('errorOverlay.swapNodes'), + missing_media: () => t('errorOverlay.missingMedia'), + missing_model: () => { + const modelCount = missingModelGroups.value.reduce( + (count, g) => count + g.models.length, + 0 + ) + return t('errorOverlay.missingModels', { count: modelCount }, modelCount) + } +} + +function toFriendlyMessage(group: (typeof allErrorGroups.value)[number]) { + return friendlyMessageMap[group.type]?.() ?? null +} + +const overlayMessages = computed(() => { + const messages = new Set() + for (const group of allErrorGroups.value) { + const friendly = toFriendlyMessage(group) + if (friendly) { + messages.add(friendly) + } else if (group.type === 'execution') { + for (const card of group.cards) { + for (const err of card.errors) { + messages.add(err.message) + } + } + } + } + return Array.from(messages) +}) + +const seeErrorsLabel = computed(() => { + const labelMap: Record = { + missing_node: t('errorOverlay.showMissingNodes'), + missing_model: t('errorOverlay.showMissingModels'), + swap_nodes: t('errorOverlay.showSwapNodes'), + missing_media: t('errorOverlay.showMissingMedia') + } + if (singleErrorType.value) { + return labelMap[singleErrorType.value] ?? t('errorOverlay.seeErrors') + } + return t('errorOverlay.seeErrors') +}) const errorCountLabel = computed(() => t( diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 5acac90c6c..cbc6418f4f 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -97,6 +97,7 @@ + @@ -128,6 +129,7 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter import TopMenuSection from '@/components/TopMenuSection.vue' import BottomPanel from '@/components/bottomPanel/BottomPanel.vue' import AppBuilder from '@/components/builder/AppBuilder.vue' +import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue' import ExtensionSlot from '@/components/common/ExtensionSlot.vue' import DomWidgets from '@/components/graph/DomWidgets.vue' import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue' @@ -193,6 +195,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil' import SelectionRectangle from './SelectionRectangle.vue' import { isCloud } from '@/platform/distribution/types' import { useFeatureFlags } from '@/composables/useFeatureFlags' +import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader' import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader' const { t } = useI18n() @@ -453,8 +456,9 @@ useEventListener( const comfyAppReady = ref(false) const workflowPersistence = useWorkflowPersistence() const { flags } = useFeatureFlags() -// Set up invite loader during setup phase so useRoute/useRouter work correctly +// Set up URL loaders during setup phase so useRoute/useRouter work correctly const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null +const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null useCanvasDrop(canvasRef) useLitegraphSettings() useNodeBadge() @@ -574,6 +578,18 @@ onMounted(async () => { await inviteUrlLoader.loadInviteFromUrl() } + // Open create workspace dialog from URL if present (e.g., ?create_workspace=1) + if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) { + try { + await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl() + } catch (error) { + console.error( + '[GraphCanvas] Failed to load create workspace from URL:', + error + ) + } + } + // Initialize release store to fetch releases from comfy-api (fire-and-forget) const { useReleaseStore } = await import('@/platform/updates/common/releaseStore') diff --git a/src/components/graph/TitleEditor.vue b/src/components/graph/TitleEditor.vue index bcfef87578..e0ea66a157 100644 --- a/src/components/graph/TitleEditor.vue +++ b/src/components/graph/TitleEditor.vue @@ -84,7 +84,9 @@ watch( pos: group.pos, size: [group.size[0], group.titleHeight] }) - inputFontStyle.value = { fontSize: `${group.font_size * scale}px` } + inputFontStyle.value = { + fontSize: `${LiteGraph.GROUP_TEXT_SIZE * scale}px` + } } else if (target instanceof LGraphNode) { const node = target const [x, y] = node.getBounding() diff --git a/src/components/graph/modals/ZoomControlsModal.vue b/src/components/graph/modals/ZoomControlsModal.vue index 14122761dc..65345906ce 100644 --- a/src/components/graph/modals/ZoomControlsModal.vue +++ b/src/components/graph/modals/ZoomControlsModal.vue @@ -11,6 +11,7 @@
{{ $t('zoomControls.zoomToFit') }} @@ -46,6 +49,7 @@
{ locale: 'en', messages: { en: { - selectionToolbox: { - bypassButton: { - tooltip: 'Toggle bypass mode' + commands: { + Comfy_Canvas_ToggleSelectedNodes_Bypass: { + label: 'Toggle bypass mode' } } } @@ -46,8 +48,10 @@ describe('BypassButton', () => { vi.clearAllMocks() }) - const mountComponent = () => { - return mount(BypassButton, { + function renderComponent() { + const user = userEvent.setup() + + render(BypassButton, { global: { plugins: [i18n, PrimeVue], directives: { tooltip: Tooltip }, @@ -56,28 +60,28 @@ describe('BypassButton', () => { } } }) + + return { user } } it('should render bypass button', () => { canvasStore.selectedItems = [getMockLGraphNode()] - const wrapper = mountComponent() - const button = wrapper.find('button') - expect(button.exists()).toBe(true) + renderComponent() + expect(screen.getByTestId('bypass-button')).toBeInTheDocument() }) it('should have correct test id', () => { canvasStore.selectedItems = [getMockLGraphNode()] - const wrapper = mountComponent() - const button = wrapper.find('[data-testid="bypass-button"]') - expect(button.exists()).toBe(true) + renderComponent() + expect(screen.getByTestId('bypass-button')).toBeInTheDocument() }) it('should execute bypass command when clicked', async () => { canvasStore.selectedItems = [getMockLGraphNode()] const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue() - const wrapper = mountComponent() - await wrapper.find('button').trigger('click') + const { user } = renderComponent() + await user.click(screen.getByTestId('bypass-button')) expect(executeSpy).toHaveBeenCalledWith( 'Comfy.Canvas.ToggleSelectedNodes.Bypass' @@ -90,21 +94,18 @@ describe('BypassButton', () => { }) canvasStore.selectedItems = [bypassedNode] vi.spyOn(commandStore, 'execute').mockResolvedValue() - const wrapper = mountComponent() + const { user } = renderComponent() - // Click to trigger the reactivity update - await wrapper.find('button').trigger('click') - await wrapper.vm.$nextTick() + await user.click(screen.getByTestId('bypass-button')) + await nextTick() - const button = wrapper.find('button') - expect(button.exists()).toBe(true) + expect(screen.getByTestId('bypass-button')).toBeInTheDocument() }) it('should handle multiple selected items', () => { vi.spyOn(commandStore, 'execute').mockResolvedValue() canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()] - const wrapper = mountComponent() - const button = wrapper.find('button') - expect(button.exists()).toBe(true) + renderComponent() + expect(screen.getByTestId('bypass-button')).toBeInTheDocument() }) }) diff --git a/src/components/helpcenter/HelpCenterPopups.vue b/src/components/helpcenter/HelpCenterPopups.vue index 5c3f307525..71ef88bccf 100644 --- a/src/components/helpcenter/HelpCenterPopups.vue +++ b/src/components/helpcenter/HelpCenterPopups.vue @@ -17,11 +17,7 @@ diff --git a/src/components/input/MultiSelect.test.ts b/src/components/input/MultiSelect.test.ts new file mode 100644 index 0000000000..c106175f21 --- /dev/null +++ b/src/components/input/MultiSelect.test.ts @@ -0,0 +1,154 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { nextTick, ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import MultiSelect from './MultiSelect.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + multiSelectDropdown: 'Multi-select dropdown', + noResultsFound: 'No results found', + search: 'Search', + clearAll: 'Clear all', + itemsSelected: 'Items selected' + } + } + } +}) + +const options = [ + { name: 'Option A', value: 'a' }, + { name: 'Option B', value: 'b' }, + { name: 'Option C', value: 'c' } +] + +function mountInParent( + multiSelectProps: Record = {}, + modelValue: { name: string; value: string }[] = [] +) { + const parentEscapeCount = { value: 0 } + + const Parent = { + template: + '
', + components: { MultiSelect }, + setup() { + return { + sel: ref(modelValue), + options, + extraProps: multiSelectProps, + onEsc: () => { + parentEscapeCount.value++ + } + } + } + } + + const wrapper = mount(Parent, { + attachTo: document.body, + global: { plugins: [i18n] } + }) + + return { wrapper, parentEscapeCount } +} + +function dispatchEscape(element: Element) { + element.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true + }) + ) +} + +function findContentElement(): HTMLElement | null { + return document.querySelector('[data-dismissable-layer]') +} + +describe('MultiSelect', () => { + it('keeps open-state border styling available while the dropdown is open', async () => { + const { wrapper } = mountInParent() + + const trigger = wrapper.get('button[aria-haspopup="listbox"]') + + expect(trigger.classes()).toContain( + 'data-[state=open]:border-node-component-border' + ) + expect(trigger.attributes('aria-expanded')).toBe('false') + + await trigger.trigger('click') + await nextTick() + + expect(trigger.attributes('aria-expanded')).toBe('true') + expect(trigger.attributes('data-state')).toBe('open') + + wrapper.unmount() + }) + + describe('Escape key propagation', () => { + it('stops Escape from propagating to parent when popover is open', async () => { + const { wrapper, parentEscapeCount } = mountInParent() + + const trigger = wrapper.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + await nextTick() + + const content = findContentElement() + expect(content).not.toBeNull() + + dispatchEscape(content!) + await nextTick() + + expect(parentEscapeCount.value).toBe(0) + + wrapper.unmount() + }) + + it('closes the popover when Escape is pressed', async () => { + const { wrapper } = mountInParent() + + const trigger = wrapper.find('button[aria-haspopup="listbox"]') + await trigger.trigger('click') + await nextTick() + expect(trigger.attributes('data-state')).toBe('open') + + const content = findContentElement() + dispatchEscape(content!) + await nextTick() + + expect(trigger.attributes('data-state')).toBe('closed') + + wrapper.unmount() + }) + }) + + describe('selected count badge', () => { + it('shows selected count when items are selected', () => { + const { wrapper } = mountInParent({}, [ + { name: 'Option A', value: 'a' }, + { name: 'Option B', value: 'b' } + ]) + + expect(wrapper.text()).toContain('2') + + wrapper.unmount() + }) + + it('does not show count badge when no items are selected', () => { + const { wrapper } = mountInParent() + const multiSelect = wrapper.findComponent(MultiSelect) + const spans = multiSelect.findAll('span') + const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim())) + + expect(countBadge).toBeUndefined() + + wrapper.unmount() + }) + }) +}) diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index 813bb46929..2980da6fca 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -1,207 +1,198 @@ diff --git a/src/components/input/select.variants.ts b/src/components/input/select.variants.ts new file mode 100644 index 0000000000..97bfdb20f8 --- /dev/null +++ b/src/components/input/select.variants.ts @@ -0,0 +1,50 @@ +import { cva } from 'cva' + +export const selectTriggerVariants = cva({ + base: 'relative inline-flex cursor-pointer items-center select-none rounded-lg bg-secondary-background text-base-foreground outline-none transition-all duration-200 ease-in-out hover:bg-secondary-background-hover border-[2.5px] border-solid disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background', + variants: { + size: { + md: 'h-8', + lg: 'h-10' + }, + border: { + none: 'border-transparent focus-visible:border-node-component-border data-[state=open]:border-node-component-border', + active: 'border-base-foreground', + invalid: 'border-destructive-background' + } + }, + defaultVariants: { + size: 'lg', + border: 'none' + } +}) + +export const selectItemVariants = cva({ + base: 'flex cursor-pointer items-center px-2 outline-none hover:bg-secondary-background-hover', + variants: { + layout: { + multi: + 'h-10 shrink-0 gap-2 rounded-lg data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected', + single: + 'relative w-full justify-between gap-3 rounded-sm py-3 text-sm select-none focus:bg-secondary-background-hover data-[state=checked]:bg-secondary-background-selected data-[state=checked]:hover:bg-secondary-background-selected' + } + }, + defaultVariants: { + layout: 'multi' + } +}) + +export const selectContentClass = + 'z-3000 overflow-hidden rounded-lg p-2 bg-base-background text-base-foreground border border-solid border-border-default shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2' + +export const selectDropdownClass = + 'flex shrink-0 cursor-pointer items-center justify-center px-3' + +export const selectEmptyMessageClass = 'px-3 pb-4 text-sm text-muted-foreground' + +export function stopEscapeToDocument(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.stopPropagation() + event.stopImmediatePropagation() + } +} diff --git a/src/components/loader/Loader.stories.ts b/src/components/loader/Loader.stories.ts index 519920c548..0d3b52c4a9 100644 --- a/src/components/loader/Loader.stories.ts +++ b/src/components/loader/Loader.stories.ts @@ -14,6 +14,11 @@ const meta: Meta = { control: 'select', options: ['sm', 'md', 'lg'], description: 'Spinner size: sm (16px), md (32px), lg (48px)' + }, + variant: { + control: 'select', + options: ['loader', 'loader-circle'], + description: 'The type of loader displayed' } } } diff --git a/src/components/node/NodePreviewCard.vue b/src/components/node/NodePreviewCard.vue index 557369a66f..2d11617dd4 100644 --- a/src/components/node/NodePreviewCard.vue +++ b/src/components/node/NodePreviewCard.vue @@ -32,7 +32,7 @@

{{ nodeDef.description }}

diff --git a/src/components/queue/QueueInlineProgress.test.ts b/src/components/queue/QueueInlineProgress.test.ts index a065789f3d..66e9dde2bb 100644 --- a/src/components/queue/QueueInlineProgress.test.ts +++ b/src/components/queue/QueueInlineProgress.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 { nextTick, ref } from 'vue' import type { Ref } from 'vue' @@ -17,8 +17,9 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({ }) })) -const createWrapper = (props: { hidden?: boolean } = {}) => - mount(QueueInlineProgress, { props }) +function renderComponent(props: { hidden?: boolean } = {}) { + return render(QueueInlineProgress, { props }) +} describe('QueueInlineProgress', () => { beforeEach(() => { @@ -29,47 +30,53 @@ describe('QueueInlineProgress', () => { it('renders when total progress is non-zero', () => { mockProgress.totalPercent.value = 12 - const wrapper = createWrapper() + renderComponent() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true) + expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument() }) it('renders when current node progress is non-zero', () => { mockProgress.currentNodePercent.value = 33 - const wrapper = createWrapper() + renderComponent() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true) + expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument() }) it('does not render when hidden', () => { mockProgress.totalPercent.value = 45 - const wrapper = createWrapper({ hidden: true }) + renderComponent({ hidden: true }) - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false) + expect( + screen.queryByTestId('queue-inline-progress') + ).not.toBeInTheDocument() }) it('shows when progress becomes non-zero', async () => { - const wrapper = createWrapper() + renderComponent() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false) + expect( + screen.queryByTestId('queue-inline-progress') + ).not.toBeInTheDocument() mockProgress.totalPercent.value = 10 await nextTick() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true) + expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument() }) it('hides when progress returns to zero', async () => { mockProgress.totalPercent.value = 10 - const wrapper = createWrapper() + renderComponent() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true) + expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument() mockProgress.totalPercent.value = 0 mockProgress.currentNodePercent.value = 0 await nextTick() - expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false) + expect( + screen.queryByTestId('queue-inline-progress') + ).not.toBeInTheDocument() }) }) diff --git a/src/components/queue/QueueInlineProgress.vue b/src/components/queue/QueueInlineProgress.vue index e591e50d20..95c6279a64 100644 --- a/src/components/queue/QueueInlineProgress.vue +++ b/src/components/queue/QueueInlineProgress.vue @@ -1,6 +1,7 @@ diff --git a/src/components/templates/thumbnails/DefaultThumbnail.test.ts b/src/components/templates/thumbnails/DefaultThumbnail.test.ts index ebe138a9ee..0d79568492 100644 --- a/src/components/templates/thumbnails/DefaultThumbnail.test.ts +++ b/src/components/templates/thumbnails/DefaultThumbnail.test.ts @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils' +import { render, screen } from '@testing-library/vue' import { describe, expect, it, vi } from 'vitest' import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue' @@ -21,8 +21,8 @@ vi.mock('@/components/common/LazyImage.vue', () => ({ })) describe('DefaultThumbnail', () => { - const mountThumbnail = (props = {}) => { - return mount(DefaultThumbnail, { + function renderThumbnail(props = {}) { + return render(DefaultThumbnail, { props: { src: '/test-image.jpg', alt: 'Test Image', @@ -33,95 +33,69 @@ describe('DefaultThumbnail', () => { } it('renders image with correct src and alt', () => { - const wrapper = mountThumbnail() - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - expect(lazyImage.props('src')).toBe('/test-image.jpg') - expect(lazyImage.props('alt')).toBe('Test Image') + renderThumbnail() + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', '/test-image.jpg') + expect(img).toHaveAttribute('alt', 'Test Image') }) it('applies scale transform when hovered', () => { - const wrapper = mountThumbnail({ + renderThumbnail({ isHovered: true, hoverZoom: 10 }) - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' }) + expect(screen.getByRole('img')).toHaveStyle({ + transform: 'scale(1.1)' + }) }) it('does not apply scale transform when not hovered', () => { - const wrapper = mountThumbnail({ + renderThumbnail({ isHovered: false }) - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - expect(lazyImage.props('imageStyle')).toBeUndefined() + expect(screen.getByRole('img')).not.toHaveStyle({ + transform: 'scale(1.1)' + }) }) it('applies video styling for video type', () => { - const wrapper = mountThumbnail({ + renderThumbnail({ isVideo: true }) - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - const imageClass = lazyImage.props('imageClass') - const classString = Array.isArray(imageClass) - ? imageClass.join(' ') - : imageClass - expect(classString).toContain('w-full') - expect(classString).toContain('h-full') - expect(classString).toContain('object-cover') + expect(screen.getByRole('img')).toHaveClass( + 'w-full', + 'h-full', + 'object-cover' + ) }) it('applies image styling for non-video type', () => { - const wrapper = mountThumbnail({ + renderThumbnail({ isVideo: false }) - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - const imageClass = lazyImage.props('imageClass') - const classString = Array.isArray(imageClass) - ? imageClass.join(' ') - : imageClass - expect(classString).toContain('max-w-full') - expect(classString).toContain('object-contain') + expect(screen.getByRole('img')).toHaveClass('max-w-full', 'object-contain') }) it('applies correct styling for webp images', () => { - const wrapper = mountThumbnail({ + renderThumbnail({ src: '/test-video.webp', isVideo: true }) - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - const imageClass = lazyImage.props('imageClass') - const classString = Array.isArray(imageClass) - ? imageClass.join(' ') - : imageClass - expect(classString).toContain('object-cover') + expect(screen.getByRole('img')).toHaveClass('object-cover') }) it('image is not draggable', () => { - const wrapper = mountThumbnail() - const img = wrapper.find('img') - expect(img.attributes('draggable')).toBe('false') + renderThumbnail() + expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false') }) it('applies transition classes', () => { - const wrapper = mountThumbnail() - const lazyImage = wrapper.findComponent({ name: 'LazyImage' }) - const imageClass = lazyImage.props('imageClass') - const classString = Array.isArray(imageClass) - ? imageClass.join(' ') - : imageClass - expect(classString).toContain('transform-gpu') - expect(classString).toContain('transition-transform') - expect(classString).toContain('duration-300') - expect(classString).toContain('ease-out') - }) - - it('passes correct props to BaseThumbnail', () => { - const wrapper = mountThumbnail({ - hoverZoom: 20, - isHovered: true - }) - const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' }) - expect(baseThumbnail.props('hoverZoom')).toBe(20) - expect(baseThumbnail.props('isHovered')).toBe(true) + renderThumbnail() + expect(screen.getByRole('img')).toHaveClass( + 'transform-gpu', + 'transition-transform', + 'duration-300', + 'ease-out' + ) }) }) diff --git a/src/components/templates/thumbnails/LogoOverlay.test.ts b/src/components/templates/thumbnails/LogoOverlay.test.ts index 69c4eb39ae..632383986b 100644 --- a/src/components/templates/thumbnails/LogoOverlay.test.ts +++ b/src/components/templates/thumbnails/LogoOverlay.test.ts @@ -1,6 +1,6 @@ -import { mount } from '@vue/test-utils' +import { fireEvent, render, screen } from '@testing-library/vue' import type { ComponentProps } from 'vue-component-type-helpers' -import { nextTick, ref } from 'vue' +import { ref } from 'vue' import { describe, expect, it, vi } from 'vitest' import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue' @@ -21,11 +21,11 @@ describe('LogoOverlay', () => { return `/logos/${provider}.png` } - function mountOverlay( + function renderOverlay( logos: LogoInfo[], props: Partial = {} ) { - return mount(LogoOverlay, { + return render(LogoOverlay, { props: { logos, getLogoUrl: mockGetLogoUrl, @@ -35,123 +35,113 @@ describe('LogoOverlay', () => { } it('renders nothing when logos array is empty', () => { - const wrapper = mountOverlay([]) - expect(wrapper.findAll('img')).toHaveLength(0) + renderOverlay([]) + expect(screen.queryAllByRole('img')).toHaveLength(0) }) it('renders a single logo with correct src and alt', () => { - const wrapper = mountOverlay([{ provider: 'Google' }]) - const img = wrapper.find('img') - expect(img.attributes('src')).toBe('/logos/Google.png') - expect(img.attributes('alt')).toBe('Google') + renderOverlay([{ provider: 'Google' }]) + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', '/logos/Google.png') + expect(img).toHaveAttribute('alt', 'Google') }) it('renders multiple separate logo entries', () => { - const wrapper = mountOverlay([ + renderOverlay([ { provider: 'Google' }, { provider: 'OpenAI' }, { provider: 'Stability' } ]) - expect(wrapper.findAll('img')).toHaveLength(3) + expect(screen.getAllByRole('img')).toHaveLength(3) }) it('displays provider name as label for single provider', () => { - const wrapper = mountOverlay([{ provider: 'Google' }]) - const span = wrapper.find('span') - expect(span.text()).toBe('Google') + renderOverlay([{ provider: 'Google' }]) + expect(screen.getByText('Google')).toBeInTheDocument() }) it('images are not draggable', () => { - const wrapper = mountOverlay([{ provider: 'Google' }]) - const img = wrapper.find('img') - expect(img.attributes('draggable')).toBe('false') + renderOverlay([{ provider: 'Google' }]) + expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false') }) it('filters out logos with empty URLs', () => { function getLogoUrl(provider: string) { return provider === 'Google' ? '/logos/Google.png' : '' } - const wrapper = mount(LogoOverlay, { + render(LogoOverlay, { props: { logos: [{ provider: 'Google' }, { provider: 'Unknown' }], getLogoUrl } }) - expect(wrapper.findAll('img')).toHaveLength(1) + expect(screen.getAllByRole('img')).toHaveLength(1) }) it('renders one logo per unique provider', () => { - const wrapper = mountOverlay([ - { provider: 'Google' }, - { provider: 'OpenAI' } - ]) - expect(wrapper.findAll('img')).toHaveLength(2) + renderOverlay([{ provider: 'Google' }, { provider: 'OpenAI' }]) + expect(screen.getAllByRole('img')).toHaveLength(2) }) describe('stacked logos', () => { it('renders multiple providers as stacked overlapping logos', () => { - const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) - const images = wrapper.findAll('img') + renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + const images = screen.getAllByRole('img') expect(images).toHaveLength(2) - expect(images[0].attributes('alt')).toBe('WaveSpeed') - expect(images[1].attributes('alt')).toBe('Hunyuan') + expect(images[0]).toHaveAttribute('alt', 'WaveSpeed') + expect(images[1]).toHaveAttribute('alt', 'Hunyuan') }) it('joins provider names with locale-aware conjunction for default label', () => { - const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) - const span = wrapper.find('span') - expect(span.text()).toBe('WaveSpeed and Hunyuan') + renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + expect(screen.getByText('WaveSpeed and Hunyuan')).toBeInTheDocument() }) it('uses custom label when provided', () => { - const wrapper = mountOverlay([ + renderOverlay([ { provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' } ]) - const span = wrapper.find('span') - expect(span.text()).toBe('Custom Label') + expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('applies negative gap for overlap effect', () => { - const wrapper = mountOverlay([ - { provider: ['WaveSpeed', 'Hunyuan'], gap: -8 } - ]) - const images = wrapper.findAll('img') - expect(images[1].attributes('style')).toContain('margin-left: -8px') + renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }]) + const images = screen.getAllByRole('img') + expect(images[1]).toHaveStyle({ marginLeft: '-8px' }) }) it('applies default gap when not specified', () => { - const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) - const images = wrapper.findAll('img') - expect(images[1].attributes('style')).toContain('margin-left: -6px') + renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }]) + const images = screen.getAllByRole('img') + expect(images[1]).toHaveStyle({ marginLeft: '-6px' }) }) it('filters out invalid providers from stacked logos', () => { function getLogoUrl(provider: string) { return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : '' } - const wrapper = mount(LogoOverlay, { + render(LogoOverlay, { props: { logos: [{ provider: ['WaveSpeed', 'Unknown'] }], getLogoUrl } }) - expect(wrapper.findAll('img')).toHaveLength(1) - expect(wrapper.find('span').text()).toBe('WaveSpeed') + expect(screen.getAllByRole('img')).toHaveLength(1) + expect(screen.getByText('WaveSpeed')).toBeInTheDocument() }) }) describe('error handling', () => { it('keeps showing remaining providers when one image fails in stacked logos', async () => { - const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }]) - const images = wrapper.findAll('[data-testid="logo-img"]') + renderOverlay([{ provider: ['Google', 'OpenAI'] }]) + const images = screen.getAllByTestId('logo-img') expect(images).toHaveLength(2) - await images[0].trigger('error') - await nextTick() + await fireEvent.error(images[0]) - const remainingImages = wrapper.findAll('[data-testid="logo-img"]') + const remainingImages = screen.getAllByTestId('logo-img') expect(remainingImages).toHaveLength(2) - expect(remainingImages[1].attributes('alt')).toBe('OpenAI') + expect(remainingImages[1]).toHaveAttribute('alt', 'OpenAI') }) }) }) diff --git a/src/components/topbar/CurrentUserPopoverLegacy.test.ts b/src/components/topbar/CurrentUserPopoverLegacy.test.ts index 1205ac8f39..c36bf77a3b 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.test.ts +++ b/src/components/topbar/CurrentUserPopoverLegacy.test.ts @@ -61,10 +61,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) })) -// Mock the useFirebaseAuthActions composable +// Mock the useAuthActions composable const mockLogout = vi.fn() -vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ - useFirebaseAuthActions: vi.fn(() => ({ +vi.mock('@/composables/auth/useAuthActions', () => ({ + useAuthActions: vi.fn(() => ({ fetchBalance: vi.fn().mockResolvedValue(undefined), logout: mockLogout })) @@ -77,7 +77,7 @@ vi.mock('@/services/dialogService', () => ({ })) })) -// Mock the firebaseAuthStore with hoisted state for per-test manipulation +// Mock the authStore with hoisted state for per-test manipulation const mockAuthStoreState = vi.hoisted(() => ({ balance: { amount_micros: 100_000, @@ -91,8 +91,8 @@ const mockAuthStoreState = vi.hoisted(() => ({ isFetchingBalance: false })) -vi.mock('@/stores/firebaseAuthStore', () => ({ - useFirebaseAuthStore: vi.fn(() => ({ +vi.mock('@/stores/authStore', () => ({ + useAuthStore: vi.fn(() => ({ getAuthHeader: vi .fn() .mockResolvedValue({ Authorization: 'Bearer mock-token' }), diff --git a/src/components/topbar/CurrentUserPopoverLegacy.vue b/src/components/topbar/CurrentUserPopoverLegacy.vue index 010de07696..c0fd24b7ed 100644 --- a/src/components/topbar/CurrentUserPopoverLegacy.vue +++ b/src/components/topbar/CurrentUserPopoverLegacy.vue @@ -159,7 +159,7 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits' import UserAvatar from '@/components/common/UserAvatar.vue' import Button from '@/components/ui/button/Button.vue' import { useCurrentUser } from '@/composables/auth/useCurrentUser' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' import { useExternalLink } from '@/composables/useExternalLink' import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' @@ -168,7 +168,7 @@ import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog' import { useDialogService } from '@/services/dialogService' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' const emit = defineEmits<{ close: [] @@ -178,8 +178,8 @@ const { buildDocsUrl, docsPaths } = useExternalLink() const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } = useCurrentUser() -const authActions = useFirebaseAuthActions() -const authStore = useFirebaseAuthStore() +const authActions = useAuthActions() +const authStore = useAuthStore() const settingsDialog = useSettingsDialog() const dialogService = useDialogService() const { diff --git a/src/components/topbar/TopbarBadge.test.ts b/src/components/topbar/TopbarBadge.test.ts index 1383c002f2..6ed5a28723 100644 --- a/src/components/topbar/TopbarBadge.test.ts +++ b/src/components/topbar/TopbarBadge.test.ts @@ -1,5 +1,5 @@ -import { mount } from '@vue/test-utils' -import Popover from 'primevue/popover' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' import PrimeVue from 'primevue/config' import Tooltip from 'primevue/tooltip' import { describe, expect, it } from 'vitest' @@ -24,26 +24,27 @@ describe('TopbarBadge', () => { variant: 'info' } - const mountTopbarBadge = ( + function renderTopbarBadge( badge: Partial = {}, displayMode: 'full' | 'compact' | 'icon-only' = 'full' - ) => { - return mount(TopbarBadge, { + ) { + const user = userEvent.setup() + const result = render(TopbarBadge, { global: { plugins: [PrimeVue, i18n], - directives: { tooltip: Tooltip }, - components: { Popover } + directives: { tooltip: Tooltip } }, props: { badge: { ...exampleBadge, ...badge }, displayMode } }) + return { ...result, user } } describe('full display mode', () => { it('renders all badge elements (icon, label, text)', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Comfy Cloud', label: 'BETA', @@ -51,30 +52,28 @@ describe('TopbarBadge', () => { }, 'full' ) - - expect(wrapper.find('.pi-cloud').exists()).toBe(true) - expect(wrapper.text()).toContain('BETA') - expect(wrapper.text()).toContain('Comfy Cloud') + expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud') + expect(screen.getByText('BETA')).toBeInTheDocument() + expect(screen.getByText('Comfy Cloud')).toBeInTheDocument() }) it('renders without icon when not provided', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Test', label: 'NEW' }, 'full' ) - - expect(wrapper.find('i').exists()).toBe(false) - expect(wrapper.text()).toContain('NEW') - expect(wrapper.text()).toContain('Test') + expect(screen.queryByTestId('badge-icon')).not.toBeInTheDocument() + expect(screen.getByText('NEW')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() }) }) describe('compact display mode', () => { it('renders icon and label but not text', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Hidden Text', label: 'BETA', @@ -82,32 +81,28 @@ describe('TopbarBadge', () => { }, 'compact' ) - - expect(wrapper.find('.pi-cloud').exists()).toBe(true) - expect(wrapper.text()).toContain('BETA') - expect(wrapper.text()).not.toContain('Hidden Text') + expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud') + expect(screen.getByText('BETA')).toBeInTheDocument() + expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument() }) - it('opens popover on click', async () => { - const wrapper = mountTopbarBadge( + it('reveals full text when clicked', async () => { + const { user } = renderTopbarBadge( { text: 'Full Text', label: 'ALERT' }, 'compact' ) - - const clickableArea = wrapper.find('[class*="flex h-full"]') - await clickableArea.trigger('click') - - const popover = wrapper.findComponent(Popover) - expect(popover.exists()).toBe(true) + expect(screen.queryByText('Full Text')).not.toBeInTheDocument() + await user.click(screen.getByText('ALERT')) + expect(await screen.findByText('Full Text')).toBeInTheDocument() }) }) describe('icon-only display mode', () => { it('renders only icon', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Hidden Text', label: 'BETA', @@ -115,29 +110,27 @@ describe('TopbarBadge', () => { }, 'icon-only' ) - - expect(wrapper.find('.pi-cloud').exists()).toBe(true) - expect(wrapper.text()).not.toContain('BETA') - expect(wrapper.text()).not.toContain('Hidden Text') + expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud') + expect(screen.queryByText('BETA')).not.toBeInTheDocument() + expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument() }) it('renders label when no icon provided', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Hidden Text', label: 'NEW' }, 'icon-only' ) - - expect(wrapper.text()).toContain('NEW') - expect(wrapper.text()).not.toContain('Hidden Text') + expect(screen.getByText('NEW')).toBeInTheDocument() + expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument() }) }) describe('badge variants', () => { it('applies error variant styles', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Error Message', label: 'ERROR', @@ -145,13 +138,12 @@ describe('TopbarBadge', () => { }, 'full' ) - - expect(wrapper.find('.bg-danger-100').exists()).toBe(true) - expect(wrapper.find('.text-danger-100').exists()).toBe(true) + expect(screen.getByText('ERROR')).toHaveClass('bg-danger-100') + expect(screen.getByText('Error Message')).toHaveClass('text-danger-100') }) it('applies warning variant styles', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Warning Message', label: 'WARN', @@ -159,65 +151,55 @@ describe('TopbarBadge', () => { }, 'full' ) - - expect(wrapper.find('.bg-gold-600').exists()).toBe(true) - expect(wrapper.find('.text-warning-background').exists()).toBe(true) + expect(screen.getByText('WARN')).toHaveClass('bg-gold-600') + expect(screen.getByText('Warning Message')).toHaveClass( + 'text-warning-background' + ) }) it('uses default error icon for error variant', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Error', variant: 'error' }, 'full' ) - - expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(true) + expect(screen.getByTestId('badge-icon')).toHaveClass( + 'pi-exclamation-circle' + ) }) it('uses default warning icon for warning variant', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Warning', variant: 'warning' }, 'full' ) - - expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe( - true + expect(screen.getByTestId('badge-icon')).toHaveClass( + 'icon-[lucide--triangle-alert]' ) }) }) - describe('popover', () => { - it('includes popover component in compact and icon-only modes', () => { - const compactWrapper = mountTopbarBadge({}, 'compact') - const iconOnlyWrapper = mountTopbarBadge({}, 'icon-only') - const fullWrapper = mountTopbarBadge({}, 'full') - - expect(compactWrapper.findComponent(Popover).exists()).toBe(true) - expect(iconOnlyWrapper.findComponent(Popover).exists()).toBe(true) - expect(fullWrapper.findComponent(Popover).exists()).toBe(false) - }) - }) - describe('edge cases', () => { it('handles badge with only text', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { - text: 'Simple Badge' + text: 'Simple Badge', + label: undefined }, 'full' ) - - expect(wrapper.text()).toContain('Simple Badge') - expect(wrapper.find('i').exists()).toBe(false) + expect(screen.getByText('Simple Badge')).toBeInTheDocument() + expect(screen.queryByText('BETA')).not.toBeInTheDocument() + expect(screen.queryByTestId('badge-icon')).not.toBeInTheDocument() }) it('handles custom icon override', () => { - const wrapper = mountTopbarBadge( + renderTopbarBadge( { text: 'Custom', variant: 'error', @@ -225,9 +207,10 @@ describe('TopbarBadge', () => { }, 'full' ) - - expect(wrapper.find('.pi-custom-icon').exists()).toBe(true) - expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(false) + expect(screen.getByTestId('badge-icon')).toHaveClass('pi-custom-icon') + expect(screen.getByTestId('badge-icon')).not.toHaveClass( + 'pi-exclamation-circle' + ) }) }) }) diff --git a/src/components/topbar/TopbarBadge.vue b/src/components/topbar/TopbarBadge.vue index 179589397c..d57d1fba02 100644 --- a/src/components/topbar/TopbarBadge.vue +++ b/src/components/topbar/TopbarBadge.vue @@ -9,6 +9,7 @@ >
{ vi.useRealTimers() }) - function mountComponent(props = {}) { - return mount(SearchInput, { + function renderComponent(props = {}) { + const result = render(SearchInput, { global: { plugins: [i18n], stubs: { @@ -63,140 +64,142 @@ describe('SearchInput', () => { ...props } }) + + return result } describe('debounced search', () => { it('should debounce search input by 300ms', async () => { - const wrapper = mountComponent() - const input = wrapper.find('input') + const onSearch = vi.fn() + renderComponent({ onSearch }) + const input = screen.getByRole('textbox') - await input.setValue('test') + await fireEvent.update(input, 'test') - expect(wrapper.emitted('search')).toBeFalsy() + expect(onSearch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(299) await nextTick() - expect(wrapper.emitted('search')).toBeFalsy() + expect(onSearch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) await nextTick() - expect(wrapper.emitted('search')).toEqual([['test']]) + expect(onSearch).toHaveBeenCalledWith('test') }) it('should reset debounce timer on each keystroke', async () => { - const wrapper = mountComponent() - const input = wrapper.find('input') + const onSearch = vi.fn() + renderComponent({ onSearch }) + const input = screen.getByRole('textbox') - await input.setValue('t') + await fireEvent.update(input, 't') vi.advanceTimersByTime(200) await nextTick() - await input.setValue('te') + await fireEvent.update(input, 'te') vi.advanceTimersByTime(200) await nextTick() - await input.setValue('tes') + await fireEvent.update(input, 'tes') await vi.advanceTimersByTimeAsync(200) await nextTick() - expect(wrapper.emitted('search')).toBeFalsy() + expect(onSearch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(100) await nextTick() - expect(wrapper.emitted('search')).toBeTruthy() - expect(wrapper.emitted('search')?.[0]).toEqual(['tes']) + expect(onSearch).toHaveBeenCalled() + expect(onSearch).toHaveBeenCalledWith('tes') }) it('should only emit final value after rapid typing', async () => { - const wrapper = mountComponent() - const input = wrapper.find('input') + const onSearch = vi.fn() + renderComponent({ onSearch }) + const input = screen.getByRole('textbox') const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search'] for (const term of searchTerms) { - await input.setValue(term) + await fireEvent.update(input, term) await vi.advanceTimersByTimeAsync(50) } await nextTick() - expect(wrapper.emitted('search')).toBeFalsy() + expect(onSearch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(350) await nextTick() - expect(wrapper.emitted('search')).toHaveLength(1) - expect(wrapper.emitted('search')?.[0]).toEqual(['search']) + expect(onSearch).toHaveBeenCalledTimes(1) + expect(onSearch).toHaveBeenCalledWith('search') }) }) describe('model sync', () => { it('should sync external model changes to internal state', async () => { - const wrapper = mountComponent({ modelValue: 'initial' }) - const input = wrapper.find('input') + const { rerender } = renderComponent({ modelValue: 'initial' }) + const input = screen.getByRole('textbox') - expect(input.element.value).toBe('initial') + expect(input).toHaveValue('initial') - await wrapper.setProps({ modelValue: 'external update' }) + await rerender({ modelValue: 'external update' }) await nextTick() - expect(input.element.value).toBe('external update') + expect(input).toHaveValue('external update') }) }) describe('placeholder', () => { it('should use custom placeholder when provided', () => { - const wrapper = mountComponent({ placeholder: 'Custom search...' }) - const input = wrapper.find('input') - - expect(input.attributes('placeholder')).toBe('Custom search...') + renderComponent({ placeholder: 'Custom search...' }) + expect( + screen.getByPlaceholderText('Custom search...') + ).toBeInTheDocument() }) it('should use i18n placeholder when not provided', () => { - const wrapper = mountComponent() - const input = wrapper.find('input') - - expect(input.attributes('placeholder')).toBe('Search...') + renderComponent() + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument() }) }) describe('autofocus', () => { it('should pass autofocus prop to ComboboxInput', () => { - const wrapper = mountComponent({ autofocus: true }) - const input = wrapper.find('input') - expect(input.attributes('autofocus')).toBeDefined() + renderComponent({ autofocus: true }) + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('autofocus') }) it('should not autofocus by default', () => { - const wrapper = mountComponent() - const input = wrapper.find('input') - expect(input.attributes('autofocus')).toBeUndefined() - }) - }) - - describe('focus method', () => { - it('should expose focus method via ref', () => { - const wrapper = mountComponent() - expect(wrapper.vm.focus).toBeDefined() + renderComponent() + const input = screen.getByRole('textbox') + expect(input).not.toHaveAttribute('autofocus') }) }) describe('clear button', () => { it('shows search icon when value is empty', () => { - const wrapper = mountComponent({ modelValue: '' }) - expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false) + renderComponent({ modelValue: '' }) + expect( + screen.queryByRole('button', { name: 'Clear' }) + ).not.toBeInTheDocument() }) it('shows clear button when value is not empty', () => { - const wrapper = mountComponent({ modelValue: 'test' }) - expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true) + renderComponent({ modelValue: 'test' }) + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument() }) it('clears value when clear button is clicked', async () => { - const wrapper = mountComponent({ modelValue: 'test' }) - const clearButton = wrapper.find('button') - await clearButton.trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']) + const onUpdate = vi.fn() + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + renderComponent({ + modelValue: 'test', + 'onUpdate:modelValue': onUpdate + }) + await user.click(screen.getByRole('button', { name: 'Clear' })) + expect(onUpdate).toHaveBeenCalledWith('') }) }) }) diff --git a/src/components/ui/tags-input/TagsInput.test.ts b/src/components/ui/tags-input/TagsInput.test.ts index fe0a3a2cc9..bc7390b804 100644 --- a/src/components/ui/tags-input/TagsInput.test.ts +++ b/src/components/ui/tags-input/TagsInput.test.ts @@ -1,5 +1,6 @@ -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 { describe, expect, it, vi } from 'vitest' import { h, nextTick } from 'vue' import { createI18n } from 'vue-i18n' @@ -16,29 +17,39 @@ const i18n = createI18n({ }) describe('TagsInput', () => { - function mountTagsInput(props = {}, slots = {}) { - return mount(TagsInput, { + function renderTagsInput(props = {}, slots = {}) { + const user = userEvent.setup() + + const result = render(TagsInput, { props: { modelValue: [], ...props }, slots }) + + return { ...result, user } } it('renders slot content', () => { - const wrapper = mountTagsInput({}, { default: 'Slot Content' }) + renderTagsInput({}, { default: 'Slot Content' }) - expect(wrapper.text()).toContain('Slot Content') + expect(screen.getByText('Slot Content')).toBeInTheDocument() }) }) describe('TagsInput with child components', () => { - function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) { - return mount(TagsInput, { + function renderFullTagsInput( + tags: string[] = ['tag1', 'tag2'], + extraProps: Record = {} + ) { + const user = userEvent.setup() + + const result = render(TagsInput, { global: { plugins: [i18n] }, props: { - modelValue: tags + modelValue: tags, + ...extraProps }, slots: { default: () => [ @@ -52,55 +63,52 @@ describe('TagsInput with child components', () => { ] } }) + + return { ...result, user } } it('renders tags structure and content', () => { const tags = ['tag1', 'tag2'] - const wrapper = mountFullTagsInput(tags) + renderFullTagsInput(tags) - const items = wrapper.findAllComponents(TagsInputItem) - const textElements = wrapper.findAllComponents(TagsInputItemText) - const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete) + expect(screen.getByText('tag1')).toBeInTheDocument() + expect(screen.getByText('tag2')).toBeInTheDocument() - expect(items).toHaveLength(tags.length) - expect(textElements).toHaveLength(tags.length) + const deleteButtons = tags.map((tag) => + screen.getByRole('button', { name: tag }) + ) expect(deleteButtons).toHaveLength(tags.length) - - textElements.forEach((el, i) => { - expect(el.text()).toBe(tags[i]) - }) - - expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true) }) it('updates model value when adding a tag', async () => { - let currentTags = ['existing'] + const onUpdate = vi.fn() - const wrapper = mount>(TagsInput, { + const user = userEvent.setup() + const { container } = render(TagsInput, { props: { - modelValue: currentTags, - 'onUpdate:modelValue': (payload) => { - currentTags = payload - } + modelValue: ['existing'], + 'onUpdate:modelValue': onUpdate }, slots: { default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) } }) - await wrapper.trigger('click') + // Click the container to enter edit mode and show the input + // eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to enter edit mode; no role/label available + await user.click(container.firstElementChild!) await nextTick() - const input = wrapper.find('input') - await input.setValue('newTag') - await input.trigger('keydown', { key: 'Enter' }) + const input = screen.getByPlaceholderText('Add tag...') + await user.type(input, 'newTag{Enter}') await nextTick() - expect(currentTags).toContain('newTag') + expect(onUpdate).toHaveBeenCalledWith(['existing', 'newTag']) }) it('does not enter edit mode when disabled', async () => { - const wrapper = mount>(TagsInput, { + const user = userEvent.setup() + const { container } = render(TagsInput, { props: { modelValue: ['tag1'], disabled: true @@ -110,18 +118,21 @@ describe('TagsInput with child components', () => { } }) - expect(wrapper.find('input').exists()).toBe(false) + expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument() - await wrapper.trigger('click') + // eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click to test disabled behavior; no role/label available + await user.click(container.firstElementChild!) await nextTick() - expect(wrapper.find('input').exists()).toBe(false) + expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument() }) it('exits edit mode when clicking outside', async () => { const outsideElement = document.createElement('div') document.body.appendChild(outsideElement) - const wrapper = mount>(TagsInput, { + + const user = userEvent.setup() + const { container } = render(TagsInput, { props: { modelValue: ['tag1'] }, @@ -130,21 +141,21 @@ describe('TagsInput with child components', () => { } }) - await wrapper.trigger('click') + // eslint-disable-next-line testing-library/no-node-access -- TagsInput root element needs click; no role/label + await user.click(container.firstElementChild!) await nextTick() - expect(wrapper.find('input').exists()).toBe(true) + expect(screen.getByPlaceholderText('Add tag...')).toBeInTheDocument() outsideElement.dispatchEvent(new PointerEvent('click', { bubbles: true })) await nextTick() - expect(wrapper.find('input').exists()).toBe(false) + expect(screen.queryByPlaceholderText('Add tag...')).not.toBeInTheDocument() - wrapper.unmount() outsideElement.remove() }) it('shows placeholder when modelValue is empty', async () => { - const wrapper = mount>(TagsInput, { + render(TagsInput, { props: { modelValue: [] }, @@ -156,8 +167,7 @@ describe('TagsInput with child components', () => { await nextTick() - const input = wrapper.find('input') - expect(input.exists()).toBe(true) - expect(input.attributes('placeholder')).toBe('Add tag...') + const input = screen.getByPlaceholderText('Add tag...') + expect(input).toBeInTheDocument() }) }) diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useAuthActions.ts similarity index 92% rename from src/composables/auth/useFirebaseAuthActions.ts rename to src/composables/auth/useAuthActions.ts index 6da741e3a8..d721cd0646 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useAuthActions.ts @@ -11,8 +11,8 @@ import { useTelemetry } from '@/platform/telemetry' import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useDialogService } from '@/services/dialogService' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' -import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' +import type { BillingPortalTargetTier } from '@/stores/authStore' import { usdToMicros } from '@/utils/formatUtil' /** @@ -20,8 +20,8 @@ import { usdToMicros } from '@/utils/formatUtil' * All actions are wrapped with error handling. * @returns {Object} - Object containing all Firebase Auth actions */ -export const useFirebaseAuthActions = () => { - const authStore = useFirebaseAuthStore() +export const useAuthActions = () => { + const authStore = useAuthStore() const toastStore = useToastStore() const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling() @@ -140,13 +140,19 @@ export const useFirebaseAuthActions = () => { return result }, reportError) - const signInWithGoogle = wrapWithErrorHandlingAsync(async () => { - return await authStore.loginWithGoogle() - }, reportError) + const signInWithGoogle = wrapWithErrorHandlingAsync( + async (options?: { isNewUser?: boolean }) => { + return await authStore.loginWithGoogle(options) + }, + reportError + ) - const signInWithGithub = wrapWithErrorHandlingAsync(async () => { - return await authStore.loginWithGithub() - }, reportError) + const signInWithGithub = wrapWithErrorHandlingAsync( + async (options?: { isNewUser?: boolean }) => { + return await authStore.loginWithGithub(options) + }, + reportError + ) const signInWithEmail = wrapWithErrorHandlingAsync( async (email: string, password: string) => { diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index 28a427749e..9615f6a327 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -3,11 +3,11 @@ import { computed, watch } from 'vue' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import { useCommandStore } from '@/stores/commandStore' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' import type { AuthUserInfo } from '@/types/authTypes' export const useCurrentUser = () => { - const authStore = useFirebaseAuthStore() + const authStore = useAuthStore() const commandStore = useCommandStore() const apiKeyStore = useApiKeyAuthStore() diff --git a/src/composables/billing/useBillingContext.test.ts b/src/composables/billing/useBillingContext.test.ts index 77a82d22d4..7ab80bb2a5 100644 --- a/src/composables/billing/useBillingContext.test.ts +++ b/src/composables/billing/useBillingContext.test.ts @@ -70,8 +70,8 @@ vi.mock( }) ) -vi.mock('@/stores/firebaseAuthStore', () => ({ - useFirebaseAuthStore: () => ({ +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => ({ balance: { amount_micros: 5000000 }, fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 }) }) diff --git a/src/composables/billing/useLegacyBilling.ts b/src/composables/billing/useLegacyBilling.ts index 0e94a798bb..c86bbc55bc 100644 --- a/src/composables/billing/useLegacyBilling.ts +++ b/src/composables/billing/useLegacyBilling.ts @@ -5,7 +5,7 @@ import type { PreviewSubscribeResponse, SubscribeResponse } from '@/platform/workspace/api/workspaceApi' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' import type { BalanceInfo, @@ -33,7 +33,7 @@ export function useLegacyBilling(): BillingState & BillingActions { showSubscriptionDialog: legacyShowSubscriptionDialog } = useSubscription() - const firebaseAuthStore = useFirebaseAuthStore() + const authStore = useAuthStore() const isInitialized = ref(false) const isLoading = ref(false) @@ -55,12 +55,12 @@ export function useLegacyBilling(): BillingState & BillingActions { renewalDate: formattedRenewalDate.value || null, endDate: formattedEndDate.value || null, isCancelled: isCancelled.value, - hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0 + hasFunds: (authStore.balance?.amount_micros ?? 0) > 0 } }) const balance = computed(() => { - const legacyBalance = firebaseAuthStore.balance + const legacyBalance = authStore.balance if (!legacyBalance) return null return { @@ -118,7 +118,7 @@ export function useLegacyBilling(): BillingState & BillingActions { isLoading.value = true error.value = null try { - await firebaseAuthStore.fetchBalance() + await authStore.fetchBalance() } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to fetch balance' diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts index 3fe38977c6..ea82714a84 100644 --- a/src/composables/graph/useErrorClearingHooks.test.ts +++ b/src/composables/graph/useErrorClearingHooks.test.ts @@ -315,6 +315,45 @@ describe('installErrorClearingHooks lifecycle', () => { cleanup() expect(graph.onNodeAdded).toBe(originalHook) }) + + it('restores original node callbacks when a node is removed', () => { + const graph = new LGraph() + const node = new LGraphNode('test') + node.addInput('clip', 'CLIP') + node.addWidget('number', 'steps', 20, () => undefined, {}) + const originalOnConnectionsChange = vi.fn() + const originalOnWidgetChanged = vi.fn() + node.onConnectionsChange = originalOnConnectionsChange + node.onWidgetChanged = originalOnWidgetChanged + graph.add(node) + + installErrorClearingHooks(graph) + + // Callbacks should be chained (not the originals) + expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange) + expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged) + + // Simulate node removal via the graph hook + graph.onNodeRemoved!(node) + + // Original callbacks should be restored + expect(node.onConnectionsChange).toBe(originalOnConnectionsChange) + expect(node.onWidgetChanged).toBe(originalOnWidgetChanged) + }) + + it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => { + const graph = new LGraph() + const node = new LGraphNode('test') + node.addInput('clip', 'CLIP') + graph.add(node) + + installErrorClearingHooks(graph) + const chainedAfterFirst = node.onConnectionsChange + + // Install again on the same graph — should be a no-op for existing nodes + installErrorClearingHooks(graph) + expect(node.onConnectionsChange).toBe(chainedAfterFirst) + }) }) describe('clearWidgetRelatedErrors parameter routing', () => { diff --git a/src/composables/graph/useErrorClearingHooks.ts b/src/composables/graph/useErrorClearingHooks.ts index 27fcc0922d..9f9be0b642 100644 --- a/src/composables/graph/useErrorClearingHooks.ts +++ b/src/composables/graph/useErrorClearingHooks.ts @@ -35,10 +35,22 @@ function resolvePromotedExecId( const hookedNodes = new WeakSet() +type OriginalCallbacks = { + onConnectionsChange: LGraphNode['onConnectionsChange'] + onWidgetChanged: LGraphNode['onWidgetChanged'] +} + +const originalCallbacks = new WeakMap() + function installNodeHooks(node: LGraphNode): void { if (hookedNodes.has(node)) return hookedNodes.add(node) + originalCallbacks.set(node, { + onConnectionsChange: node.onConnectionsChange, + onWidgetChanged: node.onWidgetChanged + }) + node.onConnectionsChange = useChainCallback( node.onConnectionsChange, function (type, slotIndex, isConnected) { @@ -82,6 +94,15 @@ function installNodeHooks(node: LGraphNode): void { ) } +function restoreNodeHooks(node: LGraphNode): void { + const originals = originalCallbacks.get(node) + if (!originals) return + node.onConnectionsChange = originals.onConnectionsChange + node.onWidgetChanged = originals.onWidgetChanged + originalCallbacks.delete(node) + hookedNodes.delete(node) +} + function installNodeHooksRecursive(node: LGraphNode): void { installNodeHooks(node) if (node.isSubgraphNode?.()) { @@ -91,6 +112,15 @@ function installNodeHooksRecursive(node: LGraphNode): void { } } +function restoreNodeHooksRecursive(node: LGraphNode): void { + restoreNodeHooks(node) + if (node.isSubgraphNode?.()) { + for (const innerNode of node.subgraph._nodes ?? []) { + restoreNodeHooksRecursive(innerNode) + } + } +} + export function installErrorClearingHooks(graph: LGraph): () => void { for (const node of graph._nodes ?? []) { installNodeHooksRecursive(node) @@ -102,7 +132,17 @@ export function installErrorClearingHooks(graph: LGraph): () => void { originalOnNodeAdded?.call(this, node) } + const originalOnNodeRemoved = graph.onNodeRemoved + graph.onNodeRemoved = function (node: LGraphNode) { + restoreNodeHooksRecursive(node) + originalOnNodeRemoved?.call(this, node) + } + return () => { + for (const node of graph._nodes ?? []) { + restoreNodeHooksRecursive(node) + } graph.onNodeAdded = originalOnNodeAdded || undefined + graph.onNodeRemoved = originalOnNodeRemoved || undefined } } diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 46ecdb33fa..268e07ec57 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => { const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 }) - // Create a PromotedWidgetView with displayName="value" (subgraph input + // Create a PromotedWidgetView with identityName="value" (subgraph input // slot name) and sourceWidgetName="prompt" (interior widget name). - // PromotedWidgetView.name returns "value", but safeWidgetMapper sets - // SafeWidgetData.name to sourceWidgetName ("prompt"). + // PromotedWidgetView.name returns "value" (identity), safeWidgetMapper + // sets SafeWidgetData.name to sourceWidgetName ("prompt"). const promotedView = createPromotedWidgetView( subgraphNode, '10', 'prompt', + 'value', + undefined, 'value' ) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 88f35079f5..98e7b3df2f 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -92,6 +92,10 @@ export interface SafeWidgetData { * execution ID (e.g. `"65:42"` vs the host node's `"65"`). */ sourceExecutionId?: string + /** Tooltip text from the resolved widget. */ + tooltip?: string + /** For promoted widgets, the display label from the subgraph input slot. */ + promotedLabel?: string } export interface VueNodeData { @@ -352,7 +356,8 @@ function safeWidgetMapper( sourceNode && app.rootGraph ? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined) : undefined, - tooltip: widget.tooltip + tooltip: widget.tooltip, + promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined } } catch (error) { console.warn( @@ -803,6 +808,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) { nodeRef.outputs = [...nodeRef.outputs] } + // Re-extract widget data so promotedLabel reflects the rename + vueNodeData.set(nodeId, extractVueNodeData(nodeRef)) } } diff --git a/src/composables/graph/useNodeErrorFlagSync.ts b/src/composables/graph/useNodeErrorFlagSync.ts new file mode 100644 index 0000000000..bb32b82363 --- /dev/null +++ b/src/composables/graph/useNodeErrorFlagSync.ts @@ -0,0 +1,111 @@ +import type { Ref } from 'vue' +import { computed, watch } from 'vue' + +import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore' +import { useSettingStore } from '@/platform/settings/settingStore' +import { app } from '@/scripts/app' +import type { NodeError } from '@/schemas/apiSchema' +import { getParentExecutionIds } from '@/types/nodeIdentification' +import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil' + +function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void { + if (node.has_errors === hasErrors) return + const oldValue = node.has_errors + node.has_errors = hasErrors + node.graph?.trigger('node:property:changed', { + type: 'node:property:changed', + nodeId: node.id, + property: 'has_errors', + oldValue, + newValue: hasErrors + }) +} + +/** + * Single-pass reconciliation of node error flags. + * Collects the set of nodes that should have errors, then walks all nodes + * once, setting each flag exactly once. This avoids the redundant + * true→false→true transition (and duplicate events) that a clear-then-apply + * approach would cause. + */ +function reconcileNodeErrorFlags( + rootGraph: LGraph, + nodeErrors: Record | null, + missingModelExecIds: Set +): void { + // Collect nodes and slot info that should be flagged + // Includes both error-owning nodes and their ancestor containers + const flaggedNodes = new Set() + const errorSlots = new Map>() + + if (nodeErrors) { + for (const [executionId, nodeError] of Object.entries(nodeErrors)) { + const node = getNodeByExecutionId(rootGraph, executionId) + if (!node) continue + + flaggedNodes.add(node) + const slotNames = new Set() + for (const error of nodeError.errors) { + const name = error.extra_info?.input_name + if (name) slotNames.add(name) + } + if (slotNames.size > 0) errorSlots.set(node, slotNames) + + for (const parentId of getParentExecutionIds(executionId)) { + const parentNode = getNodeByExecutionId(rootGraph, parentId) + if (parentNode) flaggedNodes.add(parentNode) + } + } + } + + for (const execId of missingModelExecIds) { + const node = getNodeByExecutionId(rootGraph, execId) + if (node) flaggedNodes.add(node) + } + + forEachNode(rootGraph, (node) => { + setNodeHasErrors(node, flaggedNodes.has(node)) + + if (node.inputs) { + const nodeSlotNames = errorSlots.get(node) + for (const slot of node.inputs) { + slot.hasErrors = !!nodeSlotNames?.has(slot.name) + } + } + }) +} + +export function useNodeErrorFlagSync( + lastNodeErrors: Ref | null>, + missingModelStore: ReturnType +): () => void { + const settingStore = useSettingStore() + const showErrorsTab = computed(() => + settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') + ) + + const stop = watch( + [ + lastNodeErrors, + () => missingModelStore.missingModelNodeIds, + 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 + // red highlight that Vue nodes use to indicate *why* a node has errors. + // Vue nodes compute hasAnyError independently and are unaffected. + reconcileNodeErrorFlags( + app.rootGraph, + lastNodeErrors.value, + showErrorsTab.value + ? missingModelStore.missingModelAncestorExecutionIds + : new Set() + ) + }, + { flush: 'post' } + ) + return stop +} diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index b009358565..ed3bb60c28 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -86,6 +86,24 @@ function useVueNodeLifecycleIndividual() { () => !shouldRenderVueNodes.value, () => { disposeNodeManagerAndSyncs() + + // Force arrange() on all nodes so input.pos is computed before + // the first legacy drawConnections frame (which may run before + // drawNode on the foreground canvas). + const graph = comfyApp.canvas?.graph + if (!graph) { + comfyApp.canvas?.setDirty(true, true) + return + } + for (const node of graph._nodes) { + if (node.flags.collapsed) continue + try { + node.arrange() + } catch { + /* skip nodes not fully initialized */ + } + } + comfyApp.canvas?.setDirty(true, true) } ) diff --git a/src/composables/useClickDragGuard.test.ts b/src/composables/useClickDragGuard.test.ts new file mode 100644 index 0000000000..dd875a6bbb --- /dev/null +++ b/src/composables/useClickDragGuard.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { + exceedsClickThreshold, + useClickDragGuard +} from '@/composables/useClickDragGuard' + +describe('exceedsClickThreshold', () => { + it('returns false when distance is within threshold', () => { + expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false) + }) + + it('returns true when distance exceeds threshold', () => { + expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true) + }) + + it('returns false when distance exactly equals threshold', () => { + expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false) + }) + + it('handles negative deltas', () => { + expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe( + true + ) + }) +}) + +describe('useClickDragGuard', () => { + it('reports no drag when pointer has not moved', () => { + const guard = useClickDragGuard(5) + guard.recordStart({ clientX: 100, clientY: 200 }) + expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false) + }) + + it('reports no drag when movement is within threshold', () => { + const guard = useClickDragGuard(5) + guard.recordStart({ clientX: 100, clientY: 200 }) + expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false) + }) + + it('reports drag when movement exceeds threshold', () => { + const guard = useClickDragGuard(5) + guard.recordStart({ clientX: 100, clientY: 200 }) + expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true) + }) + + it('returns false when no start has been recorded', () => { + const guard = useClickDragGuard(5) + expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false) + }) + + it('returns false after reset', () => { + const guard = useClickDragGuard(5) + guard.recordStart({ clientX: 100, clientY: 200 }) + guard.reset() + expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false) + }) + + it('respects custom threshold', () => { + const guard = useClickDragGuard(3) + guard.recordStart({ clientX: 0, clientY: 0 }) + expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false) + expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true) + }) +}) diff --git a/src/composables/useClickDragGuard.ts b/src/composables/useClickDragGuard.ts new file mode 100644 index 0000000000..b59b14ee28 --- /dev/null +++ b/src/composables/useClickDragGuard.ts @@ -0,0 +1,41 @@ +interface PointerPosition { + readonly x: number + readonly y: number +} + +function squaredDistance(a: PointerPosition, b: PointerPosition): number { + const dx = a.x - b.x + const dy = a.y - b.y + return dx * dx + dy * dy +} + +export function exceedsClickThreshold( + start: PointerPosition, + end: PointerPosition, + threshold: number +): boolean { + return squaredDistance(start, end) > threshold * threshold +} + +export function useClickDragGuard(threshold: number = 5) { + let start: PointerPosition | null = null + + function recordStart(e: { clientX: number; clientY: number }) { + start = { x: e.clientX, y: e.clientY } + } + + function wasDragged(e: { clientX: number; clientY: number }): boolean { + if (!start) return false + return exceedsClickThreshold( + start, + { x: e.clientX, y: e.clientY }, + threshold + ) + } + + function reset() { + start = null + } + + return { recordStart, wasDragged, reset } +} diff --git a/src/composables/useCopyToClipboard.test.ts b/src/composables/useCopyToClipboard.test.ts new file mode 100644 index 0000000000..ebe496df21 --- /dev/null +++ b/src/composables/useCopyToClipboard.test.ts @@ -0,0 +1,94 @@ +import { computed, ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCopy = vi.fn() +const mockToastAdd = vi.fn() + +vi.mock('@vueuse/core', () => ({ + useClipboard: vi.fn(() => ({ + copy: mockCopy, + copied: ref(false), + isSupported: computed(() => true) + })) +})) + +vi.mock('primevue/usetoast', () => ({ + useToast: vi.fn(() => ({ + add: mockToastAdd + })) +})) + +vi.mock('@/i18n', () => ({ + t: (key: string) => key +})) + +import { useClipboard } from '@vueuse/core' +import { useCopyToClipboard } from '@/composables/useCopyToClipboard' + +describe('useCopyToClipboard', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(useClipboard).mockReturnValue({ + copy: mockCopy, + copied: ref(false), + isSupported: computed(() => true), + text: ref('') + }) + }) + + it('shows success toast when modern clipboard succeeds', async () => { + mockCopy.mockResolvedValue(undefined) + + const { copyToClipboard } = useCopyToClipboard() + await copyToClipboard('hello') + + expect(mockCopy).toHaveBeenCalledWith('hello') + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + }) + + it('falls back to legacy when modern clipboard fails', async () => { + mockCopy.mockRejectedValue(new Error('Not allowed')) + document.execCommand = vi.fn(() => true) + + const { copyToClipboard } = useCopyToClipboard() + await copyToClipboard('hello') + + expect(document.execCommand).toHaveBeenCalledWith('copy') + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + }) + + it('shows error toast when both modern and legacy fail', async () => { + mockCopy.mockRejectedValue(new Error('Not allowed')) + document.execCommand = vi.fn(() => false) + + const { copyToClipboard } = useCopyToClipboard() + await copyToClipboard('hello') + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }) + ) + }) + + it('falls through to legacy when isSupported is false', async () => { + vi.mocked(useClipboard).mockReturnValue({ + copy: mockCopy, + copied: ref(false), + isSupported: computed(() => false), + text: ref('') + }) + document.execCommand = vi.fn(() => true) + + const { copyToClipboard } = useCopyToClipboard() + await copyToClipboard('hello') + + expect(mockCopy).not.toHaveBeenCalled() + expect(document.execCommand).toHaveBeenCalledWith('copy') + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }) + ) + }) +}) diff --git a/src/composables/useCopyToClipboard.ts b/src/composables/useCopyToClipboard.ts index 05b8537ab6..087b8c1451 100644 --- a/src/composables/useCopyToClipboard.ts +++ b/src/composables/useCopyToClipboard.ts @@ -3,34 +3,60 @@ import { useToast } from 'primevue/usetoast' import { t } from '@/i18n' +function legacyCopy(text: string): boolean { + const textarea = document.createElement('textarea') + textarea.setAttribute('readonly', '') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + textarea.style.top = '-9999px' + document.body.appendChild(textarea) + textarea.select() + try { + return document.execCommand('copy') + } finally { + textarea.remove() + } +} + export function useCopyToClipboard() { - const { copy, copied } = useClipboard({ legacy: true }) + const { copy, isSupported } = useClipboard() const toast = useToast() async function copyToClipboard(text: string) { + let success = false + try { - await copy(text) - if (copied.value) { - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('clipboard.successMessage'), - life: 3000 - }) - } else { - toast.add({ - severity: 'error', - summary: t('g.error'), - detail: t('clipboard.errorMessage') - }) + if (isSupported.value) { + await copy(text) + success = true } } catch { - toast.add({ - severity: 'error', - summary: t('g.error'), - detail: t('clipboard.errorMessage') - }) + // Modern clipboard API failed, fall through to legacy } + + if (!success) { + try { + success = legacyCopy(text) + } catch { + // Legacy also failed + } + } + + toast.add( + success + ? { + severity: 'success', + summary: t('g.success'), + detail: t('clipboard.successMessage'), + life: 3000 + } + : { + severity: 'error', + summary: t('g.error'), + detail: t('clipboard.errorMessage') + } + ) } return { diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index f5df599ae4..84127747ad 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -56,8 +56,8 @@ vi.mock('@/scripts/api', () => ({ vi.mock('@/platform/settings/settingStore') -vi.mock('@/stores/firebaseAuthStore', () => ({ - useFirebaseAuthStore: vi.fn(() => ({})) +vi.mock('@/stores/authStore', () => ({ + useAuthStore: vi.fn(() => ({})) })) vi.mock('@/composables/auth/useFirebaseAuth', () => ({ @@ -123,8 +123,8 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({ useColorPaletteStore: vi.fn(() => ({})) })) -vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ - useFirebaseAuthActions: vi.fn(() => ({})) +vi.mock('@/composables/auth/useAuthActions', () => ({ + useAuthActions: vi.fn(() => ({})) })) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index f23b8fc8c6..1f13d65522 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -1,5 +1,5 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations' import { useExternalLink } from '@/composables/useExternalLink' @@ -78,7 +78,7 @@ export function useCoreCommands(): ComfyCommand[] { const settingsDialog = useSettingsDialog() const dialogService = useDialogService() const colorPaletteStore = useColorPaletteStore() - const firebaseAuthActions = useFirebaseAuthActions() + const authActions = useAuthActions() const toastStore = useToastStore() const canvasStore = useCanvasStore() const executionStore = useExecutionStore() @@ -996,7 +996,7 @@ export function useCoreCommands(): ComfyCommand[] { label: 'Sign Out', versionAdded: '1.18.1', function: async () => { - await firebaseAuthActions.logout() + await authActions.logout() } }, { diff --git a/src/composables/useWaveAudioPlayer.test.ts b/src/composables/useWaveAudioPlayer.test.ts new file mode 100644 index 0000000000..e84b73b774 --- /dev/null +++ b/src/composables/useWaveAudioPlayer.test.ts @@ -0,0 +1,130 @@ +import { ref } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useWaveAudioPlayer } from './useWaveAudioPlayer' + +vi.mock('@vueuse/core', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + useMediaControls: () => ({ + playing: ref(false), + currentTime: ref(0), + duration: ref(0) + }) + } +}) + +const mockFetchApi = vi.fn() +const originalAudioContext = globalThis.AudioContext + +afterEach(() => { + globalThis.AudioContext = originalAudioContext + mockFetchApi.mockReset() +}) + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: (route: string) => '/api' + route, + fetchApi: (...args: unknown[]) => mockFetchApi(...args) + } +})) + +describe('useWaveAudioPlayer', () => { + it('initializes with default bar count', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src }) + expect(bars.value).toHaveLength(40) + }) + + it('initializes with custom bar count', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src, barCount: 20 }) + expect(bars.value).toHaveLength(20) + }) + + it('returns playedBarIndex as -1 when duration is 0', () => { + const src = ref('') + const { playedBarIndex } = useWaveAudioPlayer({ src }) + expect(playedBarIndex.value).toBe(-1) + }) + + it('generates bars with heights between 10 and 70', () => { + const src = ref('') + const { bars } = useWaveAudioPlayer({ src }) + for (const bar of bars.value) { + expect(bar.height).toBeGreaterThanOrEqual(10) + expect(bar.height).toBeLessThanOrEqual(70) + } + }) + + it('starts in paused state', () => { + const src = ref('') + const { isPlaying } = useWaveAudioPlayer({ src }) + expect(isPlaying.value).toBe(false) + }) + + it('shows 0:00 for formatted times initially', () => { + const src = ref('') + const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({ + src + }) + expect(formattedCurrentTime.value).toBe('0:00') + expect(formattedDuration.value).toBe('0:00') + }) + + it('fetches and decodes audio when src changes', async () => { + const mockAudioBuffer = { + getChannelData: vi.fn(() => new Float32Array(80)) + } + + 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 + + mockFetchApi.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + headers: { get: () => 'audio/wav' } + }) + + const src = ref('/api/view?filename=audio.wav&type=output') + const { bars, loading } = useWaveAudioPlayer({ src, barCount: 10 }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(mockFetchApi).toHaveBeenCalledWith( + '/view?filename=audio.wav&type=output' + ) + expect(mockDecodeAudioData).toHaveBeenCalled() + expect(bars.value).toHaveLength(10) + }) + + it('clears blobUrl and shows placeholder bars when fetch fails', async () => { + mockFetchApi.mockRejectedValue(new Error('Network error')) + + const src = ref('/api/view?filename=audio.wav&type=output') + const { bars, loading, audioSrc } = useWaveAudioPlayer({ + src, + barCount: 10 + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(bars.value).toHaveLength(10) + expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output') + }) + + it('does not call decodeAudioSource when src is empty', () => { + const src = ref('') + useWaveAudioPlayer({ src }) + expect(mockFetchApi).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/useWaveAudioPlayer.ts b/src/composables/useWaveAudioPlayer.ts new file mode 100644 index 0000000000..3fd12ca377 --- /dev/null +++ b/src/composables/useWaveAudioPlayer.ts @@ -0,0 +1,205 @@ +import { useMediaControls, whenever } from '@vueuse/core' +import { computed, onUnmounted, ref } from 'vue' +import type { Ref } from 'vue' + +import { api } from '@/scripts/api' +import { formatTime } from '@/utils/formatUtil' + +interface WaveformBar { + height: number +} + +interface UseWaveAudioPlayerOptions { + src: Ref + barCount?: number +} + +export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) { + const { src, barCount = 40 } = options + + const audioRef = ref() + const waveformRef = ref() + const blobUrl = ref() + const loading = ref(false) + let decodeRequestId = 0 + const bars = ref(generatePlaceholderBars()) + + const { playing, currentTime, duration, volume, muted } = + useMediaControls(audioRef) + + const playedBarIndex = computed(() => { + if (duration.value === 0) return -1 + return Math.floor((currentTime.value / duration.value) * barCount) - 1 + }) + + const formattedCurrentTime = computed(() => formatTime(currentTime.value)) + const formattedDuration = computed(() => formatTime(duration.value)) + + const audioSrc = computed(() => + src.value ? (blobUrl.value ?? src.value) : '' + ) + + function generatePlaceholderBars(): WaveformBar[] { + return Array.from({ length: barCount }, () => ({ + height: Math.random() * 60 + 10 + })) + } + + function generateBarsFromBuffer(buffer: AudioBuffer) { + const channelData = buffer.getChannelData(0) + if (channelData.length === 0) { + bars.value = generatePlaceholderBars() + return + } + + const averages: number[] = [] + for (let i = 0; i < barCount; i++) { + const start = Math.floor((i * channelData.length) / barCount) + const end = Math.max( + start + 1, + Math.floor(((i + 1) * channelData.length) / barCount) + ) + let sum = 0 + for (let j = start; j < end && j < channelData.length; j++) { + sum += Math.abs(channelData[j]) + } + averages.push(sum / (end - start)) + } + + const peak = Math.max(...averages) || 1 + bars.value = averages.map((avg) => ({ + height: Math.max(8, (avg / peak) * 100) + })) + } + + async function decodeAudioSource(url: string) { + const requestId = ++decodeRequestId + loading.value = true + let ctx: AudioContext | undefined + try { + const apiBase = api.apiURL('/') + const route = url.includes(apiBase) + ? url.slice(url.indexOf(apiBase) + api.apiURL('').length) + : url + const response = await api.fetchApi(route) + if (requestId !== decodeRequestId) return + if (!response.ok) { + throw new Error(`Failed to fetch audio (${response.status})`) + } + const arrayBuffer = await response.arrayBuffer() + + if (requestId !== decodeRequestId) return + + const blob = new Blob([arrayBuffer.slice(0)], { + type: response.headers.get('content-type') ?? 'audio/wav' + }) + if (blobUrl.value) URL.revokeObjectURL(blobUrl.value) + blobUrl.value = URL.createObjectURL(blob) + + ctx = new AudioContext() + const audioBuffer = await ctx.decodeAudioData(arrayBuffer) + if (requestId !== decodeRequestId) return + generateBarsFromBuffer(audioBuffer) + } catch { + if (requestId === decodeRequestId) { + if (blobUrl.value) { + URL.revokeObjectURL(blobUrl.value) + blobUrl.value = undefined + } + bars.value = generatePlaceholderBars() + } + } finally { + await ctx?.close() + if (requestId === decodeRequestId) { + loading.value = false + } + } + } + + const progressRatio = computed(() => { + if (duration.value === 0) return 0 + return (currentTime.value / duration.value) * 100 + }) + + function togglePlayPause() { + playing.value = !playing.value + } + + function seekToStart() { + currentTime.value = 0 + } + + function seekToEnd() { + currentTime.value = duration.value + playing.value = false + } + + function seekToRatio(ratio: number) { + const clamped = Math.max(0, Math.min(1, ratio)) + currentTime.value = clamped * duration.value + } + + function toggleMute() { + muted.value = !muted.value + } + + const volumeIcon = computed(() => { + if (muted.value || volume.value === 0) return 'icon-[lucide--volume-x]' + if (volume.value < 0.5) return 'icon-[lucide--volume-1]' + return 'icon-[lucide--volume-2]' + }) + + function handleWaveformClick(event: MouseEvent) { + if (!waveformRef.value || duration.value === 0) return + const rect = waveformRef.value.getBoundingClientRect() + const ratio = Math.max( + 0, + Math.min(1, (event.clientX - rect.left) / rect.width) + ) + currentTime.value = ratio * duration.value + + if (!playing.value) { + playing.value = true + } + } + + whenever( + src, + (url) => { + playing.value = false + currentTime.value = 0 + void decodeAudioSource(url) + }, + { immediate: true } + ) + + onUnmounted(() => { + decodeRequestId += 1 + audioRef.value?.pause() + if (blobUrl.value) { + URL.revokeObjectURL(blobUrl.value) + blobUrl.value = undefined + } + }) + + return { + audioRef, + waveformRef, + audioSrc, + bars, + loading, + isPlaying: playing, + playedBarIndex, + progressRatio, + formattedCurrentTime, + formattedDuration, + togglePlayPause, + seekToStart, + seekToEnd, + volume, + volumeIcon, + toggleMute, + seekToRatio, + handleWaveformClick + } +} diff --git a/src/constants/essentialsNodes.ts b/src/constants/essentialsNodes.ts index 07afc8d9c2..d3538a4144 100644 --- a/src/constants/essentialsNodes.ts +++ b/src/constants/essentialsNodes.ts @@ -107,6 +107,27 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap< EssentialsCategory > = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c])) +/** + * Precomputed rank map: category → display order index. + * Used for sorting essentials folders in their canonical order. + */ +export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap = new Map( + ESSENTIALS_CATEGORIES.map((c, i) => [c, i]) +) + +/** + * Precomputed rank maps: category → (node name → display order index). + * Used for sorting nodes within each essentials folder. + */ +export const ESSENTIALS_NODE_RANK: Partial< + Record> +> = Object.fromEntries( + Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [ + category, + new Map(nodes.map((name, i) => [name, i])) + ]) +) + /** * "Novel" toolkit nodes for telemetry — basics excluded. * Derived from ESSENTIALS_NODES minus the 'basics' category. diff --git a/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts b/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts new file mode 100644 index 0000000000..589670fd91 --- /dev/null +++ b/src/core/graph/subgraph/legacyProxyWidgetNormalization.test.ts @@ -0,0 +1,122 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + createTestRootGraph, + createTestSubgraph, + createTestSubgraphNode, + resetSubgraphFixtureState +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' + +function createHostWithInnerWidget(widgetName: string) { + const rootGraph = createTestRootGraph() + const innerSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'value', type: 'number' }] + }) + + const innerNode = new LGraphNode('InnerNode') + const input = innerNode.addInput('value', 'number') + innerNode.addWidget('number', widgetName, 0, () => {}) + input.widget = { name: widgetName } + innerSubgraph.add(innerNode) + innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode) + + const hostNode = createTestSubgraphNode(innerSubgraph, { + parentGraph: rootGraph + }) + + return { rootGraph, innerSubgraph, innerNode, hostNode } +} + +describe('normalizeLegacyProxyWidgetEntry', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + resetSubgraphFixtureState() + }) + + it('returns entry unchanged when it already resolves', () => { + const { hostNode, innerNode } = createHostWithInnerWidget('seed') + + const result = normalizeLegacyProxyWidgetEntry( + hostNode, + String(innerNode.id), + 'seed' + ) + + expect(result).toEqual({ + sourceNodeId: String(innerNode.id), + sourceWidgetName: 'seed' + }) + }) + + it('returns entry unchanged with disambiguator when it already resolves', () => { + const { hostNode, innerNode } = createHostWithInnerWidget('seed') + + const result = normalizeLegacyProxyWidgetEntry( + hostNode, + String(innerNode.id), + 'seed', + String(innerNode.id) + ) + + expect(result).toEqual({ + sourceNodeId: String(innerNode.id), + sourceWidgetName: 'seed', + disambiguatingSourceNodeId: String(innerNode.id) + }) + }) + + it('strips a single legacy prefix from widget name', () => { + const rootGraph = createTestRootGraph() + const innerSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: 'number' }] + }) + + const samplerNode = new LGraphNode('Sampler') + const samplerInput = samplerNode.addInput('seed', 'number') + samplerNode.addWidget('number', 'noise_seed', 42, () => {}) + samplerInput.widget = { name: 'noise_seed' } + innerSubgraph.add(samplerNode) + innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode) + + const outerSubgraph = createTestSubgraph({ rootGraph }) + const nestedNode = createTestSubgraphNode(innerSubgraph, { + parentGraph: outerSubgraph + }) + outerSubgraph.add(nestedNode) + + const hostNode = createTestSubgraphNode(outerSubgraph, { + parentGraph: rootGraph + }) + + const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed` + const result = normalizeLegacyProxyWidgetEntry( + hostNode, + String(nestedNode.id), + prefixedName + ) + + expect(result.sourceWidgetName).toBe('noise_seed') + expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id)) + }) + + it('returns original entry when prefix cannot be resolved', () => { + const { hostNode, innerNode } = createHostWithInnerWidget('seed') + + const result = normalizeLegacyProxyWidgetEntry( + hostNode, + String(innerNode.id), + '999: nonexistent_widget' + ) + + expect(result).toEqual({ + sourceNodeId: String(innerNode.id), + sourceWidgetName: '999: nonexistent_widget' + }) + }) +}) diff --git a/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts b/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts new file mode 100644 index 0000000000..0b56019dd3 --- /dev/null +++ b/src/core/graph/subgraph/legacyProxyWidgetNormalization.ts @@ -0,0 +1,111 @@ +import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes' +import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' + +const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/ + +type PromotedWidgetPatch = Omit + +function canResolve( + hostNode: SubgraphNode, + sourceNodeId: string, + widgetName: string, + disambiguator?: string +): boolean { + return ( + resolveConcretePromotedWidget( + hostNode, + sourceNodeId, + widgetName, + disambiguator + ).status === 'resolved' + ) +} + +function tryResolveCandidate( + hostNode: SubgraphNode, + sourceNodeId: string, + widgetName: string, + disambiguator?: string +): PromotedWidgetPatch | undefined { + if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator)) + return undefined + + return { + sourceWidgetName: widgetName, + ...(disambiguator && { disambiguatingSourceNodeId: disambiguator }) + } +} + +function resolveLegacyPrefixedEntry( + hostNode: SubgraphNode, + sourceNodeId: string, + sourceWidgetName: string, + disambiguatingSourceNodeId?: string +): PromotedWidgetPatch | undefined { + let remaining = sourceWidgetName + + while (true) { + const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining) + if (!match) return undefined + + const [, legacySourceNodeId, unprefixed] = match + remaining = unprefixed + + const disambiguators = [ + legacySourceNodeId, + ...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []), + undefined + ] + + for (const disambiguator of disambiguators) { + const resolved = tryResolveCandidate( + hostNode, + sourceNodeId, + remaining, + disambiguator + ) + if (resolved) return resolved + } + } +} + +export function normalizeLegacyProxyWidgetEntry( + hostNode: SubgraphNode, + sourceNodeId: string, + sourceWidgetName: string, + disambiguatingSourceNodeId?: string +): PromotedWidgetSource { + if ( + canResolve( + hostNode, + sourceNodeId, + sourceWidgetName, + disambiguatingSourceNodeId + ) + ) { + return { + sourceNodeId, + sourceWidgetName, + ...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId }) + } + } + + const patch = resolveLegacyPrefixedEntry( + hostNode, + sourceNodeId, + sourceWidgetName, + disambiguatingSourceNodeId + ) + + const patchDisambiguatingSourceNodeId = + patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId + + return { + sourceNodeId, + sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName, + ...(patchDisambiguatingSourceNodeId && { + disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId + }) + } +} diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 76f2a47240..75d471ab67 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -138,15 +138,18 @@ describe(createPromotedWidgetView, () => { expect(view.name).toBe('myWidget') }) - test('name uses displayName when provided', () => { + test('name uses identityName when provided, label uses displayName', () => { const [subgraphNode] = setupSubgraph() const view = createPromotedWidgetView( subgraphNode, '1', 'myWidget', - 'Custom Label' + 'Custom Label', + undefined, + 'my_slot' ) - expect(view.name).toBe('Custom Label') + expect(view.name).toBe('my_slot') + expect(view.label).toBe('Custom Label') }) test('node getter returns the subgraphNode', () => { @@ -334,11 +337,11 @@ describe(createPromotedWidgetView, () => { innerNode.addWidget('text', 'myWidget', 'val', () => {}) const bareId = String(innerNode.id) - // No displayName → falls back to widgetName + // No displayName → label is undefined (rendering uses widget.label ?? widget.name) const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget') - expect(view1.label).toBe('myWidget') + expect(view1.label).toBeUndefined() - // With displayName → falls back to displayName + // With displayName → label falls back to displayName const view2 = createPromotedWidgetView( subgraphNode, bareId, @@ -748,7 +751,7 @@ describe('SubgraphNode.widgets getter', () => { ]) }) - test('full linked coverage does not prune unresolved independent fallback promotions', () => { + test('full linked coverage prunes promotions referencing non-existent nodes', () => { const subgraph = createTestSubgraph({ inputs: [{ name: 'widgetA', type: '*' }] }) @@ -773,9 +776,9 @@ describe('SubgraphNode.widgets getter', () => { subgraphNode.rootGraph.id, subgraphNode.id ) + // Node 9999 does not exist in the subgraph, so its entry is pruned expect(promotions).toStrictEqual([ - { sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' }, - { sourceNodeId: '9999', sourceWidgetName: 'widgetA' } + { sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' } ]) }) @@ -1012,7 +1015,9 @@ describe('SubgraphNode.widgets getter', () => { const afterRename = promotedWidgets(subgraphNode)[0] if (!afterRename) throw new Error('Expected linked promoted view') - expect(afterRename.name).toBe('seed_renamed') + // .name stays as identity (subgraph input name), .label updates for display + expect(afterRename.name).toBe('seed') + expect(afterRename.label).toBe('seed_renamed') }) test('caches view objects across getter calls (stable references)', () => { diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index af0b4e64e0..4d215b717b 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget export type { PromotedWidgetView } from './promotedWidgetTypes' export { isPromotedWidgetView } from './promotedWidgetTypes' +interface SubgraphSlotRef { + name: string + label?: string + displayName?: string +} + function isWidgetValue(value: unknown): value is IBaseWidget['value'] { if (value === undefined) return true if (typeof value === 'string') return true @@ -50,14 +56,16 @@ export function createPromotedWidgetView( nodeId: string, widgetName: string, displayName?: string, - disambiguatingSourceNodeId?: string + disambiguatingSourceNodeId?: string, + identityName?: string ): IPromotedWidgetView { return new PromotedWidgetView( subgraphNode, nodeId, widgetName, displayName, - disambiguatingSourceNodeId + disambiguatingSourceNodeId, + identityName ) } @@ -83,12 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView { private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget } private cachedDeepestFrame = -1 + /** Cached reference to the bound subgraph slot, set at construction. */ + private _boundSlot?: SubgraphSlotRef + private _boundSlotVersion = -1 + constructor( private readonly subgraphNode: SubgraphNode, nodeId: string, widgetName: string, private readonly displayName?: string, - readonly disambiguatingSourceNodeId?: string + readonly disambiguatingSourceNodeId?: string, + private readonly identityName?: string ) { this.sourceNodeId = nodeId this.sourceWidgetName = widgetName @@ -100,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView { } get name(): string { - return this.displayName ?? this.sourceWidgetName + return this.identityName ?? this.sourceWidgetName } get y(): number { @@ -188,15 +201,58 @@ class PromotedWidgetView implements IPromotedWidgetView { } get label(): string | undefined { + const slot = this.getBoundSubgraphSlot() + if (slot) return slot.label ?? slot.displayName ?? slot.name + // Fall back to persisted widget state (survives save/reload before + // the slot binding is established) then to construction displayName. const state = this.getWidgetState() - return state?.label ?? this.displayName ?? this.sourceWidgetName + return state?.label ?? this.displayName } set label(value: string | undefined) { + const slot = this.getBoundSubgraphSlot() + if (slot) slot.label = value || undefined + // Also persist to widget state store for save/reload resilience const state = this.getWidgetState() if (state) state.label = value } + /** + * Returns the cached bound subgraph slot reference, refreshing only when + * the subgraph node's input list has changed (length mismatch). + * + * Note: Using length as the cache key works because the returned reference + * is the same mutable slot object. When slot properties (label, name) change, + * the caller reads fresh values from that reference. The cache only needs + * to invalidate when slots are added or removed, which changes length. + */ + private getBoundSubgraphSlot(): SubgraphSlotRef | undefined { + const version = this.subgraphNode.inputs?.length ?? 0 + if (this._boundSlotVersion === version) return this._boundSlot + + this._boundSlot = this.findBoundSubgraphSlot() + this._boundSlotVersion = version + return this._boundSlot + } + + private findBoundSubgraphSlot(): SubgraphSlotRef | undefined { + for (const input of this.subgraphNode.inputs ?? []) { + const slot = input._subgraphSlot as SubgraphSlotRef | undefined + if (!slot) continue + + const w = input._widget + if ( + w && + isPromotedWidgetView(w) && + w.sourceNodeId === this.sourceNodeId && + w.sourceWidgetName === this.sourceWidgetName + ) { + return slot + } + } + return undefined + } + get hidden(): boolean { return this.resolveDeepest()?.widget.hidden ?? false } @@ -238,21 +294,27 @@ class PromotedWidgetView implements IPromotedWidgetView { const originalComputedHeight = projected.computedHeight const originalComputedDisabled = projected.computedDisabled + const originalLabel = projected.label + projected.y = this.y projected.computedHeight = this.computedHeight projected.computedDisabled = this.computedDisabled projected.value = this.value + projected.label = this.label - projected.drawWidget(ctx, { - width: widgetWidth, - showText: !lowQuality, - suppressPromotedOutline: true, - previewImages: resolved.node.imgs - }) - - projected.y = originalY - projected.computedHeight = originalComputedHeight - projected.computedDisabled = originalComputedDisabled + try { + projected.drawWidget(ctx, { + width: widgetWidth, + showText: !lowQuality, + suppressPromotedOutline: true, + previewImages: resolved.node.imgs + }) + } finally { + projected.y = originalY + projected.computedHeight = originalComputedHeight + projected.computedDisabled = originalComputedDisabled + projected.label = originalLabel + } } onPointerDown( diff --git a/src/extensions/core/clipspace.ts b/src/extensions/core/clipspace.ts index 255b1f40f0..b697328685 100644 --- a/src/extensions/core/clipspace.ts +++ b/src/extensions/core/clipspace.ts @@ -2,8 +2,7 @@ import { app } from '../../scripts/app' import { ComfyApp } from '../../scripts/app' import { $el, ComfyDialog } from '../../scripts/ui' -/** @knipIgnoreUnusedButUsedByCustomNodes - Used by custom nodes via ComfyApp.clipspace */ -export class ClipspaceDialog extends ComfyDialog { +class ClipspaceDialog extends ComfyDialog { static items: Array< HTMLButtonElement & { contextPredicate?: () => boolean diff --git a/src/extensions/core/customWidgets.test.ts b/src/extensions/core/customWidgets.test.ts index e76ce43373..66d317725e 100644 --- a/src/extensions/core/customWidgets.test.ts +++ b/src/extensions/core/customWidgets.test.ts @@ -30,6 +30,7 @@ describe('PrimitiveFloat widget type bridging', () => { }) Object.defineProperty(widget.options, 'gradient_stops', { + enumerable: true, get: () => properties.gradient_stops, set: (v) => { properties.gradient_stops = v @@ -82,6 +83,20 @@ describe('PrimitiveFloat widget type bridging', () => { expect(widget.options.gradient_stops).toBe(stops) }) + it('gradient_stops survives object spread', () => { + const { properties, widget } = createMockNodeAndWidget() + applyFloatPropertyBridges(properties, widget) + + const stops = [ + { offset: 0, color: [0, 255, 255] }, + { offset: 1, color: [255, 0, 0] } + ] + properties.gradient_stops = stops + + const spread = { ...widget.options } + expect(spread.gradient_stops).toBe(stops) + }) + it('writes gradient_stops back to properties', () => { const { properties, widget } = createMockNodeAndWidget() applyFloatPropertyBridges(properties, widget) diff --git a/src/extensions/core/customWidgets.ts b/src/extensions/core/customWidgets.ts index 3f19322801..d0e08c6743 100644 --- a/src/extensions/core/customWidgets.ts +++ b/src/extensions/core/customWidgets.ts @@ -169,6 +169,7 @@ function onCustomFloatCreated(this: LGraphNode) { }) Object.defineProperty(valueWidget.options, 'gradient_stops', { + enumerable: true, get: () => this.properties.gradient_stops, set: (v) => { this.properties.gradient_stops = v diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 9a759eb2e7..7eeba28a59 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,5 +1,7 @@ import * as THREE from 'three' +import { exceedsClickThreshold } from '@/composables/useClickDragGuard' + import { AnimationManager } from './AnimationManager' import { CameraManager } from './CameraManager' import { ControlsManager } from './ControlsManager' @@ -68,9 +70,7 @@ class Load3d { targetAspectRatio: number = 1 isViewerMode: boolean = false - // Context menu tracking - private rightMouseDownX: number = 0 - private rightMouseDownY: number = 0 + private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 } private rightMouseMoved: boolean = false private readonly dragThreshold: number = 5 private contextMenuAbortController: AbortController | null = null @@ -197,18 +197,20 @@ class Load3d { const mousedownHandler = (e: MouseEvent) => { if (e.button === 2) { - this.rightMouseDownX = e.clientX - this.rightMouseDownY = e.clientY + this.rightMouseStart = { x: e.clientX, y: e.clientY } this.rightMouseMoved = false } } const mousemoveHandler = (e: MouseEvent) => { if (e.buttons === 2) { - const dx = Math.abs(e.clientX - this.rightMouseDownX) - const dy = Math.abs(e.clientY - this.rightMouseDownY) - - if (dx > this.dragThreshold || dy > this.dragThreshold) { + if ( + exceedsClickThreshold( + this.rightMouseStart, + { x: e.clientX, y: e.clientY }, + this.dragThreshold + ) + ) { this.rightMouseMoved = true } } @@ -217,12 +219,13 @@ class Load3d { const contextmenuHandler = (e: MouseEvent) => { if (this.isViewerMode) return - const dx = Math.abs(e.clientX - this.rightMouseDownX) - const dy = Math.abs(e.clientY - this.rightMouseDownY) const wasDragging = this.rightMouseMoved || - dx > this.dragThreshold || - dy > this.dragThreshold + exceedsClickThreshold( + this.rightMouseStart, + { x: e.clientX, y: e.clientY }, + this.dragThreshold + ) this.rightMouseMoved = false diff --git a/src/lib/litegraph/src/LGraph.repointAncestorPromotions.test.ts b/src/lib/litegraph/src/LGraph.repointAncestorPromotions.test.ts new file mode 100644 index 0000000000..edd142aa64 --- /dev/null +++ b/src/lib/litegraph/src/LGraph.repointAncestorPromotions.test.ts @@ -0,0 +1,287 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + LGraphNode, + LiteGraph, + SubgraphNode +} from '@/lib/litegraph/src/litegraph' +import type { + ExportedSubgraphInstance, + Positionable, + Subgraph +} from '@/lib/litegraph/src/litegraph' +import { + createTestSubgraph, + createTestSubgraphNode, + resetSubgraphFixtureState +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import { usePromotionStore } from '@/stores/promotionStore' + +/** + * Registers a minimal SubgraphNode class for a subgraph definition + * so that `LiteGraph.createNode(subgraphId)` works in tests. + */ +function registerSubgraphNodeType(subgraph: Subgraph): void { + const instanceData: ExportedSubgraphInstance = { + id: -1, + type: subgraph.id, + pos: [0, 0], + size: [100, 100], + inputs: [], + outputs: [], + flags: {}, + order: 0, + mode: 0 + } + + const node = class extends SubgraphNode { + constructor() { + super(subgraph.rootGraph, subgraph, instanceData) + } + } + Object.defineProperty(node, 'title', { value: subgraph.name }) + LiteGraph.registerNodeType(subgraph.id, node) +} + +const registeredTypes: string[] = [] + +afterEach(() => { + for (const type of registeredTypes) { + LiteGraph.unregisterNodeType(type) + } + registeredTypes.length = 0 +}) + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + resetSubgraphFixtureState() +}) + +describe('_repointAncestorPromotions', () => { + function setupParentSubgraphWithWidgets() { + const parentSubgraph = createTestSubgraph({ + name: 'Parent Subgraph', + inputs: [{ name: 'input', type: '*' }], + outputs: [{ name: 'output', type: '*' }] + }) + const rootGraph = parentSubgraph.rootGraph + + // We need to listen for new subgraph registrations so + // LiteGraph.createNode works during convertToSubgraph + rootGraph.events.addEventListener('subgraph-created', (e) => { + const { subgraph } = e.detail + registerSubgraphNodeType(subgraph) + registeredTypes.push(subgraph.id) + }) + + const interiorNode = new LGraphNode('Interior Node') + interiorNode.addInput('in', '*') + interiorNode.addOutput('out', '*') + interiorNode.addWidget('text', 'prompt', 'hello world', () => {}) + parentSubgraph.add(interiorNode) + + // Create host SubgraphNode in root graph + registerSubgraphNodeType(parentSubgraph) + registeredTypes.push(parentSubgraph.id) + const hostNode = createTestSubgraphNode(parentSubgraph) + rootGraph.add(hostNode) + + return { rootGraph, parentSubgraph, interiorNode, hostNode } + } + + it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => { + const { rootGraph, parentSubgraph, interiorNode, hostNode } = + setupParentSubgraphWithWidgets() + + // Promote the interior node's widget on the host + const store = usePromotionStore() + store.promote(rootGraph.id, hostNode.id, { + sourceNodeId: String(interiorNode.id), + sourceWidgetName: 'prompt' + }) + + const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id) + expect(beforeEntries).toHaveLength(1) + expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id)) + + // Pack the interior node into a nested subgraph + const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph( + new Set([interiorNode]) + ) + + // After conversion, the host's promotion should be repointed + const afterEntries = store.getPromotions(rootGraph.id, hostNode.id) + expect(afterEntries).toHaveLength(1) + expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id)) + expect(afterEntries[0].sourceWidgetName).toBe('prompt') + expect(afterEntries[0].disambiguatingSourceNodeId).toBe( + String(interiorNode.id) + ) + + // The nested subgraph node should also have the promotion + const nestedEntries = store.getPromotions( + rootGraph.id, + nestedSubgraphNode.id + ) + expect(nestedEntries).toHaveLength(1) + expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id)) + expect(nestedEntries[0].sourceWidgetName).toBe('prompt') + }) + + it('preserves promotions that reference non-moved nodes', () => { + const { rootGraph, parentSubgraph, interiorNode, hostNode } = + setupParentSubgraphWithWidgets() + + const remainingNode = new LGraphNode('Remaining Node') + remainingNode.addWidget('text', 'widget_b', 'b', () => {}) + parentSubgraph.add(remainingNode) + + const store = usePromotionStore() + store.promote(rootGraph.id, hostNode.id, { + sourceNodeId: String(interiorNode.id), + sourceWidgetName: 'prompt' + }) + store.promote(rootGraph.id, hostNode.id, { + sourceNodeId: String(remainingNode.id), + sourceWidgetName: 'widget_b' + }) + + // Pack only the interiorNode + parentSubgraph.convertToSubgraph(new Set([interiorNode])) + + const afterEntries = store.getPromotions(rootGraph.id, hostNode.id) + expect(afterEntries).toHaveLength(2) + + // The remaining node's promotion should be unchanged + const remainingEntry = afterEntries.find( + (e) => e.sourceWidgetName === 'widget_b' + ) + expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id)) + expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined() + + // The moved node's promotion should be repointed + const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt') + expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id)) + expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id)) + }) + + it('does not modify promotions when converting in root graph', () => { + const parentSubgraph = createTestSubgraph({ name: 'Dummy' }) + const rootGraph = parentSubgraph.rootGraph + + rootGraph.events.addEventListener('subgraph-created', (e) => { + const { subgraph } = e.detail + registerSubgraphNodeType(subgraph) + registeredTypes.push(subgraph.id) + }) + + const node = new LGraphNode('Root Node') + node.addInput('in', '*') + node.addOutput('out', '*') + node.addWidget('text', 'value', 'test', () => {}) + rootGraph.add(node) + + // Converting in root graph should not throw + rootGraph.convertToSubgraph(new Set([node])) + }) + + it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => { + const { rootGraph, parentSubgraph, interiorNode, hostNode } = + setupParentSubgraphWithWidgets() + + const store = usePromotionStore() + store.promote(rootGraph.id, hostNode.id, { + sourceNodeId: String(interiorNode.id), + sourceWidgetName: 'prompt' + }) + + // First pack: interior node → nested subgraph + const { node: firstNestedNode } = parentSubgraph.convertToSubgraph( + new Set([interiorNode]) + ) + + const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id) + expect(afterFirstPack).toHaveLength(1) + expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id)) + expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe( + String(interiorNode.id) + ) + + // Second pack: nested subgraph → another level of nesting + const { node: secondNestedNode } = parentSubgraph.convertToSubgraph( + new Set([firstNestedNode]) + ) + + // After second pack, promotion should use the disambiguatingSourceNodeId + // as fallback and point to the new nested node + const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id) + expect(afterSecondPack).toHaveLength(1) + expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id)) + expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe( + String(interiorNode.id) + ) + }) + + it('repoints promotions for multiple host instances of the same subgraph', () => { + const parentSubgraph = createTestSubgraph({ + name: 'Shared Parent Subgraph', + inputs: [{ name: 'input', type: '*' }], + outputs: [{ name: 'output', type: '*' }] + }) + const rootGraph = parentSubgraph.rootGraph + + rootGraph.events.addEventListener('subgraph-created', (e) => { + const { subgraph } = e.detail + registerSubgraphNodeType(subgraph) + registeredTypes.push(subgraph.id) + }) + + const interiorNode = new LGraphNode('Interior Node') + interiorNode.addInput('in', '*') + interiorNode.addOutput('out', '*') + interiorNode.addWidget('text', 'prompt', 'shared', () => {}) + parentSubgraph.add(interiorNode) + + // Create TWO host SubgraphNodes pointing to the same subgraph + registerSubgraphNodeType(parentSubgraph) + registeredTypes.push(parentSubgraph.id) + + const hostNode1 = createTestSubgraphNode(parentSubgraph) + const hostNode2 = createTestSubgraphNode(parentSubgraph) + rootGraph.add(hostNode1) + rootGraph.add(hostNode2) + + // Promote on both hosts + const store = usePromotionStore() + store.promote(rootGraph.id, hostNode1.id, { + sourceNodeId: String(interiorNode.id), + sourceWidgetName: 'prompt' + }) + store.promote(rootGraph.id, hostNode2.id, { + sourceNodeId: String(interiorNode.id), + sourceWidgetName: 'prompt' + }) + + // Pack the interior node + const { node: nestedNode } = parentSubgraph.convertToSubgraph( + new Set([interiorNode]) + ) + + // Both hosts' promotions should be repointed to the nested node + const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id) + expect(host1Promotions).toHaveLength(1) + expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id)) + expect(host1Promotions[0].disambiguatingSourceNodeId).toBe( + String(interiorNode.id) + ) + + const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id) + expect(host2Promotions).toHaveLength(1) + expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id)) + expect(host2Promotions[0].disambiguatingSourceNodeId).toBe( + String(interiorNode.id) + ) + }) +}) diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index c71b52d6f2..3517e035a5 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -7,7 +7,8 @@ import { LGraph, LGraphNode, LiteGraph, - LLink + LLink, + Reroute } from '@/lib/litegraph/src/litegraph' import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' import type { UUID } from '@/lib/litegraph/src/utils/uuid' @@ -17,7 +18,13 @@ import { createTestSubgraphData, createTestSubgraphNode } from './subgraph/__fixtures__/subgraphHelpers' +import { subgraphTest } from './subgraph/__fixtures__/subgraphFixtures' +import { + duplicateLinksRoot, + duplicateLinksSlotShift, + duplicateLinksSubgraph +} from './__fixtures__/duplicateLinks' import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds' import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets' import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted' @@ -93,6 +100,42 @@ describe('LGraph', () => { const fromOldSchema = new LGraph(oldSchemaGraph) expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph') }) + subgraphTest('should snap slots to same y-level', ({ emptySubgraph }) => { + const node = new LGraphNode('testname') + node.addInput('test', 'IMAGE') + emptySubgraph.add(node) + + emptySubgraph.inputNode.pos = [0, 0] + // Reroute needs offset of ~20y to align with first slot + const reroute = new Reroute(1, emptySubgraph, [0, 20]) + + node.snapToGrid(10) + reroute.snapToGrid(10) + emptySubgraph.inputNode.snapToGrid(10) + + node.arrange() + emptySubgraph.inputNode.arrange() + + const yPos = node.getInputPos(0)[1] + expect(reroute.pos[1]).toBe(yPos) + expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos) + + // Assign non-equal positions and repeat + emptySubgraph.inputNode.pos = [0, 43] + node.pos = [0, 50] + reroute.pos = [0, 63] + + node.snapToGrid(10) + reroute.snapToGrid(10) + emptySubgraph.inputNode.snapToGrid(10) + + node.arrange() + emptySubgraph.inputNode.arrange() + + const yPos2 = node.getInputPos(0)[1] + expect(reroute.pos[1]).toBe(yPos2) + expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos2) + }) }) describe('Floating Links / Reroutes', () => { @@ -560,31 +603,39 @@ describe('_removeDuplicateLinks', () => { LiteGraph.registerNodeType('test/DupTestNode', TestNode) } - it('removes orphaned duplicate links from _links and output.links', () => { + function createConnectedGraph() { registerTestNodes() const graph = new LGraph() - const source = LiteGraph.createNode('test/DupTestNode', 'Source')! const target = LiteGraph.createNode('test/DupTestNode', 'Target')! graph.add(source) graph.add(target) - source.connect(0, target, 0) - expect(graph._links.size).toBe(1) + return { graph, source, target } + } - const existingLink = graph._links.values().next().value! - for (let i = 0; i < 3; i++) { - const dupLink = new LLink( - ++graph.state.lastLinkId, - existingLink.type, - existingLink.origin_id, - existingLink.origin_slot, - existingLink.target_id, - existingLink.target_slot - ) - graph._links.set(dupLink.id, dupLink) - source.outputs[0].links!.push(dupLink.id) - } + function injectDuplicateLink( + graph: LGraph, + source: LGraphNode, + target: LGraphNode + ) { + const dup = new LLink( + ++graph.state.lastLinkId, + 'number', + source.id, + 0, + target.id, + 0 + ) + graph._links.set(dup.id, dup) + source.outputs[0].links!.push(dup.id) + return dup + } + + it('removes orphaned duplicate links from _links and output.links', () => { + const { graph, source, target } = createConnectedGraph() + + for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target) expect(graph._links.size).toBe(4) expect(source.outputs[0].links).toHaveLength(4) @@ -597,27 +648,10 @@ describe('_removeDuplicateLinks', () => { }) it('keeps the link referenced by input.link', () => { - registerTestNodes() - const graph = new LGraph() - - const source = LiteGraph.createNode('test/DupTestNode', 'Source')! - const target = LiteGraph.createNode('test/DupTestNode', 'Target')! - graph.add(source) - graph.add(target) - - source.connect(0, target, 0) + const { graph, source, target } = createConnectedGraph() const keptLinkId = target.inputs[0].link! - const dupLink = new LLink( - ++graph.state.lastLinkId, - 'number', - source.id, - 0, - target.id, - 0 - ) - graph._links.set(dupLink.id, dupLink) - source.outputs[0].links!.push(dupLink.id) + const dupLink = injectDuplicateLink(graph, source, target) graph._removeDuplicateLinks() @@ -628,18 +662,8 @@ describe('_removeDuplicateLinks', () => { }) it('keeps the valid link when input.link is at a shifted slot index', () => { - LiteGraph.registerNodeType('test/DupTestNode', TestNode) - const graph = new LGraph() - - const source = LiteGraph.createNode('test/DupTestNode', 'Source')! - const target = LiteGraph.createNode('test/DupTestNode', 'Target')! - graph.add(source) - graph.add(target) - - // Connect source:0 -> target:0, establishing input.link on target - source.connect(0, target, 0) + const { graph, source, target } = createConnectedGraph() const validLinkId = target.inputs[0].link! - expect(graph._links.has(validLinkId)).toBe(true) // Simulate widget-to-input conversion shifting the slot: insert a new // input BEFORE the connected one, moving it from index 0 to index 1. @@ -647,26 +671,13 @@ describe('_removeDuplicateLinks', () => { const connectedInput = target.inputs[0] target.inputs[0] = target.inputs[1] target.inputs[1] = connectedInput - // Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null - // Add a duplicate link with the same connection tuple (target_slot=0 - // in the LLink, matching the original slot before the shift). - const dupLink = new LLink( - ++graph.state.lastLinkId, - 'number', - source.id, - 0, - target.id, - 0 - ) - graph._links.set(dupLink.id, dupLink) - source.outputs[0].links!.push(dupLink.id) + const dupLink = injectDuplicateLink(graph, source, target) expect(graph._links.size).toBe(2) graph._removeDuplicateLinks() - // The valid link (referenced by an actual input) must survive expect(graph._links.size).toBe(1) expect(graph._links.has(validLinkId)).toBe(true) expect(graph._links.has(dupLink.id)).toBe(false) @@ -674,50 +685,22 @@ describe('_removeDuplicateLinks', () => { }) it('repairs input.link when it points to a removed duplicate', () => { - LiteGraph.registerNodeType('test/DupTestNode', TestNode) - const graph = new LGraph() - - const source = LiteGraph.createNode('test/DupTestNode', 'Source')! - const target = LiteGraph.createNode('test/DupTestNode', 'Target')! - graph.add(source) - graph.add(target) - - source.connect(0, target, 0) - - // Create a duplicate link - const dupLink = new LLink( - ++graph.state.lastLinkId, - 'number', - source.id, - 0, - target.id, - 0 - ) - graph._links.set(dupLink.id, dupLink) - source.outputs[0].links!.push(dupLink.id) + const { graph, source, target } = createConnectedGraph() + const dupLink = injectDuplicateLink(graph, source, target) // Point input.link to the duplicate (simulating corrupted state) target.inputs[0].link = dupLink.id graph._removeDuplicateLinks() expect(graph._links.size).toBe(1) - // input.link must point to whichever link survived const survivingId = graph._links.keys().next().value! expect(target.inputs[0].link).toBe(survivingId) expect(graph._links.has(target.inputs[0].link!)).toBe(true) }) it('is a no-op when no duplicates exist', () => { - registerTestNodes() - const graph = new LGraph() - - const source = LiteGraph.createNode('test/DupTestNode', 'Source')! - const target = LiteGraph.createNode('test/DupTestNode', 'Target')! - graph.add(source) - graph.add(target) - - source.connect(0, target, 0) + const { graph } = createConnectedGraph() const linksBefore = graph._links.size graph._removeDuplicateLinks() @@ -738,29 +721,56 @@ describe('_removeDuplicateLinks', () => { subgraph.add(target) source.connect(0, target, 0) - expect(subgraph._links.size).toBe(1) - const existingLink = subgraph._links.values().next().value! - for (let i = 0; i < 3; i++) { - const dup = new LLink( - ++subgraph.state.lastLinkId, - existingLink.type, - existingLink.origin_id, - existingLink.origin_slot, - existingLink.target_id, - existingLink.target_slot - ) - subgraph._links.set(dup.id, dup) - source.outputs[0].links!.push(dup.id) - } + for (let i = 0; i < 3; i++) injectDuplicateLink(subgraph, source, target) expect(subgraph._links.size).toBe(4) - // Serialize and reconfigure - should clean up during configure const serialized = subgraph.asSerialisable() subgraph.configure(serialized as never) expect(subgraph._links.size).toBe(1) }) + + it('removes duplicate links via root graph configure()', () => { + registerTestNodes() + const graph = new LGraph() + graph.configure(duplicateLinksRoot) + + expect(graph._links.size).toBe(1) + const survivingLink = graph._links.values().next().value! + const targetNode = graph.getNodeById(survivingLink.target_id)! + expect(targetNode.inputs[0].link).toBe(survivingLink.id) + const sourceNode = graph.getNodeById(survivingLink.origin_id)! + expect(sourceNode.outputs[0].links).toEqual([survivingLink.id]) + }) + + it('preserves link integrity after configure() with slot-shifted duplicates', () => { + registerTestNodes() + const graph = new LGraph() + graph.configure(duplicateLinksSlotShift) + + expect(graph._links.size).toBe(1) + + const link = graph._links.values().next().value! + const target = graph.getNodeById(link.target_id)! + const linkedInput = target.inputs.find((inp) => inp.link === link.id) + expect(linkedInput).toBeDefined() + + const source = graph.getNodeById(link.origin_id)! + expect(source.outputs[link.origin_slot].links).toContain(link.id) + }) + + it('deduplicates links inside subgraph definitions during root configure()', () => { + const graph = new LGraph() + graph.configure(duplicateLinksSubgraph) + + const subgraph = graph.subgraphs.values().next().value! + expect(subgraph._links.size).toBe(1) + + const link = subgraph._links.values().next().value! + const target = subgraph.getNodeById(link.target_id)! + expect(target.inputs[0].link).toBe(link.id) + }) }) describe('Subgraph Unpacking', () => { @@ -790,6 +800,21 @@ describe('Subgraph Unpacking', () => { return rootGraph.createSubgraph(createTestSubgraphData()) } + function duplicateExistingLink(graph: LGraph, source: LGraphNode) { + const existingLink = graph._links.values().next().value! + const dup = new LLink( + ++graph.state.lastLinkId, + existingLink.type, + existingLink.origin_id, + existingLink.origin_slot, + existingLink.target_id, + existingLink.target_slot + ) + graph._links.set(dup.id, dup) + source.outputs[0].links!.push(dup.id) + return dup + } + it('deduplicates links when unpacking subgraph with duplicate links', () => { registerTestNodes() const rootGraph = new LGraph() @@ -800,24 +825,9 @@ describe('Subgraph Unpacking', () => { subgraph.add(sourceNode) subgraph.add(targetNode) - // Create a legitimate link sourceNode.connect(0, targetNode, 0) - expect(subgraph._links.size).toBe(1) - // Manually add duplicate links (simulating the bug) - const existingLink = subgraph._links.values().next().value! - for (let i = 0; i < 3; i++) { - const dupLink = new LLink( - ++subgraph.state.lastLinkId, - existingLink.type, - existingLink.origin_id, - existingLink.origin_slot, - existingLink.target_id, - existingLink.target_slot - ) - subgraph._links.set(dupLink.id, dupLink) - sourceNode.outputs[0].links!.push(dupLink.id) - } + for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode) expect(subgraph._links.size).toBe(4) const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] }) @@ -839,21 +849,8 @@ describe('Subgraph Unpacking', () => { subgraph.add(sourceNode) subgraph.add(targetNode) - // Connect source output 0 → target input 0 sourceNode.connect(0, targetNode, 0) - - // Add duplicate links to the same connection - const existingLink = subgraph._links.values().next().value! - const dupLink = new LLink( - ++subgraph.state.lastLinkId, - existingLink.type, - existingLink.origin_id, - existingLink.origin_slot, - existingLink.target_id, - existingLink.target_slot - ) - subgraph._links.set(dupLink.id, dupLink) - sourceNode.outputs[0].links!.push(dupLink.id) + duplicateExistingLink(subgraph, sourceNode) const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] }) rootGraph.add(subgraphNode) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 68b80d9dc8..ea16441665 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1,5 +1,6 @@ import { toString } from 'es-toolkit/compat' +import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes' import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID @@ -9,10 +10,20 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { LayoutSource } from '@/renderer/core/layout/types' -import { usePromotionStore } from '@/stores/promotionStore' +import { + makePromotionEntryKey, + usePromotionStore +} from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' import { forEachNode } from '@/utils/graphTraversalUtil' +import { + groupLinksByTuple, + purgeOrphanedLinks, + repairInputLinks, + selectSurvivorLink +} from './linkDeduplication' + import type { DragAndScaleState } from './DragAndScale' import { LGraphCanvas } from './LGraphCanvas' import { LGraphGroup } from './LGraphGroup' @@ -168,11 +179,6 @@ export class LGraph static STATUS_STOPPED = 1 static STATUS_RUNNING = 2 - /** Generates a unique string key for a link's connection tuple. */ - static _linkTupleKey(link: LLink): string { - return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}` - } - /** List of LGraph properties that are manually handled by {@link LGraph.configure}. */ static readonly ConfigureProperties = new Set([ 'nodes', @@ -1626,68 +1632,21 @@ export class LGraph * (origin_id, origin_slot, target_id, target_slot). Keeps the link * referenced by input.link and removes orphaned duplicates from * output.links and the graph's _links map. + * + * Three phases: group links by tuple, select the survivor, purge duplicates. */ _removeDuplicateLinks(): void { - // Group all link IDs by their connection tuple. - const groups = new Map() - for (const [id, link] of this._links) { - const key = LGraph._linkTupleKey(link) - let group = groups.get(key) - if (!group) { - group = [] - groups.set(key, group) - } - group.push(id) - } + const groups = groupLinksByTuple(this._links) - for (const [, ids] of groups) { + for (const ids of groups.values()) { if (ids.length <= 1) continue const sampleLink = this._links.get(ids[0])! const node = this.getNodeById(sampleLink.target_id) + const keepId = selectSurvivorLink(ids, node) - // Find which link ID is actually referenced by any input on the target - // node. Cannot rely on target_slot index because widget-to-input - // conversions during configure() can shift slot indices. - let keepId: LinkId | undefined - if (node) { - for (const input of node.inputs ?? []) { - const match = ids.find((id) => input.link === id) - if (match != null) { - keepId = match - break - } - } - } - keepId ??= ids[0] - - for (const id of ids) { - if (id === keepId) continue - - const link = this._links.get(id) - if (!link) continue - - // Remove from origin node's output.links array - const originNode = this.getNodeById(link.origin_id) - if (originNode) { - const output = originNode.outputs?.[link.origin_slot] - if (output?.links) { - const idx = output.links.indexOf(id) - if (idx !== -1) output.links.splice(idx, 1) - } - } - - this._links.delete(id) - } - - // Ensure input.link points to the surviving link - if (node) { - for (const input of node.inputs ?? []) { - if (ids.includes(input.link as LinkId) && input.link !== keepId) { - input.link = keepId - } - } - } + purgeOrphanedLinks(ids, keepId, this._links, (id) => this.getNodeById(id)) + repairInputLinks(ids, keepId, node) } } @@ -1952,6 +1911,13 @@ export class LGraph subgraphNode._setConcreteSlots() subgraphNode.arrange() + // Repair ancestor promotions: when nodes are packed into a nested + // subgraph, any host SubgraphNode whose proxyWidgets referenced the + // moved nodes must be repointed to chain through the new nested node. + if (!this.isRootGraph) { + this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode) + } + this.canvasAction((c) => c.canvas.dispatchEvent( new CustomEvent('subgraph-converted', { @@ -1964,6 +1930,75 @@ export class LGraph return { subgraph, node: subgraphNode as SubgraphNode } } + /** + * After packing nodes into a nested subgraph, repoint any ancestor + * SubgraphNode promotions that referenced the moved nodes so they + * chain through the newly created nested SubgraphNode. + */ + private _repointAncestorPromotions( + movedNodes: Set, + nestedSubgraphNode: SubgraphNode + ): void { + const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id))) + const store = usePromotionStore() + const nestedNodeId = String(nestedSubgraphNode.id) + const graphId = this.rootGraph.id + const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id) + const nextNestedEntries = [...nestedEntries] + const nestedEntryKeys = new Set( + nestedEntries.map((entry) => makePromotionEntryKey(entry)) + ) + const hostUpdates: Array<{ + node: SubgraphNode + entries: PromotedWidgetSource[] + }> = [] + + // Find all SubgraphNode instances that host `this` subgraph. + // They live in any graph and have `type === this.id`. + const allGraphs: LGraph[] = [ + this.rootGraph, + ...this.rootGraph._subgraphs.values() + ] + for (const graph of allGraphs) { + for (const node of graph._nodes) { + if (!node.isSubgraphNode() || node.type !== this.id) continue + + const entries = store.getPromotions(graphId, node.id) + const movedEntries = entries.filter((entry) => + movedNodeIds.has(entry.sourceNodeId) + ) + if (movedEntries.length === 0) continue + + for (const entry of movedEntries) { + const key = makePromotionEntryKey(entry) + if (nestedEntryKeys.has(key)) continue + nestedEntryKeys.add(key) + nextNestedEntries.push(entry) + } + + const nextEntries = entries.map((entry) => { + if (!movedNodeIds.has(entry.sourceNodeId)) return entry + return { + sourceNodeId: nestedNodeId, + sourceWidgetName: entry.sourceWidgetName, + disambiguatingSourceNodeId: + entry.disambiguatingSourceNodeId ?? entry.sourceNodeId + } + }) + + hostUpdates.push({ node, entries: nextEntries }) + } + } + + if (nextNestedEntries.length !== nestedEntries.length) + store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries) + + for (const { node, entries } of hostUpdates) { + store.setPromotions(graphId, node.id, entries) + node.rebuildInputWidgetBindings() + } + } + unpackSubgraph( subgraphNode: SubgraphNode, options?: { skipMissingNodes?: boolean } diff --git a/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts b/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts new file mode 100644 index 0000000000..dab4d121d3 --- /dev/null +++ b/src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts @@ -0,0 +1,207 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + LGraph, + LGraphCanvas, + LGraphNode, + LiteGraph +} from '@/lib/litegraph/src/litegraph' +import { LLink } from '@/lib/litegraph/src/LLink' +import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils' + +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + querySlotAtPoint: vi.fn(), + queryRerouteAtPoint: vi.fn(), + getNodeLayoutRef: vi.fn(() => ({ value: null })), + getSlotLayout: vi.fn(), + setSource: vi.fn(), + batchUpdateNodeBounds: vi.fn(), + getCurrentSource: vi.fn(() => 'test'), + getCurrentActor: vi.fn(() => 'test'), + applyOperation: vi.fn(), + pendingSlotSync: false + } +})) + +function createMockCtx(): CanvasRenderingContext2D { + return createMockCanvas2DContext({ + translate: vi.fn(), + scale: vi.fn(), + fillText: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 50 }), + closePath: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + setTransform: vi.fn(), + roundRect: vi.fn(), + getTransform: vi + .fn() + .mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + createLinearGradient: vi.fn().mockReturnValue({ + addColorStop: vi.fn() + }), + bezierCurveTo: vi.fn(), + quadraticCurveTo: vi.fn(), + isPointInStroke: vi.fn().mockReturnValue(false), + globalAlpha: 1, + textAlign: 'left' as CanvasTextAlign, + textBaseline: 'alphabetic' as CanvasTextBaseline, + shadowColor: '', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + imageSmoothingEnabled: true + }) +} + +/** + * Creates a link between two nodes by directly mutating graph state, + * bypassing the layout store integration in connect(). + */ +function createTestLink( + graph: LGraph, + sourceNode: LGraphNode, + outputSlot: number, + targetNode: LGraphNode, + inputSlot: number +): LLink { + const linkId = ++graph.state.lastLinkId + const link = new LLink( + linkId, + sourceNode.outputs[outputSlot].type, + sourceNode.id, + outputSlot, + targetNode.id, + inputSlot + ) + graph._links.set(linkId, link) + sourceNode.outputs[outputSlot].links ??= [] + sourceNode.outputs[outputSlot].links!.push(linkId) + targetNode.inputs[inputSlot].link = linkId + return link +} + +describe('drawConnections widget-input slot positioning', () => { + let graph: LGraph + let canvas: LGraphCanvas + let canvasElement: HTMLCanvasElement + + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createTestingPinia()) + + canvasElement = document.createElement('canvas') + canvasElement.width = 800 + canvasElement.height = 600 + canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx()) + canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 0, + top: 0, + width: 800, + height: 600 + }) + + graph = new LGraph() + canvas = new LGraphCanvas(canvasElement, graph, { + skip_render: true + }) + + LiteGraph.vueNodesMode = false + }) + + afterEach(() => { + LiteGraph.vueNodesMode = false + }) + + it('arranges widget-input slots before rendering links', () => { + const sourceNode = new LGraphNode('Source') + sourceNode.pos = [0, 100] + sourceNode.size = [150, 60] + sourceNode.addOutput('out', 'STRING') + graph.add(sourceNode) + + const targetNode = new LGraphNode('Target') + targetNode.pos = [300, 100] + targetNode.size = [200, 120] + const widget = targetNode.addWidget('text', 'value', '', null) + const input = targetNode.addInput('value', 'STRING') + input.widget = { name: 'value' } + graph.add(targetNode) + + createTestLink(graph, sourceNode, 0, targetNode, 0) + + // Before drawConnections, input.pos should not be set + expect(input.pos).toBeUndefined() + + canvas.drawConnections(createMockCtx()) + + // After drawConnections, input.pos should be set to the widget row + expect(input.pos).toBeDefined() + expect(input.pos![1]).toBeGreaterThan(0) + + const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + expect(input.pos![1]).toBe(widget.y + offset) + }) + + it('does not re-arrange nodes whose widget-input slots already have positions', () => { + const sourceNode = new LGraphNode('Source') + sourceNode.pos = [0, 100] + sourceNode.size = [150, 60] + sourceNode.addOutput('out', 'STRING') + graph.add(sourceNode) + + const targetNode = new LGraphNode('Target') + targetNode.pos = [300, 100] + targetNode.size = [200, 120] + targetNode.addWidget('text', 'value', '', null) + const input = targetNode.addInput('value', 'STRING') + input.widget = { name: 'value' } + graph.add(targetNode) + + createTestLink(graph, sourceNode, 0, targetNode, 0) + + // Pre-arrange so input.pos is already set + targetNode._setConcreteSlots() + targetNode.arrange() + expect(input.pos).toBeDefined() + + const arrangeSpy = vi.spyOn(targetNode, 'arrange') + + canvas.drawConnections(createMockCtx()) + + expect(arrangeSpy).not.toHaveBeenCalled() + }) + + it('positions widget-input slots when display name differs from slot.widget.name', () => { + const sourceNode = new LGraphNode('Source') + sourceNode.pos = [0, 100] + sourceNode.size = [150, 60] + sourceNode.addOutput('out', 'STRING') + graph.add(sourceNode) + + const targetNode = new LGraphNode('Target') + targetNode.pos = [300, 100] + targetNode.size = [200, 120] + + // Widget has a display name that differs from the slot's widget.name + // (simulates a renamed subgraph label) + const widget = targetNode.addWidget('text', 'renamed_label', '', null) + const input = targetNode.addInput('renamed_label', 'STRING') + input.widget = { name: 'original_name' } + + // Bind the widget as the slot's _widget (preferred over name-map lookup) + input._widget = widget + + graph.add(targetNode) + createTestLink(graph, sourceNode, 0, targetNode, 0) + + canvas.drawConnections(createMockCtx()) + + expect(input.pos).toBeDefined() + const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + expect(input.pos![1]).toBe(widget.y + offset) + }) +}) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 6731781852..d13c646d81 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -2616,8 +2616,7 @@ export class LGraphCanvas implements CustomEventDispatcher } pointer.finally = () => (this.resizingGroup = null) } else { - const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE - const headerHeight = f * 1.4 + const headerHeight = LiteGraph.NODE_TITLE_HEIGHT if ( isInRectangle( x, @@ -5882,7 +5881,8 @@ export class LGraphCanvas implements CustomEventDispatcher drawSnapGuide( ctx: CanvasRenderingContext2D, item: Positionable, - shape = RenderShape.ROUND + shape = RenderShape.ROUND, + { offsetToSlot }: { offsetToSlot?: boolean } = {} ) { const snapGuide = temp snapGuide.set(item.boundingRect) @@ -5890,7 +5890,10 @@ export class LGraphCanvas implements CustomEventDispatcher // Not all items have pos equal to top-left of bounds const { pos } = item const offsetX = pos[0] - snapGuide[0] - const offsetY = pos[1] - snapGuide[1] + const offsetY = + pos[1] - + snapGuide[1] - + (offsetToSlot ? LiteGraph.NODE_SLOT_HEIGHT * 0.7 : 0) // Normalise boundingRect to pos to snap snapGuide[0] += offsetX @@ -5950,6 +5953,19 @@ export class LGraphCanvas implements CustomEventDispatcher ctx.globalAlpha = this.editor_alpha // for every node const nodes = graph._nodes + + // Ensure widget-input slot positions are computed before rendering links. + // arrange() sets input.pos for widget-backed slots, but is normally called + // in drawNode (foreground canvas). drawConnections runs on the background + // canvas, which may render before drawNode has executed for this frame. + // The dirty flag avoids a per-frame O(N) scan of all inputs. + for (const node of nodes) { + if (node.flags.collapsed || !node._widgetSlotsDirty) continue + + node._setConcreteSlots() + node.arrange() + } + for (const node of nodes) { // for every input (we render just inputs because it is easier as every slot can only have one input) const { inputs } = node @@ -6067,7 +6083,9 @@ export class LGraphCanvas implements CustomEventDispatcher this.isDragging && this.selectedItems.has(reroute) ) { - this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE) + this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE, { + offsetToSlot: true + }) } reroute.draw(ctx, this._pattern) diff --git a/src/lib/litegraph/src/LGraphGroup.ts b/src/lib/litegraph/src/LGraphGroup.ts index 9fcd4985f9..52b61a93c8 100644 --- a/src/lib/litegraph/src/LGraphGroup.ts +++ b/src/lib/litegraph/src/LGraphGroup.ts @@ -40,7 +40,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { color?: string title: string font?: string - font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24 + font_size: number = LiteGraph.GROUP_TEXT_SIZE _bounding = new Rectangle(10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight) _pos: Point = this._bounding.pos @@ -116,7 +116,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { } get titleHeight() { - return this.font_size * 1.4 + return LiteGraph.NODE_TITLE_HEIGHT } get children(): ReadonlySet { @@ -148,7 +148,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { this._bounding.set(o.bounding) this.color = o.color this.flags = o.flags || this.flags - if (o.font_size) this.font_size = o.font_size } serialize(): ISerialisedGroup { @@ -158,7 +157,6 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { title: this.title, bounding: [...b], color: this.color, - font_size: this.font_size, flags: this.flags } } @@ -170,7 +168,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { */ draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void { const { padding, resizeLength, defaultColour } = LGraphGroup - const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE + const font_size = LiteGraph.GROUP_TEXT_SIZE const [x, y] = this._pos const [width, height] = this._size @@ -181,7 +179,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { ctx.fillStyle = color ctx.strokeStyle = color ctx.beginPath() - ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4) + ctx.rect(x + 0.5, y + 0.5, width, LiteGraph.NODE_TITLE_HEIGHT) ctx.fill() // Group background, border @@ -203,11 +201,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { // Title ctx.font = `${font_size}px ${LiteGraph.GROUP_FONT}` ctx.textAlign = 'left' + ctx.textBaseline = 'middle' ctx.fillText( this.title + (this.pinned ? '📌' : ''), - x + padding, - y + font_size + x + font_size / 2, + y + LiteGraph.NODE_TITLE_HEIGHT / 2 + 1 ) + ctx.textBaseline = 'alphabetic' if (LiteGraph.highlight_selected_group && this.selected) { strokeShape(ctx, this._bounding, { diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 61006636ea..b62ecc7d1a 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -295,6 +295,12 @@ export class LGraphNode */ freeWidgetSpace?: number + /** + * Set to true when widget-backed input slot positions need recalculation. + * Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections. + */ + _widgetSlotsDirty = false + locked?: boolean /** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */ @@ -1992,6 +1998,7 @@ export class LGraphNode this.widgets ||= [] const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget this.widgets.push(widget) + this._widgetSlotsDirty = true // Only register with store if node has a valid ID (is already in a graph). // If the node isn't in a graph yet (id === -1), registration happens @@ -2031,9 +2038,11 @@ export class LGraphNode if (input._widget === widget) { input._widget = undefined input.widget = undefined + input.pos = undefined } } } + this._widgetSlotsDirty = true widget.onRemove?.() this.widgets.splice(widgetIndex, 1) @@ -4206,40 +4215,29 @@ export class LGraphNode * Arranges the layout of the node's widget input slots. */ private _arrangeWidgetInputSlots(): void { - if (!this.widgets) return + if (!this.widgets?.length) return - const slotByWidgetName = new Map< - string, - INodeInputSlot & { index: number } - >() + // Build a name→widget map for fast lookup. + const widgetByName = new Map() + for (const w of this.widgets) widgetByName.set(w.name, w) - for (const [i, slot] of this.inputs.entries()) { + // Set widget-backed slot positions from widget Y coordinates. + // In Vue mode, promoted widget inputs are not rendered as + // components (NodeSlots filters them out), so they have no DOM-registered + // position. input.pos serves as the fallback for getSlotPosition(). + for (const [i, slot] of this._concreteInputs.entries()) { if (!isWidgetInputSlot(slot)) continue - slotByWidgetName.set(slot.widget.name, { ...slot, index: i }) - } - if (!slotByWidgetName.size) return + // Prefer the slot's direct _widget binding (1:1 for promoted inputs). + // Fall back to name-map lookup for regular nodes without _widget set. + // Note: the name-map is ambiguous if two promoted inputs share a label; + // _widget avoids this since it is a direct reference. + const widget = slot._widget ?? widgetByName.get(slot.widget.name) + if (!widget) continue - // Only set custom pos if not using Vue positioning - // Vue positioning calculates widget slot positions dynamically - if (!LiteGraph.vueNodesMode) { - for (const widget of this.widgets) { - const slot = slotByWidgetName.get(widget.name) - if (!slot) continue - - const actualSlot = this._concreteInputs[slot.index] - const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 - actualSlot.pos = [offset, widget.y + offset] - this._measureSlot(actualSlot, slot.index, true) - } - } else { - // For Vue positioning, just measure the slots without setting pos - for (const widget of this.widgets) { - const slot = slotByWidgetName.get(widget.name) - if (!slot) continue - - this._measureSlot(this._concreteInputs[slot.index], slot.index, true) - } + const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + slot.pos = [offset, widget.y + offset] + this._measureSlot(slot, i, true) } } @@ -4269,6 +4267,7 @@ export class LGraphNode : 0 this._arrangeWidgets(widgetStartY) this._arrangeWidgetInputSlots() + this._widgetSlotsDirty = false } /** diff --git a/src/lib/litegraph/src/LiteGraphGlobal.ts b/src/lib/litegraph/src/LiteGraphGlobal.ts index 476f1b287c..f2323e78ac 100644 --- a/src/lib/litegraph/src/LiteGraphGlobal.ts +++ b/src/lib/litegraph/src/LiteGraphGlobal.ts @@ -72,8 +72,7 @@ export class LiteGraphGlobal { DEFAULT_FONT = 'Inter' DEFAULT_SHADOW_COLOR = 'rgba(0,0,0,0.5)' - DEFAULT_GROUP_FONT = 24 - DEFAULT_GROUP_FONT_SIZE = 24 + GROUP_TEXT_SIZE = 20 GROUP_FONT = 'Inter' WIDGET_BGCOLOR = '#222' diff --git a/src/lib/litegraph/src/Reroute.ts b/src/lib/litegraph/src/Reroute.ts index 6436df5322..74cdbd111d 100644 --- a/src/lib/litegraph/src/Reroute.ts +++ b/src/lib/litegraph/src/Reroute.ts @@ -16,6 +16,7 @@ import type { ReadOnlyRect, ReadonlyLinkNetwork } from './interfaces' +import { LiteGraph } from './litegraph' import { distance, isPointInRect } from './measure' import type { Serialisable, SerialisableReroute } from './types/serialisation' @@ -428,9 +429,10 @@ export class Reroute snapToGrid(snapTo: number): boolean { if (!snapTo) return false + const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7 const { pos } = this pos[0] = snapTo * Math.round(pos[0] / snapTo) - pos[1] = snapTo * Math.round(pos[1] / snapTo) + pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY return true } diff --git a/src/lib/litegraph/src/__fixtures__/duplicateLinks.ts b/src/lib/litegraph/src/__fixtures__/duplicateLinks.ts new file mode 100644 index 0000000000..2fa213dcd1 --- /dev/null +++ b/src/lib/litegraph/src/__fixtures__/duplicateLinks.ts @@ -0,0 +1,240 @@ +import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation' + +/** + * Root graph with two nodes (Source, Target) connected by one valid link + * plus two duplicate links sharing the same connection tuple. + * Tests that configure() deduplicates to a single link. + */ +export const duplicateLinksRoot: SerialisableGraph = { + id: 'dd000000-0000-4000-8000-000000000001', + version: 1, + revision: 0, + state: { + lastNodeId: 2, + lastLinkId: 3, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 1, + type: 'test/DupTestNode', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + inputs: [{ name: 'input_0', type: 'number', link: null }], + outputs: [{ name: 'output_0', type: 'number', links: [1, 2, 3] }], + properties: {} + }, + { + id: 2, + type: 'test/DupTestNode', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + inputs: [{ name: 'input_0', type: 'number', link: 1 }], + outputs: [{ name: 'output_0', type: 'number', links: [] }], + properties: {} + } + ], + links: [ + { + id: 1, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + }, + { + id: 2, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + }, + { + id: 3, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + } + ] +} + +/** + * Root graph with slot-shifted duplicates. Target node has an extra input + * (simulating widget-to-input conversion) that shifts the connected input + * from slot 0 to slot 1. Link 1 is valid (referenced by input.link), + * link 2 is a duplicate with the original (pre-shift) target_slot. + */ +export const duplicateLinksSlotShift: SerialisableGraph = { + id: 'dd000000-0000-4000-8000-000000000002', + version: 1, + revision: 0, + state: { + lastNodeId: 2, + lastLinkId: 2, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 1, + type: 'test/DupTestNode', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + inputs: [{ name: 'input_0', type: 'number', link: null }], + outputs: [{ name: 'output_0', type: 'number', links: [1, 2] }], + properties: {} + }, + { + id: 2, + type: 'test/DupTestNode', + pos: [300, 0], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + inputs: [ + { name: 'extra_widget', type: 'number', link: null }, + { name: 'input_0', type: 'number', link: 1 } + ], + outputs: [{ name: 'output_0', type: 'number', links: [] }], + properties: {} + } + ], + links: [ + { + id: 1, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + }, + { + id: 2, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + } + ] +} + +/** + * Root graph containing a SubgraphNode whose subgraph definition has + * duplicate links. Tests that configure() deduplicates links inside + * subgraph definitions during root-level configure. + */ +export const duplicateLinksSubgraph: SerialisableGraph = { + id: 'dd000000-0000-4000-8000-000000000003', + version: 1, + revision: 0, + state: { + lastNodeId: 1, + lastLinkId: 0, + lastGroupId: 0, + lastRerouteId: 0 + }, + nodes: [ + { + id: 1, + type: 'dd111111-1111-4111-8111-111111111111', + pos: [0, 0], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + properties: {} + } + ], + definitions: { + subgraphs: [ + { + id: 'dd111111-1111-4111-8111-111111111111', + version: 1, + revision: 0, + state: { + lastNodeId: 2, + lastLinkId: 3, + lastGroupId: 0, + lastRerouteId: 0 + }, + name: 'Subgraph With Duplicates', + config: {}, + inputNode: { id: -10, bounding: [0, 100, 120, 60] }, + outputNode: { id: -20, bounding: [500, 100, 120, 60] }, + inputs: [], + outputs: [], + widgets: [], + nodes: [ + { + id: 1, + type: 'test/Source', + pos: [100, 100], + size: [200, 100], + flags: {}, + order: 0, + mode: 0, + inputs: [], + outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }], + properties: {} + }, + { + id: 2, + type: 'test/Target', + pos: [400, 100], + size: [200, 100], + flags: {}, + order: 1, + mode: 0, + inputs: [{ name: 'in', type: 'number', link: 1 }], + outputs: [], + properties: {} + } + ], + groups: [], + links: [ + { + id: 1, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + }, + { + id: 2, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + }, + { + id: 3, + origin_id: 1, + origin_slot: 0, + target_id: 2, + target_slot: 0, + type: 'number' + } + ], + extra: {} + } + ] + } +} diff --git a/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap b/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap index a159fd1b9e..836ad17563 100644 --- a/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap +++ b/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap @@ -18,7 +18,6 @@ exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = ` ], "color": "#6029aa", "flags": {}, - "font_size": 14, "id": 123, "title": "A group to test with", }, diff --git a/src/lib/litegraph/src/__snapshots__/LGraphGroup.test.ts.snap b/src/lib/litegraph/src/__snapshots__/LGraphGroup.test.ts.snap index fc086fe26f..e2e5d32158 100644 --- a/src/lib/litegraph/src/__snapshots__/LGraphGroup.test.ts.snap +++ b/src/lib/litegraph/src/__snapshots__/LGraphGroup.test.ts.snap @@ -10,7 +10,6 @@ exports[`LGraphGroup > serializes to the existing format > Basic 1`] = ` ], "color": "#3f789e", "flags": {}, - "font_size": 24, "id": 929, "title": "title", } diff --git a/src/lib/litegraph/src/__snapshots__/litegraph.test.ts.snap b/src/lib/litegraph/src/__snapshots__/litegraph.test.ts.snap index 6bdd6b6788..31984143b3 100644 --- a/src/lib/litegraph/src/__snapshots__/litegraph.test.ts.snap +++ b/src/lib/litegraph/src/__snapshots__/litegraph.test.ts.snap @@ -21,8 +21,6 @@ LiteGraphGlobal { "ContextMenu": [Function], "CurveEditor": [Function], "DEFAULT_FONT": "Inter", - "DEFAULT_GROUP_FONT": 24, - "DEFAULT_GROUP_FONT_SIZE": 24, "DEFAULT_POSITION": [ 100, 100, @@ -34,6 +32,7 @@ LiteGraphGlobal { "EVENT_LINK_COLOR": "#A86", "GRID_SHAPE": 6, "GROUP_FONT": "Inter", + "GROUP_TEXT_SIZE": 20, "Globals": {}, "HIDDEN_LINK": -1, "INPUT": 1, diff --git a/src/lib/litegraph/src/linkDeduplication.ts b/src/lib/litegraph/src/linkDeduplication.ts new file mode 100644 index 0000000000..e02b2209d6 --- /dev/null +++ b/src/lib/litegraph/src/linkDeduplication.ts @@ -0,0 +1,82 @@ +import type { LGraphNode, NodeId } from './LGraphNode' +import type { LLink, LinkId } from './LLink' + +/** Generates a unique string key for a link's connection tuple. */ +function linkTupleKey(link: LLink): string { + return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}` +} + +/** Groups all link IDs by their connection tuple key. */ +export function groupLinksByTuple( + links: Map +): Map { + const groups = new Map() + for (const [id, link] of links) { + const key = linkTupleKey(link) + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(id) + } + return groups +} + +/** + * Finds the link ID actually referenced by an input on the target node. + * Cannot rely on target_slot index because widget-to-input conversions + * during configure() can shift slot indices. + */ +export function selectSurvivorLink( + ids: LinkId[], + node: LGraphNode | null +): LinkId { + if (!node) return ids[0] + + for (const input of node.inputs ?? []) { + if (!input) continue + const match = ids.find((id) => input.link === id) + if (match != null) return match + } + return ids[0] +} + +/** Removes duplicate links from origin outputs and the graph's link map. */ +export function purgeOrphanedLinks( + ids: LinkId[], + keepId: LinkId, + links: Map, + getNodeById: (id: NodeId) => LGraphNode | null +): void { + for (const id of ids) { + if (id === keepId) continue + + const link = links.get(id) + if (!link) continue + + const originNode = getNodeById(link.origin_id) + const output = originNode?.outputs?.[link.origin_slot] + if (output?.links) { + for (let i = output.links.length - 1; i >= 0; i--) { + if (output.links[i] === id) output.links.splice(i, 1) + } + } + + links.delete(id) + } +} + +/** Ensures input.link on the target node points to the surviving link. */ +export function repairInputLinks( + ids: LinkId[], + keepId: LinkId, + node: LGraphNode | null +): void { + if (!node) return + + const duplicateIds = new Set(ids) + + for (const input of node.inputs ?? []) { + if (input?.link == null || input.link === keepId) continue + if (duplicateIds.has(input.link)) { + input.link = keepId + } + } +} diff --git a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts index 5d0c8ae5b6..5e4c097328 100644 --- a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts +++ b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts @@ -1,8 +1,8 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes' -type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string } - -type CreateView = (entry: ViewManagerEntry) => TView +type ViewManagerEntry = PromotedWidgetSource & { + viewKey?: string +} /** * Reconciles promoted widget entries to stable view instances. @@ -15,9 +15,9 @@ export class PromotedWidgetViewManager { private cachedViews: TView[] | null = null private cachedEntryKeys: string[] | null = null - reconcile( - entries: readonly ViewManagerEntry[], - createView: CreateView + reconcile( + entries: readonly TEntry[], + createView: (entry: TEntry) => TView ): TView[] { const entryKeys = entries.map((entry) => this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey) diff --git a/src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts b/src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts index 9c9690a8f0..cbce5e5310 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphIONodeBase.ts @@ -36,7 +36,7 @@ export abstract class SubgraphIONodeBase< { static margin = 10 static minWidth = 100 - static roundedRadius = 10 + static roundedRadius = 14 // Matches NODE_SLOT_HEIGHT * 0.7 for slot alignment private readonly _boundingRect: Rectangle = new Rectangle() diff --git a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts index fe0dc25c02..3687fd32e2 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts @@ -209,6 +209,14 @@ export class SubgraphInputNode link.id ) } + + if (subgraphInput.linkIds.length === 0) { + subgraphInput._widget = undefined + } + subgraphInput.events.dispatch('input-disconnected', { + input: subgraphInput + }) + const slotIndex = node.inputs.findIndex((inp) => inp === input) if (slotIndex !== -1) { node.onConnectionsChange?.( diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts index 803f3deeda..d044e5dfc2 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.test.ts @@ -9,7 +9,7 @@ import { createTestingPinia } from '@pinia/testing' import { setActivePinia } from 'pinia' import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation' -import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' import { subgraphTest } from './__fixtures__/subgraphFixtures' import { @@ -196,6 +196,258 @@ describe('SubgraphNode Synchronization', () => { expect(subgraphNode.outputs[0].label).toBe('newOutput') }) + + it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'text', type: 'STRING' }] + }) + + const interiorNode = new LGraphNode('Interior') + const input = interiorNode.addInput('value', 'STRING') + input.widget = { name: 'value' } + interiorNode.addOutput('out', 'STRING') + interiorNode.addWidget('text', 'value', '', () => {}) + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph) + const promotedInput = subgraphNode.inputs[0] + expect(promotedInput.widget).toBeDefined() + + const originalWidgetName = promotedInput.widget!.name + + // Rename the subgraph input label + subgraph.inputs[0].label = 'my_custom_prompt' + subgraph.events.dispatch('renaming-input', { + input: subgraph.inputs[0], + index: 0, + oldName: 'text', + newName: 'my_custom_prompt' + }) + + // widget.name stays as the internal name — NOT the display label + expect(promotedInput.widget!.name).toBe(originalWidgetName) + + // The display label is on input.label (live-read via PromotedWidgetView.label) + expect(promotedInput.label).toBe('my_custom_prompt') + + // input.widget.name should still match a widget in node.widgets + const matchingWidget = subgraphNode.widgets?.find( + (w) => w.name === promotedInput.widget!.name + ) + expect(matchingWidget).toBeDefined() + }) + + it('should preserve renamed label through serialize/configure round-trip', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'seed', type: 'INT' }] + }) + + const interiorNode = new LGraphNode('Interior') + const input = interiorNode.addInput('value', 'INT') + input.widget = { name: 'value' } + interiorNode.addOutput('out', 'INT') + interiorNode.addWidget('number', 'value', 0, () => {}) + subgraph.add(interiorNode) + subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode) + + const subgraphNode = createTestSubgraphNode(subgraph) + const promotedWidget = subgraphNode.widgets?.[0] + expect(promotedWidget).toBeDefined() + + // Rename via the subgraph slot (simulates right-click rename) + subgraph.inputs[0].label = 'My Seed' + subgraphNode.inputs[0].label = 'My Seed' + subgraph.events.dispatch('renaming-input', { + input: subgraph.inputs[0], + index: 0, + oldName: 'seed', + newName: 'My Seed' + }) + + // Label should be visible before round-trip + const widgetBeforeRoundTrip = subgraphNode.widgets?.[0] + expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe( + 'My Seed' + ) + + // Serialize and reconfigure (simulates save/reload) + const serialized = subgraphNode.serialize() + subgraphNode.configure(serialized) + + // Label should survive the round-trip + const widgetAfterRoundTrip = subgraphNode.widgets?.[0] + expect(widgetAfterRoundTrip).toBeDefined() + expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe( + 'My Seed' + ) + }) +}) + +describe('SubgraphNode widget name collision on rename', () => { + it('should not collapse two inputs when renamed to the same label', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'prompt_a', type: 'STRING' }, + { name: 'prompt_b', type: 'STRING' } + ] + }) + + // Create two interior nodes with widgets + const nodeA = new LGraphNode('NodeA') + nodeA.addInput('value', 'STRING') + nodeA.inputs[0].widget = { name: 'value' } + nodeA.addOutput('out', 'STRING') + nodeA.addWidget('text', 'value', '', () => {}) + subgraph.add(nodeA) + subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA) + + const nodeB = new LGraphNode('NodeB') + nodeB.addInput('value', 'STRING') + nodeB.inputs[0].widget = { name: 'value' } + nodeB.addOutput('out', 'STRING') + nodeB.addWidget('text', 'value', '', () => {}) + subgraph.add(nodeB) + subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB) + + const subgraphNode = createTestSubgraphNode(subgraph) + + expect(subgraphNode.inputs).toHaveLength(2) + // widget.name is now nodeId:widgetName (stable composite key) + const key0 = subgraphNode.inputs[0].widget?.name + const key1 = subgraphNode.inputs[1].widget?.name + expect(key0).toBeDefined() + expect(key1).toBeDefined() + expect(key0).not.toBe(key1) + + // Rename prompt_b to same LABEL as prompt_a + subgraph.inputs[1].label = 'prompt_a' + subgraph.events.dispatch('renaming-input', { + input: subgraph.inputs[1], + index: 1, + oldName: 'prompt_b', + newName: 'prompt_a' + }) + + // Both inputs survive — widget.name stays as composite key, no collision + expect(subgraphNode.inputs).toHaveLength(2) + expect(subgraphNode.inputs[0].widget?.name).toBe(key0) + expect(subgraphNode.inputs[1].widget?.name).toBe(key1) + + // Display labels: input[1] was renamed + expect(subgraphNode.inputs[1].label).toBe('prompt_a') + + // Distinct _widget bindings + expect(subgraphNode.inputs[0]._widget).toBeDefined() + expect(subgraphNode.inputs[1]._widget).toBeDefined() + expect(subgraphNode.inputs[0]._widget).not.toBe( + subgraphNode.inputs[1]._widget + ) + }) + + it('should keep unique widget.name keys even with duplicate labels', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'seed', type: 'INT' }, + { name: 'seed2', type: 'INT' } + ] + }) + + const nodeA = new LGraphNode('NodeA') + nodeA.addInput('value', 'INT') + nodeA.inputs[0].widget = { name: 'value' } + nodeA.addOutput('out', 'INT') + nodeA.addWidget('number', 'value', 0, () => {}) + subgraph.add(nodeA) + subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA) + + const nodeB = new LGraphNode('NodeB') + nodeB.addInput('value', 'INT') + nodeB.inputs[0].widget = { name: 'value' } + nodeB.addOutput('out', 'INT') + nodeB.addWidget('number', 'value', 0, () => {}) + subgraph.add(nodeB) + subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB) + + const subgraphNode = createTestSubgraphNode(subgraph) + + const key0 = subgraphNode.inputs[0].widget?.name + const key1 = subgraphNode.inputs[1].widget?.name + + // Keys should be unique composite identifiers (nodeId:widgetName) + expect(key0).toBeDefined() + expect(key1).toBeDefined() + expect(key0).not.toBe(key1) + + // Rename seed2 to "seed" — duplicate display label + subgraph.inputs[1].label = 'seed' + subgraph.events.dispatch('renaming-input', { + input: subgraph.inputs[1], + index: 1, + oldName: 'seed2', + newName: 'seed' + }) + + // Widget keys remain stable — rename only affects display label + expect(subgraphNode.inputs[0].widget?.name).toBe(key0) + expect(subgraphNode.inputs[1].widget?.name).toBe(key1) + + // Distinct _widget bindings survive the rename + expect(subgraphNode.inputs[0]._widget).toBeDefined() + expect(subgraphNode.inputs[1]._widget).toBeDefined() + expect(subgraphNode.inputs[0]._widget).not.toBe( + subgraphNode.inputs[1]._widget + ) + }) + + it('should not lose input when onGraphConfigured runs after duplicate rename', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'alpha', type: 'STRING' }, + { name: 'beta', type: 'STRING' } + ] + }) + + const nodeA = new LGraphNode('NodeA') + nodeA.addInput('value', 'STRING') + nodeA.inputs[0].widget = { name: 'value' } + nodeA.addOutput('out', 'STRING') + nodeA.addWidget('text', 'value', '', () => {}) + subgraph.add(nodeA) + subgraph.inputNode.slots[0].connect(nodeA.inputs[0], nodeA) + + const nodeB = new LGraphNode('NodeB') + nodeB.addInput('value', 'STRING') + nodeB.inputs[0].widget = { name: 'value' } + nodeB.addOutput('out', 'STRING') + nodeB.addWidget('text', 'value', '', () => {}) + subgraph.add(nodeB) + subgraph.inputNode.slots[1].connect(nodeB.inputs[0], nodeB) + + const subgraphNode = createTestSubgraphNode(subgraph) + + // Rename beta to "alpha" — collision + subgraph.inputs[1].label = 'alpha' + subgraph.events.dispatch('renaming-input', { + input: subgraph.inputs[1], + index: 1, + oldName: 'beta', + newName: 'alpha' + }) + + // Simulate onGraphConfigured check: for each input with widget, + // find a matching widget by name. If not found, the input gets removed. + for (const input of subgraphNode.inputs) { + if (!input.widget) continue + const name = input.widget.name + const w = subgraphNode.widgets?.find((w) => w.name === name) + // Every input should find at least one matching widget + expect(w).toBeDefined() + } + + // Both inputs should survive + expect(subgraphNode.inputs).toHaveLength(2) + }) }) describe('SubgraphNode Lifecycle', () => { @@ -706,3 +958,69 @@ describe('SubgraphNode promotion view keys', () => { expect(firstKey).not.toBe(secondKey) }) }) + +describe('SubgraphNode label propagation', () => { + it('should preserve input labels from configure path', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'steps', type: 'number' }] + }) + subgraph.inputs[0].label = 'Steps Count' + + const subgraphNode = createTestSubgraphNode(subgraph) + + expect(subgraphNode.inputs[0].label).toBe('Steps Count') + expect(subgraphNode.inputs[0].name).toBe('steps') + }) + + it('should preserve output labels from configure path', () => { + const subgraph = createTestSubgraph({ + outputs: [{ name: 'result', type: 'number' }] + }) + subgraph.outputs[0].label = 'Final Result' + + const subgraphNode = createTestSubgraphNode(subgraph) + + expect(subgraphNode.outputs[0].label).toBe('Final Result') + expect(subgraphNode.outputs[0].name).toBe('result') + }) + + it('should propagate label via renaming-input event', () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + + subgraph.addInput('steps', 'number') + expect(subgraphNode.inputs[0].label).toBeUndefined() + + subgraph.renameInput(subgraph.inputs[0], 'Steps Count') + + expect(subgraphNode.inputs[0].label).toBe('Steps Count') + expect(subgraphNode.inputs[0].name).toBe('steps') + }) + + it('should propagate label via renaming-output event', () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph) + + subgraph.addOutput('result', 'number') + expect(subgraphNode.outputs[0].label).toBeUndefined() + + subgraph.renameOutput(subgraph.outputs[0], 'Final Result') + + expect(subgraphNode.outputs[0].label).toBe('Final Result') + expect(subgraphNode.outputs[0].name).toBe('result') + }) + + it('should preserve localized_name from configure path', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'steps', type: 'number' }], + outputs: [{ name: 'result', type: 'number' }] + }) + subgraph.inputs[0].localized_name = 'ステップ' + subgraph.outputs[0].localized_name = '結果' + + const subgraphNode = createTestSubgraphNode(subgraph) + + expect(subgraphNode.inputs[0].localized_name).toBe('ステップ') + expect(subgraphNode.outputs[0].localized_name).toBe('結果') + }) +}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index beda5ec2e7..e9ca28f001 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -34,6 +34,7 @@ import { createPromotedWidgetView, isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' +import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization' import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes' import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' @@ -63,6 +64,8 @@ workflowSvg.src = type LinkedPromotionEntry = PromotedWidgetSource & { inputName: string inputKey: string + /** The subgraph input slot's internal name (stable identity). */ + slotName: string } // Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing // the SVG's internal stylesheet on every ctx.drawImage() call per frame. @@ -192,6 +195,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { linkedEntries.push({ inputName: input.label ?? input.name, inputKey: String(subgraphInput.id), + slotName: subgraphInput.name, sourceNodeId: boundWidget.sourceNodeId, sourceWidgetName: boundWidget.sourceWidgetName }) @@ -206,6 +210,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { linkedEntries.push({ inputName: input.label ?? input.name, inputKey: String(subgraphInput.id), + slotName: subgraphInput.name, ...resolved }) } @@ -277,7 +282,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined, - entry.disambiguatingSourceNodeId + entry.disambiguatingSourceNodeId, + entry.slotName ) ) @@ -333,6 +339,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { sourceWidgetName: string viewKey?: string disambiguatingSourceNodeId?: string + slotName?: string }> } { const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries( @@ -509,6 +516,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { const prunedEntries: PromotedWidgetSource[] = [] for (const entry of fallbackStoredEntries) { + if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue + const concreteKey = this._resolveConcretePromotionEntryKey(entry) if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue @@ -562,17 +571,22 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { sourceNodeId: string sourceWidgetName: string viewKey: string + disambiguatingSourceNodeId?: string + slotName: string }> { return linkedEntries.map( ({ inputKey, inputName, + slotName, sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({ sourceNodeId, sourceWidgetName, + slotName, + disambiguatingSourceNodeId, viewKey: this._makePromotionViewKey( inputKey, sourceNodeId, @@ -780,9 +794,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (!input) throw new Error('Subgraph input not found') input.label = newName - if (input._widget) { - input._widget.label = newName - } + // Do NOT change input.widget.name — it is the stable internal + // identifier used by onGraphConfigured (widgetInputs.ts) to match + // inputs to widgets. Changing it to the display label would cause + // collisions when two promoted inputs share the same label. + // Display is handled via input.label and _widget.label. + if (input._widget) input._widget.label = newName this._invalidatePromotedViewsCache() this.graph?.trigger('node:slot-label:changed', { nodeId: this.id, @@ -868,8 +885,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { usePromotionStore().demote(this.rootGraph.id, this.id, source) } - const didSetWidgetFromEvent = !input._widget - if (didSetWidgetFromEvent) + const boundWidget = + input._widget && isPromotedWidgetView(input._widget) + ? input._widget + : undefined + const hasStaleBoundWidget = + boundWidget && + this.subgraph + .getNodeById(boundWidget.sourceNodeId) + ?.widgets?.some( + (widget) => widget.name === boundWidget.sourceWidgetName + ) !== true + + const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget + if (shouldSetWidgetFromEvent) this._setWidget( subgraphInput, input, @@ -1049,20 +1078,23 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } return null } - const entry: PromotedWidgetSource = { - sourceNodeId: nodeId, - sourceWidgetName: widgetName, - ...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId }) - } - return entry + if (!this.subgraph.getNodeById(nodeId)) return null + + return normalizeLegacyProxyWidgetEntry( + this, + nodeId, + widgetName, + sourceNodeId + ) }) .filter((e): e is NonNullable => e !== null) store.setPromotions(this.rootGraph.id, this.id, entries) - // Write back resolved entries so legacy -1 format doesn't persist - if (raw.some(([id]) => id === '-1')) { - this.properties.proxyWidgets = this._serializeEntries(entries) + // Write back resolved entries so legacy or stale entries don't persist + const serialized = this._serializeEntries(entries) + if (JSON.stringify(serialized) !== JSON.stringify(raw)) { + this.properties.proxyWidgets = serialized } // Check all inputs for connected widgets @@ -1094,6 +1126,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } + /** + * Clears all cached promoted widget views and re-resolves `input._widget` + * bindings from the current subgraph connections. Called after ancestor + * promotions are repointed during nested subgraph packing. + */ + rebuildInputWidgetBindings(): void { + this._promotedViewManager.clear() + this._invalidatePromotedViewsCache() + + for (const input of this.inputs) { + delete input.widget + delete input.pos + input._widget = undefined + const subgraphInput = input._subgraphSlot + if (!subgraphInput) continue + this._resolveInputWidget(subgraphInput, input) + } + + this._syncPromotions() + } + private _resolveInputWidget( subgraphInput: SubgraphInput, input: INodeInputSlot @@ -1134,6 +1187,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } + /** + * Binds a promoted widget view to a subgraph input slot. + * + * Creates or retrieves a {@link PromotedWidgetView}, registers it in the + * promotion store, sets up the prototype chain for multi-level subgraph + * nesting, and dispatches the `widget-promoted` event. + */ private _setWidget( subgraphInput: Readonly, input: INodeInputSlot, @@ -1187,7 +1247,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { }) } - // Create/retrieve the view from cache + // Create/retrieve the view from cache. + // The cache key uses `input.name` (the slot's internal name) rather + // than `subgraphInput.name` because nested subgraphs may remap + // the internal name independently of the interior node. const view = this._promotedViewManager.getOrCreate( nodeId, widgetName, @@ -1197,7 +1260,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { nodeId, widgetName, input.label ?? subgraphInput.name, - sourceNodeId + sourceNodeId, + subgraphInput.name ), this._makePromotionViewKey( String(subgraphInput.id), @@ -1211,6 +1275,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { // NOTE: This code creates linked chains of prototypes for passing across // multiple levels of subgraphs. As part of this, it intentionally avoids // creating new objects. Have care when making changes. + // Use subgraphInput.name as the stable identity — unique per subgraph + // slot, immune to label renames. Matches PromotedWidgetView.name. + // Display is handled via widget.label / PromotedWidgetView.label. input.widget ??= { name: subgraphInput.name } input.widget.name = subgraphInput.name if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget) diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index b99bfa2b26..d85d0c8750 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -8,9 +8,11 @@ import type { TWidgetType } from '@/lib/litegraph/src/litegraph' import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' import { createEventCapture, + createTestRootGraph, createTestSubgraph, createTestSubgraphNode, resetSubgraphFixtureState @@ -263,6 +265,121 @@ describe('SubgraphWidgetPromotion', () => { }) }) + describe('Nested Subgraph Widget Promotion', () => { + 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 + // node IDs that no longer exist directly in the outer subgraph. + // Uses 3 inputs with only 1 having a linked widget entry, matching the + // real workflow structure where model/vae inputs don't resolve widgets. + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'clip', type: 'CLIP' }, + { name: 'model', type: 'MODEL' }, + { name: 'vae', type: 'VAE' } + ] + }) + + const { node: samplerNode } = createNodeWithWidget( + 'Sampler', + 'number', + 42, + 'number' + ) + subgraph.add(samplerNode) + subgraph.inputNode.slots[1].connect(samplerNode.inputs[0], samplerNode) + + // Add nodes without widget-connected inputs for the other slots + const modelNode = new LGraphNode('ModelNode') + modelNode.addInput('model', 'MODEL') + subgraph.add(modelNode) + + const vaeNode = new LGraphNode('VAENode') + vaeNode.addInput('vae', 'VAE') + subgraph.add(vaeNode) + + const outerNode = createTestSubgraphNode(subgraph) + const keptSamplerNodeId = String(samplerNode.id) + + // Inject stale proxyWidgets referencing nodes that don't exist in + // this subgraph (they were packed into a nested subgraph) + outerNode.properties.proxyWidgets = [ + ['999', 'text'], + ['998', 'text'], + [keptSamplerNodeId, 'widget'] + ] + + outerNode.configure(outerNode.serialize()) + + // Check widgets getter — stale entries should not produce views + const widgetSourceIds = outerNode.widgets + .filter(isPromotedWidgetView) + .filter((w) => !w.name.startsWith('$$')) + .map((w) => w.sourceNodeId) + + expect(widgetSourceIds).not.toContain('999') + expect(widgetSourceIds).not.toContain('998') + expect(widgetSourceIds).toContain(keptSamplerNodeId) + }) + + it('should normalize legacy prefixed proxyWidgets on configure', () => { + const rootGraph = createTestRootGraph() + + const innerSubgraph = createTestSubgraph({ + rootGraph, + inputs: [{ name: 'seed', type: 'number' }] + }) + + const samplerNode = new LGraphNode('Sampler') + const samplerInput = samplerNode.addInput('seed', 'number') + samplerNode.addWidget('number', 'noise_seed', 123, () => {}) + samplerInput.widget = { name: 'noise_seed' } + innerSubgraph.add(samplerNode) + innerSubgraph.inputNode.slots[0].connect( + samplerNode.inputs[0], + samplerNode + ) + + const outerSubgraph = createTestSubgraph({ rootGraph }) + const nestedNode = createTestSubgraphNode(innerSubgraph, { + parentGraph: outerSubgraph + }) + outerSubgraph.add(nestedNode) + + const hostNode = createTestSubgraphNode(outerSubgraph, { + parentGraph: rootGraph + }) + + const serializedHostNode = hostNode.serialize() + serializedHostNode.properties = { + ...serializedHostNode.properties, + proxyWidgets: [ + [ + String(nestedNode.id), + `${nestedNode.id}: ${samplerNode.id}: noise_seed` + ] + ] + } + + hostNode.configure(serializedHostNode) + + const promotedWidgets = hostNode.widgets + .filter(isPromotedWidgetView) + .filter((widget) => !widget.name.startsWith('$$')) + + expect(promotedWidgets).toHaveLength(1) + expect(promotedWidgets[0].type).toBe('number') + expect(promotedWidgets[0].value).toBe(123) + expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed') + expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe( + String(samplerNode.id) + ) + expect(hostNode.properties.proxyWidgets).toStrictEqual([ + [String(nestedNode.id), 'noise_seed', String(samplerNode.id)] + ]) + }) + }) + describe('Tooltip Promotion', () => { it('should preserve widget tooltip when promoting', () => { const subgraph = createTestSubgraph({ diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index 70219c8e37..827e464eeb 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -15,6 +15,14 @@ "message": "يحتوي سير العمل هذا على عقد API، والتي تتطلب تسجيل دخولك إلى حسابك لتشغيلها.", "title": "تسجيل الدخول مطلوب لاستخدام عقد API" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "لأفضل تجربة، يستخدم منشئ التطبيقات Nodes 2.0. يمكنك العودة بعد بناء التطبيق من القائمة الرئيسية.", + "dismiss": "تجاهل", + "dontShowAgain": "عدم الإظهار مرة أخرى", + "title": "تم التبديل إلى Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "جميع {category}", "allModels": "جميع النماذج", @@ -556,19 +564,26 @@ "uploadCover": "+ رفع صورة الغلاف", "uploadProfilePicture": "+ رفع صورة الملف الشخصي", "uploadWorkflowButton": "رفع سير عملي", + "usernameError": "٣–٤٢ حرفًا صغيرًا أو رقمًا أو شرطة، ويجب أن يبدأ وينتهي بحرف أو رقم", "usernameLabel": "اسم المستخدم (إجباري)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "معلومات إضافية", "back": "رجوع", "createProfileCta": "إنشاء ملف شخصي", "createProfileToPublish": "أنشئ ملفًا شخصيًا للنشر على ComfyHub", "exampleImage": "صورة نموذجية {index}", + "exampleImagePosition": "الصورة النموذجية {index} من {total}", "examplesDescription": "أضف حتى {total} صورة نموذجية إضافية", "maxExamples": "يمكنك اختيار حتى {max} أمثلة", "next": "التالي", "publishButton": "النشر على ComfyHub", + "publishFailedDescription": "حدث خطأ أثناء نشر سير العمل الخاص بك. يرجى المحاولة مرة أخرى.", + "publishFailedTitle": "فشل النشر", + "removeExampleImage": "إزالة الصورة النموذجية", "selectAThumbnail": "اختر صورة مصغرة", + "shareAs": "مشاركة كـ", "showLessTags": "عرض أقل...", "showMoreTags": "عرض المزيد...", "stepDescribe": "وصف سير العمل", @@ -583,6 +598,7 @@ "thumbnailPreview": "معاينة الصورة المصغرة", "thumbnailVideo": "فيديو", "title": "النشر على ComfyHub", + "unsavedDescription": "يجب حفظ سير العمل الخاص بك قبل النشر على ComfyHub. احفظه الآن للمتابعة.", "uploadAnImage": "انقر للاستعراض أو اسحب صورة", "uploadComparison": "رفع صورة قبل وبعد", "uploadComparisonAfterPrompt": "بعد", @@ -598,13 +614,7 @@ "workflowDescription": "وصف سير العمل", "workflowDescriptionPlaceholder": "ما الذي يجعل سير عملك مميزًا ومثيرًا؟ كن محددًا حتى يعرف الآخرون ما يمكن توقعه.", "workflowName": "اسم سير العمل", - "workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه", - "workflowType": "نوع سير العمل", - "workflowTypeEditing": "تحرير", - "workflowTypeImageGeneration": "توليد الصور", - "workflowTypePlaceholder": "اختر النوع", - "workflowTypeUpscaling": "تحسين الجودة", - "workflowTypeVideoGeneration": "توليد الفيديو" + "workflowNamePlaceholder": "نصيحة: أدخل اسمًا وصفيًا يسهل البحث عنه" }, "commands": { "clear": "مسح سير العمل", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "تكييف", "CONTROL_NET": "ControlNet", + "CURVE": "منحنى", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "ملف ثلاثي الأبعاد", "FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "ملفات إدخال جيميني", "GLIGEN": "GLIGEN", "GUIDER": "موجه", + "HISTOGRAM": "مخطط بياني", "HOOKS": "معالجات", "HOOK_KEYFRAMES": "مفاتيح المعالجات", "IMAGE": "صورة", @@ -876,6 +888,8 @@ "resume": "استئناف التنزيل" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "حدث خطأ", "extensionFileHint": "قد يكون السبب هو السكربت التالي", "loadWorkflowTitle": "تم إلغاء التحميل بسبب خطأ في إعادة تحميل بيانات سير العمل", @@ -924,6 +938,19 @@ "textToImage": "تحويل نص إلى صورة", "textToVideo": "تحويل نص إلى فيديو" }, + "execution": { + "decoding": "جارٍ فك الترميز…", + "encoding": "جارٍ الترميز…", + "generating": "جارٍ التوليد…", + "generatingVideo": "جارٍ توليد الفيديو…", + "loading": "جارٍ التحميل…", + "processing": "جارٍ المعالجة…", + "processingVideo": "جارٍ معالجة الفيديو…", + "resizing": "جارٍ تغيير الحجم…", + "running": "جارٍ التشغيل…", + "saving": "جارٍ الحفظ…", + "training": "جارٍ التدريب…" + }, "exportToast": { "allExportsCompleted": "اكتملت جميع عمليات التصدير", "downloadExport": "تحميل التصدير", @@ -1064,11 +1091,14 @@ "filterBy": "تصفية حسب:", "filterByType": "تصفية حسب {type}...", "findIssues": "العثور على مشاكل", + "findOnGithub": "ابحث في GitHub", "frameNodes": "تأطير العقد", "frontendNewer": "إصدار الواجهة الأمامية {frontendVersion} قد لا يكون متوافقاً مع الإصدار الخلفي {backendVersion}.", "frontendOutdated": "إصدار الواجهة الأمامية {frontendVersion} قديم. يتطلب الإصدار الخلفي {requiredVersion} أو أحدث.", + "gallery": "المعرض", "galleryImage": "صورة المعرض", "galleryThumbnail": "صورة مصغرة للمعرض", + "getHelpAction": "الحصول على المساعدة", "goToNode": "الانتقال إلى العقدة", "graphNavigation": "التنقل في الرسم البياني", "halfSpeed": "0.5x", @@ -1077,6 +1107,8 @@ "icon": "أيقونة", "imageDoesNotExist": "الصورة غير موجودة", "imageFailedToLoad": "فشل تحميل الصورة", + "imageGallery": "معرض الصور", + "imageLightbox": "معاينة الصورة", "imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور", "imageUrl": "رابط الصورة", "import": "استيراد", @@ -1090,13 +1122,38 @@ "installed": "مثبت", "installing": "جارٍ التثبيت", "interrupted": "تمت المقاطعة", - "itemSelected": "تم تحديد عنصر واحد", "itemsCopiedToClipboard": "تم نسخ العناصر إلى الحافظة", "itemsSelected": "تم تحديد {selectedCount} عناصر", "job": "مهمة", "jobIdCopied": "تم نسخ معرف المهمة إلى الحافظة", "keybinding": "اختصار لوحة المفاتيح", "keybindingAlreadyExists": "الاختصار موجود بالفعل في", + "keybindingPresets": { + "default": "الإعداد المسبق الافتراضي", + "deletePreset": "حذف الإعداد المسبق", + "deletePresetFailed": "فشل في حذف الإعداد المسبق \"{name}\"", + "deletePresetTitle": "حذف الإعداد المسبق الحالي؟", + "deletePresetWarning": "سيتم حذف هذا الإعداد المسبق. لا يمكن التراجع عن ذلك.", + "discardAndSwitch": "تجاهل والانتقال", + "exportPreset": "تصدير الإعداد المسبق", + "importKeybindingPreset": "استيراد إعداد مفاتيح الاختصار", + "importPreset": "استيراد الإعداد المسبق", + "invalidPresetFile": "يجب أن يكون ملف الإعداد المسبق ملف JSON صالح تم تصديره من ComfyUI", + "invalidPresetName": "يجب ألا يكون اسم الإعداد المسبق فارغًا أو \"default\" أو يبدأ بنقطة أو يحتوي على فواصل مسار أو ينتهي بـ .json", + "loadPresetFailed": "فشل في تحميل الإعداد المسبق \"{name}\"", + "overwritePresetMessage": "يوجد إعداد مسبق باسم \"{name}\" بالفعل. هل تريد استبداله؟", + "overwritePresetTitle": "استبدال الإعداد المسبق", + "presetDeleted": "تم حذف الإعداد المسبق \"{name}\"", + "presetImported": "تم استيراد إعداد مفاتيح الاختصار", + "presetNamePrompt": "أدخل اسمًا للإعداد المسبق", + "presetSaved": "تم حفظ الإعداد المسبق \"{name}\"", + "resetToDefault": "إعادة التعيين إلى الافتراضي", + "saveAndSwitch": "حفظ والانتقال", + "saveAsNewPreset": "حفظ كإعداد مخصص جديد", + "saveChanges": "حفظ التغييرات", + "unsavedChangesMessage": "لديك تغييرات غير محفوظة ستفقد إذا انتقلت دون حفظ.", + "unsavedChangesTo": "تغييرات غير محفوظة على {name}" + }, "keybindings": "اختصارات لوحة المفاتيح", "learnMore": "اعرف المزيد", "listening": "جاري الاستماع...", @@ -1153,6 +1210,8 @@ "output": "إخراج", "overwrite": "الكتابة فوق", "partner": "شريك", + "pause": "إيقاف مؤقت", + "play": "تشغيل", "playPause": "تشغيل/إيقاف مؤقت", "playRecording": "تشغيل التسجيل", "playbackSpeed": "سرعة التشغيل", @@ -1160,6 +1219,7 @@ "preloadError": "فشل تحميل مورد مطلوب. يرجى إعادة تحميل الصفحة.", "preloadErrorTitle": "خطأ في التحميل", "preview": "معاينة", + "previous": "السابق", "previousImage": "الصورة السابقة", "profile": "الملف الشخصي", "progressCountOf": "من", @@ -1234,6 +1294,8 @@ "showReport": "عرض التقرير", "showRightPanel": "إظهار اللوحة اليمنى", "singleSelectDropdown": "قائمة منسدلة اختيار واحد", + "skipToEnd": "الانتقال للنهاية", + "skipToStart": "الانتقال للبداية", "sort": "فرز", "source": "المصدر", "startRecording": "بدء التسجيل", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.", "videoFailedToLoad": "فشل تحميل الفيديو", "videoPreview": "معاينة الفيديو - استخدم مفاتيح الأسهم للتنقل بين الفيديوهات", + "viewGrid": "عرض الشبكة", "viewImageOfTotal": "عرض الصورة {index} من {total}", "viewVideoOfTotal": "عرض الفيديو {index} من {total}", "volume": "مستوى الصوت", @@ -1832,6 +1895,7 @@ "mirrorVertical": "انعكاس عمودي", "negative": "سلبي", "opacity": "الشفافية", + "openMaskEditor": "فتح في محرر القناع", "paintBucketSettings": "إعدادات دلو الطلاء", "paintLayer": "طبقة الطلاء", "redo": "إعادة", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "مون فالي ماري", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "رودان", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "ستظهر المدخلات التي تضعها في المفضلة هنا", "favoritesNoneHint": "في علامة تبويب المعلمات، انقر على {moreIcon} بجانب أي إدخال لإضافته هنا", "favoritesNoneTooltip": "قم بوضع نجمة على الأدوات للوصول السريع إليها دون اختيار العقد", + "findOnGithubTooltip": "ابحث في مشكلات GitHub عن مشاكل مشابهة", + "getHelpTooltip": "أبلغ عن هذا الخطأ وسنساعدك في حله", "globalSettings": { "canvas": "اللوحة", "connectionLinks": "روابط الاتصال", @@ -3073,7 +3140,6 @@ "title": "تم إلغاء اشتراكك" }, "changeTo": "تغيير إلى {plan}", - "chooseBestPlanWorkspace": "اختر أفضل خطة لمساحة العمل الخاصة بك", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "شعار Comfy Cloud", "contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (ذاكرة 96GB VRAM)", "haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟", + "inviteUpTo": "ادعُ حتى", "invoiceHistory": "سجل الفواتير", "learnMore": "معرفة المزيد", "managePayment": "إدارة الدفع", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "الرصيد الشهري / عضو", "monthlyCreditsRollover": "سيتم ترحيل هذا الرصيد إلى الشهر التالي", "mostPopular": "الأكثر شيوعًا", + "needTeamWorkspace": "هل تحتاج إلى مساحة عمل للفريق؟", "nextBillingCycle": "دورة الفوترة التالية", "nextMonthInvoice": "فاتورة الشهر القادم", "partnerNodesBalance": "رصيد \"عُقَد الشريك\"", "partnerNodesCredits": "رصيد العقد الشريكة", "partnerNodesDescription": "لتشغيل النماذج التجارية/المملوكة", "perMonth": "دولار أمريكي / شهر", + "personalWorkspace": "مساحة العمل الشخصية", "plansAndPricing": "الخطط والأسعار", + "plansForWorkspace": "الخطط لمساحة العمل {workspace}", "prepaidCreditsInfo": "رصيد تم شراؤه بشكل منفصل ولا ينتهي صلاحيته", "prepaidDescription": "رصيد مسبق الدفع", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "إعادة الاشتراك", "resubscribeSuccess": "تمت إعادة تفعيل الاشتراك بنجاح", "resubscribeTo": "إعادة الاشتراك في {plan}", + "soloUseOnly": "للاستخدام الفردي فقط", "subscribeForMore": "ترقية", "subscribeNow": "اشترك الآن", "subscribeTo": "اشترك في {plan}", @@ -3177,6 +3248,7 @@ "subscribeToRun": "اشتراك", "subscribeToRunFull": "الاشتراك للتشغيل", "subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة", + "teamWorkspace": "مساحة عمل الفريق", "tierNameYearly": "{name} سنوي", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "تكرار التبويب", "removeFromBookmarks": "إزالة من العلامات" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "تم إنشاء مساحة العمل لكن الإعداد غير مكتمل", + "createWorkspace": "إنشاء مساحة عمل", + "namePlaceholder": "مثال: فريق التسويق", + "nameValidationError": "يجب أن يكون الاسم من ١ إلى ٥٠ حرفًا باستخدام الحروف أو الأرقام أو المسافات أو علامات الترقيم الشائعة.", + "newWorkspace": "مساحة عمل جديدة", + "subtitle": "انتقل إلى مساحة عمل موجودة أو أنشئ مساحة عمل جديدة", + "subtitleNoWorkspaces": "أنشئ مساحة عمل فريق جديدة لمشاركة الرصيد", + "switch": "تبديل", + "title": "مساحات عمل الفريق", + "yourTeamWorkspaces": "مساحات عمل فريقك" + }, "templateWidgets": { "sort": { "searchPlaceholder": "بحث..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "إنشاء مساحة عمل جديدة", + "failedToSwitch": "فشل في تبديل مساحة العمل", "maxWorkspacesReached": "يمكنك امتلاك ١٠ مساحات عمل فقط. احذف واحدة لإنشاء مساحة جديدة.", "personal": "شخصي", "roleMember": "عضو", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index 9471b00078..d2bd517d26 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "تحويل الرقم", + "inputs": { + "value": { + "name": "القيمة" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "مفتاح التحويل", "inputs": { @@ -1736,6 +1752,10 @@ "name": "closed_loop", "tooltip": "ما إذا كان سيتم إغلاق حلقة نافذة السياق؛ تنطبق فقط على الجداول الحلقية." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "قائمة مؤشرات latent التي سيتم الاحتفاظ بها في موترات التكييف لكل نافذة. على سبيل المثال، تعيين هذه القيمة إلى '0' سيستخدم صورة البداية الأولية لكل نافذة." + }, "context_length": { "name": "context_length", "tooltip": "طول نافذة السياق." @@ -1767,6 +1787,10 @@ "model": { "name": "model", "tooltip": "النموذج المراد تطبيق نوافذ السياق عليه أثناء أخذ العينات." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "هل تريد تقسيم التكييفات المتعددة (التي تم إنشاؤها بواسطة ConditionCombine) إلى كل نافذة بناءً على مؤشر المنطقة." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "محرر المنحنى", + "inputs": { + "curve": { + "name": "منحنى" + }, + "histogram": { + "name": "مخطط بياني" + } + }, + "outputs": { + "0": { + "name": "منحنى", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "توليفة مخصصة", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "الصورة الأولى من عملية تفكير النموذج. متوفرة فقط عند مستوى التفكير العالي ونمط IMAGE+TEXT." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "تمديد فيديو موجود باستمرار سلس بناءً على وصف نصي.", + "display_name": "تمديد فيديو Grok", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج", + "tooltip": "النموذج المستخدم لتمديد الفيديو." + }, + "model_duration": { + "name": "المدة" + }, + "prompt": { + "name": "الوصف النصي", + "tooltip": "وصف نصي لما يجب أن يحدث بعد ذلك في الفيديو." + }, + "seed": { + "name": "البذرة", + "tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة." + }, + "video": { + "name": "فيديو", + "tooltip": "الفيديو المصدر للتمديد. صيغة MP4، من ٢ إلى ١٥ ثانية." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "توليد فيديو من مطالبة أو صورة", "display_name": "فيديو Grok", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "توليد فيديو موجه بواسطة صور مرجعية كمرجع للأسلوب والمحتوى.", + "display_name": "Grok من مرجع إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج", + "tooltip": "النموذج المستخدم لتوليد الفيديو." + }, + "model_aspect_ratio": { + "name": "نسبة العرض إلى الارتفاع" + }, + "model_duration": { + "name": "المدة" + }, + "model_resolution": { + "name": "الدقة" + }, + "prompt": { + "name": "الوصف النصي", + "tooltip": "وصف نصي للفيديو المطلوب." + }, + "seed": { + "name": "البذرة", + "tooltip": "بذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "توسيع القناع", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "تعيين صوت مرجعي لنقل هوية المتحدث باستخدام ID-LoRA. يقوم بترميز مقطع صوتي مرجعي إلى التكييف، ويمكنه أيضًا تعديل النموذج بتوجيه الهوية (تمرير إضافي للأمام بدون المرجع، مما يعزز تأثير هوية المتحدث).", + "display_name": "LTXV مرجع الصوت (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE للترميز." + }, + "end_percent": { + "name": "نسبة النهاية", + "tooltip": "نهاية نطاق سيغما حيث يكون توجيه الهوية نشطًا." + }, + "identity_guidance_scale": { + "name": "مقياس توجيه الهوية", + "tooltip": "قوة توجيه الهوية. ينفذ تمريرًا إضافيًا للأمام بدون المرجع في كل خطوة لتعزيز هوية المتحدث. اضبط على ٠ للتعطيل (بدون تمرير إضافي)." + }, + "model": { + "name": "النموذج" + }, + "negative": { + "name": "سلبي" + }, + "positive": { + "name": "إيجابي" + }, + "reference_audio": { + "name": "الصوت_المرجعي", + "tooltip": "مقطع صوتي مرجعي لنقل هوية المتحدث. يُوصى بأن يكون حوالي ٥ ثوانٍ (مدة التدريب). المقاطع الأقصر أو الأطول قد تؤثر سلبًا على نقل هوية الصوت." + }, + "start_percent": { + "name": "نسبة البداية", + "tooltip": "بداية نطاق سيغما حيث يكون توجيه الهوية نشطًا." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "إيجابي", + "tooltip": null + }, + "2": { + "name": "سلبي", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXV المجدول", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "تحويل صورة نقطية إلى SVG باستخدام Quiver AI.", + "display_name": "Quiver تحويل صورة إلى SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "قص تلقائي للعنصر الرئيسي في الصورة." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "الصورة المدخلة لتحويلها إلى متجهات." + }, + "model": { + "name": "model", + "tooltip": "النموذج المستخدم لتحويل الصورة إلى SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "إنشاء SVG من وصف نصي باستخدام Quiver AI.", + "display_name": "Quiver تحويل نص إلى SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "إرشادات إضافية حول الأسلوب أو التنسيق." + }, + "model": { + "name": "model", + "tooltip": "النموذج المستخدم لإنشاء SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "وصف نصي لمخرجات SVG المطلوبة." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "حتى ٤ صور مرجعية لتوجيه عملية الإنشاء." + }, + "seed": { + "name": "seed", + "tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "أقل قيمة p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "عقوبة التكرار" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "أعلى p" + }, + "thinking": { + "name": "التفكير", + "tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "أدنى قيمة p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "عقوبة التكرار" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "أعلى p" + }, + "thinking": { + "name": "التفكير", + "tooltip": "التشغيل في وضع التفكير إذا كان النموذج يدعم ذلك." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "إيجابي", "tooltip": "التكييف الإيجابي المستخدم في التدريب." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "عند استخدام نوع التدريب 'none' والتدريب على نموذج كمي، يتم تنفيذ عملية الرجوع للخلف باستخدام ضرب المصفوفات الكمي عند التفعيل." + }, "rank": { "name": "الرتبة", "tooltip": "رتبة طبقات LoRA." diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 6e67fd6004..495cb3e12e 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "مخصص: استبدال شريط عنوان النظام بالقائمة العلوية لـ ComfyUI" }, + "Comfy_Appearance_DisableAnimations": { + "name": "تعطيل الرسوم المتحركة", + "tooltip": "يقوم بإيقاف معظم الرسوم المتحركة والانتقالات في CSS. يسرّع الاستدلال عندما يتم استخدام وحدة معالجة الرسوميات للعرض أيضًا في التوليد." + }, "Comfy_Canvas_BackgroundImage": { "name": "صورة خلفية اللوحة", "tooltip": "رابط صورة لخلفية اللوحة. يمكنك النقر بزر الفأرة الأيمن على صورة في لوحة النتائج واختيار \"تعيين كخلفية\" لاستخدامها، أو رفع صورتك الخاصة باستخدام زر الرفع." @@ -95,6 +99,10 @@ "name": "عدد أرقام التقريب العشرية لأدوات التحكم العائمة [0 = تلقائي]", "tooltip": "(يتطلب إعادة تحميل الصفحة)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "سرعة التحريك التلقائي", + "tooltip": "السرعة القصوى عند التحريك التلقائي بسحب المؤشر إلى حافة اللوحة. اضبطها على 0 لتعطيل التحريك التلقائي." + }, "Comfy_Graph_CanvasInfo": { "name": "عرض معلومات اللوحة في الزاوية السفلى اليسرى (الإطارات في الثانية، إلخ)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "عرض تحذير النماذج المفقودة" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "عرض تحذير العقد المفقودة" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "ترتيب معرفات العقد عند حفظ سير العمل" }, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 3a7db18cb8..aa28271914 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -34,6 +34,8 @@ "imageLightbox": "Image preview", "imagePreview": "Image preview - Use arrow keys to navigate between images", "videoPreview": "Video preview - Use arrow keys to navigate between videos", + "viewGrid": "Grid view", + "imageGallery": "image gallery", "galleryImage": "Gallery image", "galleryThumbnail": "Gallery thumbnail", "previousImage": "Previous image", @@ -277,8 +279,7 @@ "clearAll": "Clear all", "copyURL": "Copy URL", "releaseTitle": "{package} {version} Release", - "itemSelected": "{selectedCount} item selected", - "itemsSelected": "{selectedCount} items selected", + "itemsSelected": "No items selected | {count} item selected | {count} items selected", "multiSelectDropdown": "Multi-select dropdown", "singleSelectDropdown": "Single-select dropdown", "progressCountOf": "of", @@ -344,9 +345,13 @@ "frameNodes": "Frame Nodes", "listening": "Listening...", "ready": "Ready", + "play": "Play", + "pause": "Pause", "playPause": "Play/Pause", "playRecording": "Play Recording", "playing": "Playing", + "skipToStart": "Skip to Start", + "skipToEnd": "Skip to End", "stopPlayback": "Stop Playback", "playbackSpeed": "Playback Speed", "volume": "Volume", @@ -1718,6 +1723,7 @@ "photomaker": "photomaker", "PixVerse": "PixVerse", "primitive": "primitive", + "Quiver": "Quiver", "Recraft": "Recraft", "edit_models": "edit_models", "Reve": "Reve", @@ -1762,6 +1768,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "CONDITIONING", "CONTROL_NET": "CONTROL_NET", + "CURVE": "CURVE", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -1775,6 +1782,7 @@ "GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES", "GLIGEN": "GLIGEN", "GUIDER": "GUIDER", + "HISTOGRAM": "HISTOGRAM", "HOOK_KEYFRAMES": "HOOK_KEYFRAMES", "HOOKS": "HOOKS", "IMAGE": "IMAGE", @@ -1882,7 +1890,9 @@ "loadWorkflowTitle": "Loading aborted due to error reloading workflow data", "noStackTrace": "No stacktrace available", "extensionFileHint": "This may be due to the following script", - "promptExecutionError": "Prompt execution failed" + "promptExecutionError": "Prompt execution failed", + "accessRestrictedTitle": "Access Restricted", + "accessRestrictedMessage": "Your account is not authorized for this feature." }, "apiNodesSignInDialog": { "title": "Sign In Required to Use API Nodes", @@ -2287,7 +2297,12 @@ "topupTimeout": "Top-up verification timed out" }, "subscription": { - "chooseBestPlanWorkspace": "Choose the best plan for your workspace", + "plansForWorkspace": "Plans for {workspace}", + "personalWorkspace": "Personal Workspace", + "teamWorkspace": "Team Workspace", + "soloUseOnly": "Solo use only", + "needTeamWorkspace": "Need team workspace?", + "inviteUpTo": "Invite up to", "title": "Subscription", "titleUnsubscribed": "Subscribe to Comfy Cloud", "comfyCloud": "Comfy Cloud", @@ -2599,6 +2614,18 @@ "failedToFetchWorkspaces": "Failed to load workspaces" } }, + "teamWorkspacesDialog": { + "title": "Team Workspaces", + "subtitle": "Switch to an existing one or create a new workspace", + "subtitleNoWorkspaces": "Create a new team workspace to share credits", + "confirmCallbackFailed": "Workspace created but setup incomplete", + "yourTeamWorkspaces": "Your team workspaces", + "switch": "Switch", + "newWorkspace": "New workspace", + "namePlaceholder": "e.g. Marketing Team", + "createWorkspace": "Create workspace", + "nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation." + }, "workspaceSwitcher": { "switchWorkspace": "Switch workspace", "subscribe": "Subscribe", @@ -2606,7 +2633,8 @@ "roleOwner": "Owner", "roleMember": "Member", "createWorkspace": "Create new workspace", - "maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one." + "maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.", + "failedToSwitch": "Failed to switch workspace" }, "selectionToolbox": { "executeButton": { @@ -3141,6 +3169,7 @@ }, "comfyHubPublish": { "title": "Publish to ComfyHub", + "unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.", "stepDescribe": "Describe your workflow", "stepExamples": "Add output examples", "stepFinish": "Finish publishing", @@ -3148,12 +3177,6 @@ "workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search", "workflowDescription": "Workflow description", "workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.", - "workflowType": "Workflow type", - "workflowTypePlaceholder": "Select the type", - "workflowTypeImageGeneration": "Image generation", - "workflowTypeVideoGeneration": "Video generation", - "workflowTypeUpscaling": "Upscaling", - "workflowTypeEditing": "Editing", "tags": "Tags", "tagsDescription": "Select tags so people can find your workflow faster", "tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap", @@ -3180,11 +3203,17 @@ "examplesDescription": "Add up to {total} additional sample images", "uploadAnImage": "Click to browse or drag an image", "uploadExampleImage": "Upload example image", + "removeExampleImage": "Remove example image", "exampleImage": "Example image {index}", + "exampleImagePosition": "Example image {index} of {total}", "videoPreview": "Video thumbnail preview", "maxExamples": "You can select up to {max} examples", + "shareAs": "Share as", + "additionalInfo": "Additional information", "createProfileToPublish": "Create a profile to publish to ComfyHub", - "createProfileCta": "Create a profile" + "createProfileCta": "Create a profile", + "publishFailedTitle": "Publish failed", + "publishFailedDescription": "Something went wrong while publishing your workflow. Please try again." }, "comfyHubProfile": { "checkingAccess": "Checking your publishing access...", @@ -3203,6 +3232,7 @@ "namePlaceholder": "Enter your name here", "usernameLabel": "Your username (required)", "usernamePlaceholder": "@", + "usernameError": "3–42 lowercase alphanumeric characters and hyphens, must start and end with a letter or number", "descriptionLabel": "Your description", "descriptionPlaceholder": "Tell the community about yourself...", "createProfile": "Create profile", @@ -3230,6 +3260,14 @@ "desc": "– More flexible workflows, powerful new widgets, built for extensibility", "tryItOut": "Try it out" }, + "appBuilder": { + "vueNodeSwitch": { + "title": "Switched over to Nodes 2.0", + "content": "For the best experience, App builder uses Nodes 2.0. You can switch back after building the app from the main menu.", + "dontShowAgain": "Don't show again", + "dismiss": "Dismiss" + } + }, "vueNodesMigration": { "message": "Prefer the legacy design?", "button": "Switch back" @@ -3550,7 +3588,15 @@ }, "errorOverlay": { "errorCount": "{count} ERROR | {count} ERRORS", - "seeErrors": "See Errors" + "seeErrors": "See Errors", + "showMissingNodes": "Show missing nodes", + "showMissingModels": "Show missing models", + "showSwapNodes": "Show swap nodes", + "showMissingMedia": "Show missing inputs", + "missingNodes": "Some nodes are missing and need to be installed", + "missingModels": "{count} required model is missing | {count} required models are missing", + "swapNodes": "Some nodes can be replaced with alternatives", + "missingMedia": "Some nodes are missing required inputs" }, "help": { "recentReleases": "Recent releases", @@ -3730,5 +3776,18 @@ "footer": "ComfyUI stays free and open source. Cloud is optional.", "continueLocally": "Continue Locally", "exploreCloud": "Try Cloud for Free" + }, + "execution": { + "generating": "Generating…", + "saving": "Saving…", + "loading": "Loading…", + "encoding": "Encoding…", + "decoding": "Decoding…", + "processing": "Processing…", + "resizing": "Resizing…", + "generatingVideo": "Generating video…", + "training": "Training…", + "processingVideo": "Processing video…", + "running": "Running…" } } diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index 8dcd67a6a0..a2cf8310ea 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Number Convert", + "inputs": { + "value": { + "name": "value" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Switch", "inputs": { @@ -1767,6 +1783,14 @@ "freenoise": { "name": "freenoise", "tooltip": "Whether to apply FreeNoise noise shuffling, improves window blending." + }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "List of latent indices to retain in the conditioning tensors for each window, for example setting this to '0' will use the initial start image for each window." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Whether to split multiple conditionings (created by ConditionCombine) to each window based on region index." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Curve Editor", + "inputs": { + "curve": { + "name": "curve" + }, + "histogram": { + "name": "histogram" + } + }, + "outputs": { + "0": { + "name": "curve", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Custom Combo", "inputs": { @@ -3715,6 +3756,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "First image from the model's thinking process. Only available with thinking_level HIGH and IMAGE+TEXT modality." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "display_name": "Grok Video Extend", + "description": "Extend an existing video with a seamless continuation based on a text prompt.", + "inputs": { + "prompt": { + "name": "prompt", + "tooltip": "Text description of what should happen next in the video." + }, + "video": { + "name": "video", + "tooltip": "Source video to extend. MP4 format, 2-15 seconds." + }, + "model": { + "name": "model", + "tooltip": "The model to use for video extension." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "display_name": "Grok Video", "description": "Generate video from a prompt or an image", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "display_name": "Grok Reference-to-Video", + "description": "Generate video guided by reference images as style and content references.", + "inputs": { + "prompt": { + "name": "prompt", + "tooltip": "Text description of the desired video." + }, + "model": { + "name": "model", + "tooltip": "The model to use for video generation." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_aspect_ratio": { + "name": "aspect_ratio" + }, + "model_duration": { + "name": "duration" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "Grow Mask", "inputs": { @@ -7611,6 +7724,54 @@ } } }, + "LTXVReferenceAudio": { + "display_name": "LTXV Reference Audio (ID-LoRA)", + "description": "Set reference audio for ID-LoRA speaker identity transfer. Encodes a reference audio clip into the conditioning and optionally patches the model with identity guidance (extra forward pass without reference, amplifying the speaker identity effect).", + "inputs": { + "model": { + "name": "model" + }, + "positive": { + "name": "positive" + }, + "negative": { + "name": "negative" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "Reference audio clip whose speaker identity to transfer. ~5 seconds recommended (training duration). Shorter or longer clips may degrade voice identity transfer." + }, + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE for encoding." + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "Strength of identity guidance. Runs an extra forward pass without reference each step to amplify speaker identity. Set to 0 to disable (no extra pass)." + }, + "start_percent": { + "name": "start_percent", + "tooltip": "Start of the sigma range where identity guidance is active." + }, + "end_percent": { + "name": "end_percent", + "tooltip": "End of the sigma range where identity guidance is active." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVScheduler", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "display_name": "Quiver Image to SVG", + "description": "Vectorize a raster image into SVG using Quiver AI.", + "inputs": { + "image": { + "name": "image", + "tooltip": "Input image to vectorize." + }, + "auto_crop": { + "name": "auto_crop", + "tooltip": "Automatically crop to the dominant subject." + }, + "model": { + "name": "model", + "tooltip": "Model to use for SVG vectorization." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "display_name": "Quiver Text to SVG", + "description": "Generate an SVG from a text prompt using Quiver AI.", + "inputs": { + "prompt": { + "name": "prompt", + "tooltip": "Text description of the desired SVG output." + }, + "model": { + "name": "model", + "tooltip": "Model to use for SVG generation." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed." + }, + "instructions": { + "name": "instructions", + "tooltip": "Additional style or formatting guidance." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "Up to 4 reference images to guide the generation." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15664,9 +15910,16 @@ "image": { "name": "image" }, + "thinking": { + "name": "thinking", + "tooltip": "Operate in thinking mode if the model supports it." + }, "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "repetition_penalty" }, @@ -15708,9 +15961,16 @@ "image": { "name": "image" }, + "thinking": { + "name": "thinking", + "tooltip": "Operate in thinking mode if the model supports it." + }, "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "repetition_penalty" }, @@ -15979,6 +16239,10 @@ "name": "lora_dtype", "tooltip": "The dtype to use for lora." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "When using training_dtype 'none' and training on quantized model, doing backward with quantized matmul when enabled." + }, "algorithm": { "name": "algorithm", "tooltip": "The algorithm to use for training." diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index fbb17e00b0..22ccf5fdfd 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -25,6 +25,10 @@ "custom": "custom" } }, + "Comfy_Appearance_DisableAnimations": { + "name": "Disable animations", + "tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation." + }, "Comfy_Canvas_BackgroundImage": { "name": "Canvas background image", "tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button." @@ -95,6 +99,10 @@ "name": "Float widget rounding decimal places [0 = auto].", "tooltip": "(requires page reload)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Auto-pan speed", + "tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning." + }, "Comfy_Graph_CanvasInfo": { "name": "Show canvas info on bottom left corner (fps, etc.)" }, @@ -120,10 +128,6 @@ "name": "Live selection", "tooltip": "When enabled, nodes are selected/deselected in real-time as you drag the selection rectangle, similar to other design tools." }, - "Comfy_Graph_AutoPanSpeed": { - "name": "Auto-pan speed", - "tooltip": "Maximum speed when auto-panning by dragging to the canvas edge. Set to 0 to disable auto-panning." - }, "Comfy_Graph_ZoomSpeed": { "name": "Canvas zoom speed" }, diff --git a/src/locales/es/main.json b/src/locales/es/main.json index c0188f54f0..53a568c7cb 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -15,6 +15,14 @@ "message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.", "title": "Se requiere iniciar sesión para usar los nodos de API" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "Para la mejor experiencia, el constructor de aplicaciones utiliza Nodes 2.0. Puedes volver después de construir la aplicación desde el menú principal.", + "dismiss": "Descartar", + "dontShowAgain": "No mostrar de nuevo", + "title": "Cambiado a Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "Todo {category}", "allModels": "Todos los modelos", @@ -556,19 +564,26 @@ "uploadCover": "+ Subir una portada", "uploadProfilePicture": "+ Subir una foto de perfil", "uploadWorkflowButton": "Subir mi flujo de trabajo", + "usernameError": "De 3 a 42 caracteres alfanuméricos en minúsculas y guiones, debe comenzar y terminar con una letra o número", "usernameLabel": "Tu nombre de usuario (requerido)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "Información adicional", "back": "Atrás", "createProfileCta": "Crear un perfil", "createProfileToPublish": "Crea un perfil para publicar en ComfyHub", "exampleImage": "Imagen de ejemplo {index}", + "exampleImagePosition": "Imagen de ejemplo {index} de {total}", "examplesDescription": "Agrega hasta {total} imágenes de ejemplo adicionales", "maxExamples": "Puedes seleccionar hasta {max} ejemplos", "next": "Siguiente", "publishButton": "Publicar en ComfyHub", + "publishFailedDescription": "Ocurrió un error al publicar tu flujo de trabajo. Por favor, inténtalo de nuevo.", + "publishFailedTitle": "Error al publicar", + "removeExampleImage": "Eliminar imagen de ejemplo", "selectAThumbnail": "Selecciona una miniatura", + "shareAs": "Compartir como", "showLessTags": "Mostrar menos...", "showMoreTags": "Mostrar más...", "stepDescribe": "Describe tu flujo de trabajo", @@ -583,6 +598,7 @@ "thumbnailPreview": "Vista previa de la miniatura", "thumbnailVideo": "Video", "title": "Publicar en ComfyHub", + "unsavedDescription": "Debes guardar tu flujo de trabajo antes de publicarlo en ComfyHub. Guárdalo ahora para continuar.", "uploadAnImage": "Haz clic para buscar o arrastra una imagen", "uploadComparison": "Subir antes y después", "uploadComparisonAfterPrompt": "Después", @@ -598,13 +614,7 @@ "workflowDescription": "Descripción del flujo de trabajo", "workflowDescriptionPlaceholder": "¿Qué hace que tu flujo de trabajo sea emocionante y especial? Sé específico para que las personas sepan qué esperar.", "workflowName": "Nombre del flujo de trabajo", - "workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar", - "workflowType": "Tipo de flujo de trabajo", - "workflowTypeEditing": "Edición", - "workflowTypeImageGeneration": "Generación de imágenes", - "workflowTypePlaceholder": "Selecciona el tipo", - "workflowTypeUpscaling": "Aumento de resolución", - "workflowTypeVideoGeneration": "Generación de video" + "workflowNamePlaceholder": "Consejo: ingresa un nombre descriptivo y fácil de buscar" }, "commands": { "clear": "Limpiar flujo de trabajo", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "ACONDICIONAMIENTO", "CONTROL_NET": "RED_DE_CONTROL", + "CURVE": "CURVA", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "ARCHIVO_3D", "FILE_3D_FBX": "ARCHIVO_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "ARCHIVOS_ENTRADA_GEMINI", "GLIGEN": "GLIGEN", "GUIDER": "GUÍA", + "HISTOGRAM": "HISTOGRAMA", "HOOKS": "GANCHOS", "HOOK_KEYFRAMES": "GANCHO_FOTOGRAMAS_CLAVE", "IMAGE": "IMAGEN", @@ -876,6 +888,8 @@ "resume": "Reanudar descarga" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "Ocurrió un error", "extensionFileHint": "Esto puede deberse al siguiente script", "loadWorkflowTitle": "La carga se interrumpió debido a un error al recargar los datos del flujo de trabajo", @@ -924,6 +938,19 @@ "textToImage": "Texto a imagen", "textToVideo": "Texto a video" }, + "execution": { + "decoding": "Decodificando…", + "encoding": "Codificando…", + "generating": "Generando…", + "generatingVideo": "Generando video…", + "loading": "Cargando…", + "processing": "Procesando…", + "processingVideo": "Procesando video…", + "resizing": "Redimensionando…", + "running": "Ejecutando…", + "saving": "Guardando…", + "training": "Entrenando…" + }, "exportToast": { "allExportsCompleted": "Todas las exportaciones completadas", "downloadExport": "Descargar exportación", @@ -1064,11 +1091,14 @@ "filterBy": "Filtrar por:", "filterByType": "Filtrar por {type}...", "findIssues": "Encontrar problemas", + "findOnGithub": "Buscar en GitHub", "frameNodes": "Enmarcar Nodos", "frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.", "frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.", + "gallery": "Galería", "galleryImage": "Imagen de galería", "galleryThumbnail": "Miniatura de galería", + "getHelpAction": "Obtener ayuda", "goToNode": "Ir al nodo", "graphNavigation": "Navegación de gráficos", "halfSpeed": "0.5x", @@ -1077,6 +1107,8 @@ "icon": "Icono", "imageDoesNotExist": "La imagen no existe", "imageFailedToLoad": "Falló la carga de la imagen", + "imageGallery": "galería de imágenes", + "imageLightbox": "Vista previa de imagen", "imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes", "imageUrl": "URL de la imagen", "import": "Importar", @@ -1090,13 +1122,38 @@ "installed": "Instalado", "installing": "Instalando", "interrupted": "Interrumpido", - "itemSelected": "{selectedCount} elemento seleccionado", "itemsCopiedToClipboard": "Elementos copiados al portapapeles", "itemsSelected": "{selectedCount} elementos seleccionados", "job": "Tarea", "jobIdCopied": "ID de trabajo copiado al portapapeles", "keybinding": "Combinación de teclas", "keybindingAlreadyExists": "La combinación de teclas ya existe en", + "keybindingPresets": { + "default": "Preajuste predeterminado", + "deletePreset": "Eliminar preajuste", + "deletePresetFailed": "No se pudo eliminar el preajuste \"{name}\"", + "deletePresetTitle": "¿Eliminar el preajuste actual?", + "deletePresetWarning": "Este preajuste será eliminado. Esto no se puede deshacer.", + "discardAndSwitch": "Descartar y cambiar", + "exportPreset": "Exportar preajuste", + "importKeybindingPreset": "Importar preajuste de atajos", + "importPreset": "Importar preajuste", + "invalidPresetFile": "El archivo de preajuste debe ser un JSON válido exportado desde ComfyUI", + "invalidPresetName": "El nombre del preajuste no debe estar vacío, ser \"default\", comenzar con un punto, contener separadores de ruta o terminar en .json", + "loadPresetFailed": "No se pudo cargar el preajuste \"{name}\"", + "overwritePresetMessage": "Ya existe un preajuste llamado \"{name}\". ¿Sobrescribirlo?", + "overwritePresetTitle": "Sobrescribir preajuste", + "presetDeleted": "Preajuste \"{name}\" eliminado", + "presetImported": "Preajuste de atajos importado", + "presetNamePrompt": "Introduce un nombre para el preajuste", + "presetSaved": "Preajuste \"{name}\" guardado", + "resetToDefault": "Restablecer a predeterminado", + "saveAndSwitch": "Guardar y cambiar", + "saveAsNewPreset": "Guardar como nuevo preajuste", + "saveChanges": "Guardar cambios", + "unsavedChangesMessage": "Tienes cambios no guardados que se perderán si cambias sin guardar.", + "unsavedChangesTo": "Cambios no guardados en {name}" + }, "keybindings": "Atajos de teclado", "learnMore": "Aprende más", "listening": "Escuchando...", @@ -1153,6 +1210,8 @@ "output": "Salida", "overwrite": "Sobrescribir", "partner": "Socio", + "pause": "Pausar", + "play": "Reproducir", "playPause": "Reproducir/Pausar", "playRecording": "Reproducir grabación", "playbackSpeed": "Velocidad de reproducción", @@ -1160,6 +1219,7 @@ "preloadError": "No se pudo cargar un recurso necesario. Por favor, recarga la página.", "preloadErrorTitle": "Error de carga", "preview": "VISTA PREVIA", + "previous": "Anterior", "previousImage": "Imagen anterior", "profile": "Perfil", "progressCountOf": "de", @@ -1234,6 +1294,8 @@ "showReport": "Mostrar informe", "showRightPanel": "Mostrar panel derecho", "singleSelectDropdown": "Menú desplegable de selección única", + "skipToEnd": "Ir al final", + "skipToStart": "Ir al inicio", "sort": "Ordenar", "source": "Fuente", "startRecording": "Iniciar grabación", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.", "videoFailedToLoad": "Falló la carga del video", "videoPreview": "Vista previa de video - Usa las teclas de flecha para navegar entre videos", + "viewGrid": "Vista de cuadrícula", "viewImageOfTotal": "Ver imagen {index} de {total}", "viewVideoOfTotal": "Ver video {index} de {total}", "volume": "Volumen", @@ -1832,6 +1895,7 @@ "mirrorVertical": "Espejar verticalmente", "negative": "Negativo", "opacity": "Opacidad", + "openMaskEditor": "Abrir en el editor de máscaras", "paintBucketSettings": "Configuración del bote de pintura", "paintLayer": "Capa de pintura", "redo": "Rehacer", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "Las entradas que marques como favoritas aparecerán aquí", "favoritesNoneHint": "En la pestaña Parámetros, haz clic en {moreIcon} en cualquier entrada para añadirla aquí", "favoritesNoneTooltip": "Marca widgets con estrella para acceder rápidamente sin seleccionar nodos", + "findOnGithubTooltip": "Buscar problemas relacionados en GitHub", + "getHelpTooltip": "Informa de este error y te ayudaremos a resolverlo", "globalSettings": { "canvas": "LIENZO", "connectionLinks": "ENLACES DE CONEXIÓN", @@ -3073,7 +3140,6 @@ "title": "Tu suscripción ha sido cancelada" }, "changeTo": "Cambiar a {plan}", - "chooseBestPlanWorkspace": "Elige el mejor plan para tu espacio de trabajo", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo de Comfy Cloud", "contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?", + "inviteUpTo": "Invita hasta", "invoiceHistory": "Historial de facturas", "learnMore": "Más información", "managePayment": "Gestionar pago", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "Créditos mensuales / miembro", "monthlyCreditsRollover": "Estos créditos se transferirán al próximo mes", "mostPopular": "Más popular", + "needTeamWorkspace": "¿Necesitas un espacio de trabajo en equipo?", "nextBillingCycle": "próximo ciclo de facturación", "nextMonthInvoice": "Factura del próximo mes", "partnerNodesBalance": "Saldo de créditos de \"Nodos de Partners\"", "partnerNodesCredits": "Créditos de Nodos de Socio", "partnerNodesDescription": "Para ejecutar modelos comerciales/propietarios", "perMonth": "USD / mes", + "personalWorkspace": "Espacio de trabajo personal", "plansAndPricing": "Planes y precios", + "plansForWorkspace": "Planes para {workspace}", "prepaidCreditsInfo": "Créditos comprados por separado que no expiran", "prepaidDescription": "Créditos prepagados", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "Volver a suscribirse", "resubscribeSuccess": "¡Suscripción reactivada correctamente!", "resubscribeTo": "Volver a suscribirse a {plan}", + "soloUseOnly": "Solo para uso individual", "subscribeForMore": "Mejorar", "subscribeNow": "Suscribirse Ahora", "subscribeTo": "Suscribirse a {plan}", @@ -3177,6 +3248,7 @@ "subscribeToRun": "Suscribirse", "subscribeToRunFull": "Suscribirse a Ejecutar", "subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube", + "teamWorkspace": "Espacio de trabajo en equipo", "tierNameYearly": "{name} Anual", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "Duplicar pestaña", "removeFromBookmarks": "Eliminar de marcadores" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "Espacio de trabajo creado pero la configuración está incompleta", + "createWorkspace": "Crear espacio de trabajo", + "namePlaceholder": "ej. Equipo de Marketing", + "nameValidationError": "El nombre debe tener entre 1 y 50 caracteres usando letras, números, espacios o signos de puntuación comunes.", + "newWorkspace": "Nuevo espacio de trabajo", + "subtitle": "Cambia a uno existente o crea un nuevo espacio de trabajo", + "subtitleNoWorkspaces": "Crea un nuevo espacio de trabajo en equipo para compartir créditos", + "switch": "Cambiar", + "title": "Espacios de trabajo en equipo", + "yourTeamWorkspaces": "Tus espacios de trabajo en equipo" + }, "templateWidgets": { "sort": { "searchPlaceholder": "Buscar..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "Crear nuevo espacio de trabajo", + "failedToSwitch": "No se pudo cambiar el espacio de trabajo", "maxWorkspacesReached": "Solo puedes ser propietario de 10 espacios de trabajo. Elimina uno para crear uno nuevo.", "personal": "Personal", "roleMember": "Miembro", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 3ec50bd60d..8f2885ad8a 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Conversión de número", + "inputs": { + "value": { + "name": "valor" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Interruptor", "inputs": { @@ -1736,6 +1752,10 @@ "name": "bucle_cerrado", "tooltip": "Si se debe cerrar el bucle de la ventana de contexto; solo aplicable a programaciones en bucle." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "Lista de índices latentes que se conservarán en los tensores de condicionamiento para cada ventana; por ejemplo, si se establece en '0', se usará la imagen inicial de inicio para cada ventana." + }, "context_length": { "name": "longitud_contexto", "tooltip": "La longitud de la ventana de contexto." @@ -1767,6 +1787,10 @@ "model": { "name": "modelo", "tooltip": "El modelo al que aplicar ventanas de contexto durante el muestreo." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Indica si se deben dividir múltiples condicionamientos (creados por ConditionCombine) en cada ventana según el índice de la región." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Editor de curvas", + "inputs": { + "curve": { + "name": "curva" + }, + "histogram": { + "name": "histograma" + } + }, + "outputs": { + "0": { + "name": "curva", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Combinación personalizada", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "Primera imagen del proceso de pensamiento del modelo. Solo disponible con nivel de pensamiento ALTO y modalidad IMAGEN+TEXTO." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "Extiende un video existente con una continuación fluida basada en un prompt de texto.", + "display_name": "Extender video Grok", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "model": { + "name": "modelo", + "tooltip": "El modelo a utilizar para la extensión de video." + }, + "model_duration": { + "name": "duración" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descripción en texto de lo que debe suceder a continuación en el video." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla." + }, + "video": { + "name": "video", + "tooltip": "Video fuente a extender. Formato MP4, 2-15 segundos." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "Genera video a partir de una indicación o una imagen", "display_name": "Video Grok", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "Genera video guiado por imágenes de referencia como referencias de estilo y contenido.", + "display_name": "Referencia a video Grok", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "model": { + "name": "modelo", + "tooltip": "El modelo a utilizar para la generación de video." + }, + "model_aspect_ratio": { + "name": "relación de aspecto" + }, + "model_duration": { + "name": "duración" + }, + "model_resolution": { + "name": "resolución" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descripción en texto del video deseado." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "GrowMask", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "Establece el audio de referencia para la transferencia de identidad de locutor con ID-LoRA. Codifica un clip de audio de referencia en el condicionamiento y, opcionalmente, modifica el modelo con una guía de identidad (pase adicional sin referencia, amplificando el efecto de identidad del locutor).", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE para la codificación." + }, + "end_percent": { + "name": "porcentaje_fin", + "tooltip": "Fin del rango sigma donde la guía de identidad está activa." + }, + "identity_guidance_scale": { + "name": "escala_guía_identidad", + "tooltip": "Intensidad de la guía de identidad. Ejecuta un pase adicional sin referencia en cada paso para amplificar la identidad del locutor. Establece en 0 para desactivar (sin pase adicional)." + }, + "model": { + "name": "modelo" + }, + "negative": { + "name": "negativo" + }, + "positive": { + "name": "positivo" + }, + "reference_audio": { + "name": "audio_referencia", + "tooltip": "Clip de audio de referencia cuya identidad de locutor se va a transferir. Se recomienda ~5 segundos (duración de entrenamiento). Clips más cortos o largos pueden degradar la transferencia de identidad de voz." + }, + "start_percent": { + "name": "porcentaje_inicio", + "tooltip": "Inicio del rango sigma donde la guía de identidad está activa." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positivo", + "tooltip": null + }, + "2": { + "name": "negativo", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVProgramador", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Vectoriza una imagen ráster a SVG usando Quiver AI.", + "display_name": "Quiver Imagen a SVG", + "inputs": { + "auto_crop": { + "name": "recorte_automático", + "tooltip": "Recorta automáticamente al sujeto dominante." + }, + "control_after_generate": { + "name": "control después de generar" + }, + "image": { + "name": "imagen", + "tooltip": "Imagen de entrada para vectorizar." + }, + "model": { + "name": "modelo", + "tooltip": "Modelo a utilizar para la vectorización SVG." + }, + "model_presence_penalty": { + "name": "penalización_de_presencia" + }, + "model_target_size": { + "name": "tamaño_objetivo" + }, + "model_temperature": { + "name": "temperatura" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Genera un SVG a partir de un prompt de texto usando Quiver AI.", + "display_name": "Quiver Texto a SVG", + "inputs": { + "control_after_generate": { + "name": "control después de generar" + }, + "instructions": { + "name": "instrucciones", + "tooltip": "Guía adicional de estilo o formato." + }, + "model": { + "name": "modelo", + "tooltip": "Modelo a utilizar para la generación de SVG." + }, + "model_presence_penalty": { + "name": "penalización_de_presencia" + }, + "model_temperature": { + "name": "temperatura" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descripción en texto del SVG deseado." + }, + "reference_images": { + "name": "imágenes_de_referencia", + "tooltip": "Hasta 4 imágenes de referencia para guiar la generación." + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "penalización_de_presencia" + }, "sampling_mode_repetition_penalty": { "name": "penalización_por_repetición" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "pensando", + "tooltip": "Operar en modo de pensamiento si el modelo lo permite." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "penalización_de_presencia" + }, "sampling_mode_repetition_penalty": { "name": "penalización_por_repetición" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "pensando", + "tooltip": "Operar en modo de pensamiento si el modelo lo permite." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "positivo", "tooltip": "El condicionamiento positivo a utilizar para el entrenamiento." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "Al usar 'training_dtype' como 'none' y entrenar en un modelo cuantizado, realiza el retropropagado con multiplicación de matrices cuantizadas cuando está activado." + }, "rank": { "name": "rango", "tooltip": "El rango de las capas LoRA." diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 0176d8f119..ea53b9073a 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "Personalizado: Reemplace la barra de título del sistema con el menú superior de ComfyUI" }, + "Comfy_Appearance_DisableAnimations": { + "name": "Desactivar animaciones", + "tooltip": "Desactiva la mayoría de las animaciones y transiciones CSS. Acelera la inferencia cuando la GPU de pantalla también se utiliza para la generación." + }, "Comfy_Canvas_BackgroundImage": { "name": "Imagen de fondo del lienzo", "tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla." @@ -95,6 +99,10 @@ "name": "Decimales de redondeo del widget flotante [0 = automático].", "tooltip": "(requiere recargar la página)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Velocidad de auto-desplazamiento", + "tooltip": "Velocidad máxima al auto-desplazar arrastrando hacia el borde del lienzo. Establece en 0 para desactivar el auto-desplazamiento." + }, "Comfy_Graph_CanvasInfo": { "name": "Mostrar información del lienzo en la esquina inferior izquierda (fps, etc.)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "Mostrar advertencia de modelos faltantes" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "Mostrar advertencia de nodos faltantes" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "Ordenar IDs de nodos al guardar el flujo de trabajo" }, diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index 05d833c8e6..755dfb3693 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -15,6 +15,14 @@ "message": "این workflow شامل API Node است که برای اجرا نیاز به ورود به حساب کاربری دارد.", "title": "ورود برای استفاده از API Nodeها لازم است" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "برای بهترین تجربه، سازنده اپلیکیشن از Nodes 2.0 استفاده می‌کند. پس از ساخت اپلیکیشن می‌توانید از منوی اصلی به نسخه قبلی بازگردید.", + "dismiss": "بستن", + "dontShowAgain": "دیگر نمایش نده", + "title": "به Nodes 2.0 منتقل شدید" + } + }, "assetBrowser": { "allCategory": "همه {category}", "allModels": "همه مدل‌ها", @@ -556,19 +564,26 @@ "uploadCover": "+ بارگذاری کاور", "uploadProfilePicture": "+ بارگذاری تصویر پروفایل", "uploadWorkflowButton": "بارگذاری جریان‌کار من", + "usernameError": "۳ تا ۴۲ کاراکتر حرفی یا عددی کوچک و خط تیره، باید با حرف یا عدد شروع و پایان یابد", "usernameLabel": "نام کاربری شما (الزامی)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "اطلاعات تکمیلی", "back": "بازگشت", "createProfileCta": "ساخت پروفایل", "createProfileToPublish": "برای انتشار در ComfyHub یک پروفایل بسازید", "exampleImage": "تصویر نمونه {index}", + "exampleImagePosition": "تصویر نمونه {index} از {total}", "examplesDescription": "تا {total} تصویر نمونه اضافی اضافه کنید", "maxExamples": "شما می‌توانید تا {max} نمونه انتخاب کنید", "next": "بعدی", "publishButton": "انتشار در ComfyHub", + "publishFailedDescription": "در هنگام انتشار گردش‌کار شما مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", + "publishFailedTitle": "انتشار ناموفق بود", + "removeExampleImage": "حذف تصویر نمونه", "selectAThumbnail": "یک تصویر بندانگشتی انتخاب کنید", + "shareAs": "اشتراک‌گذاری به عنوان", "showLessTags": "نمایش کمتر...", "showMoreTags": "نمایش بیشتر...", "stepDescribe": "شرح جریان‌کار خود را وارد کنید", @@ -583,6 +598,7 @@ "thumbnailPreview": "پیش‌نمایش بندانگشتی", "thumbnailVideo": "ویدیو", "title": "انتشار در ComfyHub", + "unsavedDescription": "شما باید گردش‌کار خود را قبل از انتشار در ComfyHub ذخیره کنید. اکنون ذخیره کنید تا ادامه دهید.", "uploadAnImage": "برای انتخاب کلیک کنید یا تصویر را بکشید", "uploadComparison": "بارگذاری قبل و بعد", "uploadComparisonAfterPrompt": "بعد", @@ -598,13 +614,7 @@ "workflowDescription": "توضیحات جریان‌کار", "workflowDescriptionPlaceholder": "چه چیزی جریان‌کار شما را هیجان‌انگیز و خاص می‌کند؟ مشخص توضیح دهید تا کاربران بدانند چه انتظاری داشته باشند.", "workflowName": "نام جریان‌کار", - "workflowNamePlaceholder": "نکته: یک نام توصیفی وارد کنید که به راحتی قابل جستجو باشد", - "workflowType": "نوع جریان‌کار", - "workflowTypeEditing": "ویرایش", - "workflowTypeImageGeneration": "تولید تصویر", - "workflowTypePlaceholder": "نوع را انتخاب کنید", - "workflowTypeUpscaling": "افزایش کیفیت", - "workflowTypeVideoGeneration": "تولید ویدیو" + "workflowNamePlaceholder": "نکته: یک نام توصیفی وارد کنید که به راحتی قابل جستجو باشد" }, "commands": { "clear": "پاک‌سازی workflow", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "Comfy MatchType V3", "CONDITIONING": "شرط‌گذاری", "CONTROL_NET": "controlnet", + "CURVE": "CURVE", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "فایل‌های ورودی Gemini", "GLIGEN": "GLIGEN", "GUIDER": "راهنما", + "HISTOGRAM": "HISTOGRAM", "HOOKS": "hookها", "HOOK_KEYFRAMES": "کلیدفریم‌های hook", "IMAGE": "تصویر", @@ -876,6 +888,8 @@ "resume": "ادامه دانلود" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "خطایی رخ داد", "extensionFileHint": "این ممکن است به دلیل اسکریپت زیر باشد", "loadWorkflowTitle": "بارگذاری به دلیل خطا در بارگذاری مجدد داده‌های workflow متوقف شد", @@ -924,6 +938,19 @@ "textToImage": "تبدیل متن به تصویر", "textToVideo": "تبدیل متن به ویدیو" }, + "execution": { + "decoding": "در حال رمزگشایی…", + "encoding": "در حال کدگذاری…", + "generating": "در حال تولید…", + "generatingVideo": "در حال تولید ویدیو…", + "loading": "در حال بارگذاری…", + "processing": "در حال پردازش…", + "processingVideo": "در حال پردازش ویدیو…", + "resizing": "در حال تغییر اندازه…", + "running": "در حال اجرا…", + "saving": "در حال ذخیره‌سازی…", + "training": "در حال آموزش…" + }, "exportToast": { "allExportsCompleted": "همه خروجی‌ها تکمیل شد", "downloadExport": "دانلود خروجی", @@ -1064,11 +1091,14 @@ "filterBy": "فیلتر بر اساس:", "filterByType": "فیلتر بر اساس {type}...", "findIssues": "یافتن مشکلات", + "findOnGithub": "یافتن در GitHub", "frameNodes": "قاب‌بندی nodeها", "frontendNewer": "نسخه فرانت‌اند {frontendVersion} ممکن است با نسخه بک‌اند {backendVersion} ناسازگار باشد.", "frontendOutdated": "نسخه فرانت‌اند {frontendVersion} قدیمی است. بک‌اند به نسخه {requiredVersion} یا بالاتر نیاز دارد.", + "gallery": "گالری", "galleryImage": "تصویر گالری", "galleryThumbnail": "تصویر بندانگشتی گالری", + "getHelpAction": "دریافت راهنما", "goToNode": "رفتن به node", "graphNavigation": "ناوبری گراف", "halfSpeed": "۰.۵x", @@ -1077,6 +1107,8 @@ "icon": "آیکون", "imageDoesNotExist": "تصویر وجود ندارد", "imageFailedToLoad": "بارگذاری تصویر ناموفق بود", + "imageGallery": "گالری تصاویر", + "imageLightbox": "پیش‌نمایش تصویر", "imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید", "imageUrl": "آدرس تصویر", "import": "وارد کردن", @@ -1090,13 +1122,38 @@ "installed": "نصب شده", "installing": "در حال نصب", "interrupted": "متوقف شده", - "itemSelected": "{selectedCount} مورد انتخاب شد", "itemsCopiedToClipboard": "موارد در کلیپ‌بورد کپی شدند", "itemsSelected": "{selectedCount} مورد انتخاب شدند", "job": "وظیفه", "jobIdCopied": "شناسه وظیفه در کلیپ‌بورد کپی شد", "keybinding": "کلید میانبر", "keybindingAlreadyExists": "کلید میانبر قبلاً وجود دارد در", + "keybindingPresets": { + "default": "پیش‌تنظیم پیش‌فرض", + "deletePreset": "حذف پیش‌تنظیم", + "deletePresetFailed": "حذف پیش‌تنظیم «{name}» ناموفق بود", + "deletePresetTitle": "پیش‌تنظیم فعلی حذف شود؟", + "deletePresetWarning": "این پیش‌تنظیم حذف خواهد شد. این عمل قابل بازگشت نیست.", + "discardAndSwitch": "رد کردن و جابجایی", + "exportPreset": "خروجی گرفتن از پیش‌تنظیم", + "importKeybindingPreset": "وارد کردن پیش‌تنظیم کلیدها", + "importPreset": "وارد کردن پیش‌تنظیم", + "invalidPresetFile": "فایل پیش‌تنظیم باید یک JSON معتبر باشد که از ComfyUI خروجی گرفته شده است", + "invalidPresetName": "نام پیش‌تنظیم نباید خالی، «default»، با نقطه شروع شود، شامل جداکننده مسیر باشد یا با ‎.json‎ پایان یابد", + "loadPresetFailed": "بارگذاری پیش‌تنظیم «{name}» ناموفق بود", + "overwritePresetMessage": "پیش‌تنظیمی با نام «{name}» وجود دارد. جایگزین شود؟", + "overwritePresetTitle": "جایگزینی پیش‌تنظیم", + "presetDeleted": "پیش‌تنظیم «{name}» حذف شد", + "presetImported": "پیش‌تنظیم کلیدها وارد شد", + "presetNamePrompt": "یک نام برای پیش‌تنظیم وارد کنید", + "presetSaved": "پیش‌تنظیم «{name}» ذخیره شد", + "resetToDefault": "بازنشانی به پیش‌فرض", + "saveAndSwitch": "ذخیره و جابجایی", + "saveAsNewPreset": "ذخیره به عنوان پیش‌تنظیم جدید", + "saveChanges": "ذخیره تغییرات", + "unsavedChangesMessage": "تغییرات ذخیره‌نشده‌ای دارید که در صورت جابجایی بدون ذخیره، از بین خواهند رفت.", + "unsavedChangesTo": "تغییرات ذخیره‌نشده برای {name}" + }, "keybindings": "کلیدهای میانبر", "learnMore": "اطلاعات بیشتر", "listening": "در حال گوش دادن...", @@ -1153,6 +1210,8 @@ "output": "خروجی", "overwrite": "جایگزینی", "partner": "همکار", + "pause": "توقف", + "play": "پخش", "playPause": "پخش/توقف", "playRecording": "پخش ضبط", "playbackSpeed": "سرعت پخش", @@ -1160,6 +1219,7 @@ "preloadError": "یک منبع مورد نیاز بارگذاری نشد. لطفاً صفحه را مجدداً بارگذاری کنید.", "preloadErrorTitle": "خطا در بارگذاری", "preview": "پیش‌نمایش", + "previous": "قبلی", "previousImage": "تصویر قبلی", "profile": "پروفایل", "progressCountOf": "از", @@ -1234,6 +1294,8 @@ "showReport": "نمایش گزارش", "showRightPanel": "نمایش پنل راست", "singleSelectDropdown": "لیست کشویی تک‌انتخابی", + "skipToEnd": "رفتن به انتها", + "skipToStart": "رفتن به ابتدا", "sort": "مرتب‌سازی", "source": "منبع", "startRecording": "شروع ضبط", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.", "videoFailedToLoad": "بارگذاری ویدیو ناموفق بود", "videoPreview": "پیش‌نمایش ویدیو - برای جابجایی بین ویدیوها از کلیدهای جهت‌دار استفاده کنید", + "viewGrid": "نمای شبکه‌ای", "viewImageOfTotal": "مشاهده تصویر {index} از {total}", "viewVideoOfTotal": "مشاهده ویدیو {index} از {total}", "volume": "حجم صدا", @@ -1832,6 +1895,7 @@ "mirrorVertical": "آینه عمودی", "negative": "نگاتیو", "opacity": "شفافیت", + "openMaskEditor": "باز کردن در Mask Editor", "paintBucketSettings": "تنظیمات سطل رنگ", "paintLayer": "لایه نقاشی", "redo": "بازانجام", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "ورودی‌هایی که به علاقه‌مندی‌ها اضافه کنید اینجا نمایش داده می‌شوند", "favoritesNoneHint": "در تب پارامترها، روی {moreIcon} هر ورودی کلیک کنید تا اینجا اضافه شود", "favoritesNoneTooltip": "برای دسترسی سریع، ویجت‌ها را ستاره‌دار کنید تا بدون انتخاب nodeها به آن‌ها دسترسی داشته باشید", + "findOnGithubTooltip": "جستجوی مشکلات مرتبط در GitHub", + "getHelpTooltip": "گزارش این خطا و دریافت راهنمایی برای رفع آن", "globalSettings": { "canvas": "canvas", "connectionLinks": "اتصالات", @@ -3085,7 +3152,6 @@ "title": "اشتراک شما لغو شده است" }, "changeTo": "تغییر به {plan}", - "chooseBestPlanWorkspace": "بهترین طرح را برای فضای کاری خود انتخاب کنید", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "لوگوی Comfy Cloud", "contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید", @@ -3115,6 +3181,7 @@ }, "gpuLabel": "RTX 6000 Pro (۹۶ گیگابایت VRAM)", "haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟", + "inviteUpTo": "دعوت تا سقف", "invoiceHistory": "تاریخچه فاکتورها", "learnMore": "اطلاعات بیشتر", "managePayment": "مدیریت پرداخت", @@ -3140,13 +3207,16 @@ "monthlyCreditsPerMemberLabel": "اعتبار ماهانه / هر عضو", "monthlyCreditsRollover": "این اعتبارها به ماه بعد منتقل می‌شوند", "mostPopular": "محبوب‌ترین", + "needTeamWorkspace": "به فضای کاری تیمی نیاز دارید؟", "nextBillingCycle": "چرخه صورتحساب بعدی", "nextMonthInvoice": "صورتحساب ماه آینده", "partnerNodesBalance": "اعتبار «Partner Nodes»", "partnerNodesCredits": "قیمت‌گذاری Partner Nodes", "partnerNodesDescription": "برای اجرای مدل‌های تجاری/اختصاصی", "perMonth": "/ ماه", + "personalWorkspace": "فضای کاری شخصی", "plansAndPricing": "طرح‌ها و قیمت‌ها", + "plansForWorkspace": "طرح‌ها برای {workspace}", "prepaidCreditsInfo": "اعتبارهای پیش‌پرداخت تا یک سال پس از تاریخ خرید منقضی می‌شوند.", "prepaidDescription": "اعتبارهای پیش‌پرداخت", "preview": { @@ -3182,6 +3252,7 @@ "resubscribe": "تمدید اشتراک", "resubscribeSuccess": "اشتراک با موفقیت فعال شد", "resubscribeTo": "تمدید اشتراک {plan}", + "soloUseOnly": "فقط برای استفاده فردی", "subscribeForMore": "ارتقاء", "subscribeNow": "هم‌اکنون اشتراک بگیرید", "subscribeTo": "اشتراک در {plan}", @@ -3189,6 +3260,7 @@ "subscribeToRun": "اشتراک", "subscribeToRunFull": "اشتراک برای اجرا", "subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.", + "teamWorkspace": "فضای کاری تیمی", "tierNameYearly": "{name} سالانه", "tiers": { "creator": { @@ -3240,6 +3312,18 @@ "duplicateTab": "ایجاد تب مشابه", "removeFromBookmarks": "حذف از نشانک‌ها" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "فضای کاری ایجاد شد اما راه‌اندازی کامل نشد", + "createWorkspace": "ایجاد فضای کاری", + "namePlaceholder": "مثلاً تیم بازاریابی", + "nameValidationError": "نام باید بین ۱ تا ۵۰ کاراکتر و شامل حروف، اعداد، فاصله یا علائم نگارشی رایج باشد.", + "newWorkspace": "فضای کاری جدید", + "subtitle": "به یکی از فضاهای موجود بروید یا فضای کاری جدیدی ایجاد کنید", + "subtitleNoWorkspaces": "برای اشتراک‌گذاری اعتبارها، فضای کاری تیمی جدیدی ایجاد کنید", + "switch": "تغییر", + "title": "فضاهای کاری تیمی", + "yourTeamWorkspaces": "فضاهای کاری تیمی شما" + }, "templateWidgets": { "sort": { "searchPlaceholder": "جستجو..." @@ -3626,6 +3710,7 @@ }, "workspaceSwitcher": { "createWorkspace": "ایجاد محیط کاری جدید", + "failedToSwitch": "تغییر فضای کاری ناموفق بود", "maxWorkspacesReached": "شما فقط می‌توانید مالک ۱۰ محیط کاری باشید. برای ایجاد محیط کاری جدید، یکی را حذف کنید.", "personal": "شخصی", "roleMember": "عضو", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 20559332ca..882f22b705 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "تبدیل عدد", + "inputs": { + "value": { + "name": "مقدار" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "سوئیچ", "inputs": { @@ -1736,6 +1752,10 @@ "name": "حلقه بسته", "tooltip": "آیا حلقه پنجره زمینه بسته شود؛ فقط برای برنامه‌ریزی حلقه‌ای قابل استفاده است." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "فهرست اندیس‌های latent که باید در تنسورهای شرطی برای هر پنجره حفظ شوند؛ برای مثال، اگر این مقدار را '۰' قرار دهید، تصویر ابتدایی برای هر پنجره استفاده خواهد شد." + }, "context_length": { "name": "طول پنجره زمینه", "tooltip": "طول پنجره زمینه." @@ -1767,6 +1787,10 @@ "model": { "name": "مدل", "tooltip": "مدلی که پنجره‌های زمینه هنگام نمونه‌گیری بر آن اعمال می‌شود." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "آیا شرط‌های متعدد (ایجاد شده توسط ConditionCombine) بر اساس اندیس ناحیه به هر پنجره تقسیم شوند یا خیر." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "ویرایشگر منحنی", + "inputs": { + "curve": { + "name": "منحنی" + }, + "histogram": { + "name": "هیستوگرام" + } + }, + "outputs": { + "0": { + "name": "منحنی", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "ترکیب سفارشی", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "اولین تصویر از فرایند تفکر مدل. فقط در حالت thinking_level بالا و مدالیته IMAGE+TEXT در دسترس است." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "گسترش یک ویدیوی موجود با ادامه‌ای یکپارچه بر اساس یک متن راهنما.", + "display_name": "گسترش ویدیو Grok", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل", + "tooltip": "مدلی که برای گسترش ویدیو استفاده می‌شود." + }, + "model_duration": { + "name": "مدت زمان" + }, + "prompt": { + "name": "راهنمای متنی", + "tooltip": "توضیح متنی درباره آنچه باید در ادامه ویدیو رخ دهد." + }, + "seed": { + "name": "seed", + "tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند." + }, + "video": { + "name": "ویدیو", + "tooltip": "ویدیوی منبع برای گسترش. فرمت MP4، بین ۲ تا ۱۵ ثانیه." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "تولید ویدیو از یک راهنما یا تصویر", "display_name": "ویدیو Grok", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "تولید ویدیو با راهنمایی تصاویر مرجع به عنوان سبک و محتوا.", + "display_name": "تولید ویدیو با مرجع Grok", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل", + "tooltip": "مدلی که برای تولید ویدیو استفاده می‌شود." + }, + "model_aspect_ratio": { + "name": "نسبت تصویر" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_resolution": { + "name": "وضوح" + }, + "prompt": { + "name": "راهنمای متنی", + "tooltip": "توضیح متنی درباره ویدیوی مورد نظر." + }, + "seed": { + "name": "seed", + "tooltip": "seed برای تعیین اینکه node باید دوباره اجرا شود یا خیر؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "گسترش ماسک", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "تنظیم صدای مرجع برای انتقال هویت گوینده با استفاده از ID-LoRA. یک کلیپ صوتی مرجع را به صورت شرطی رمزگذاری می‌کند و در صورت نیاز مدل را با راهنمایی هویتی (یک عبور اضافی بدون مرجع برای تقویت اثر هویت گوینده) اصلاح می‌کند.", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE برای رمزگذاری." + }, + "end_percent": { + "name": "end_percent", + "tooltip": "پایان بازه سیگما که راهنمایی هویتی در آن فعال است." + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "شدت راهنمایی هویتی. در هر مرحله یک عبور اضافی بدون مرجع اجرا می‌شود تا هویت گوینده تقویت شود. برای غیرفعال کردن، مقدار را روی ۰ قرار دهید (بدون عبور اضافی)." + }, + "model": { + "name": "مدل" + }, + "negative": { + "name": "منفی" + }, + "positive": { + "name": "مثبت" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "کلیپ صوتی مرجع که هویت گوینده آن منتقل می‌شود. مدت زمان پیشنهادی حدود ۵ ثانیه (مدت زمان آموزش). کلیپ‌های کوتاه‌تر یا بلندتر ممکن است کیفیت انتقال هویت صدا را کاهش دهند." + }, + "start_percent": { + "name": "start_percent", + "tooltip": "آغاز بازه سیگما که راهنمایی هویتی در آن فعال است." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "مثبت", + "tooltip": null + }, + "2": { + "name": "منفی", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVScheduler", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "بردارسازی یک تصویر شطرنجی به SVG با استفاده از Quiver AI.", + "display_name": "تبدیل تصویر Quiver به SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "برش خودکار به سوژه غالب." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "تصویر ورودی برای بردارسازی." + }, + "model": { + "name": "model", + "tooltip": "مدل مورد استفاده برای بردارسازی SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "تولید یک SVG از طریق پرامپت متنی با استفاده از Quiver AI.", + "display_name": "تبدیل متن Quiver به SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "راهنمایی‌های اضافی برای سبک یا قالب‌بندی." + }, + "model": { + "name": "model", + "tooltip": "مدل مورد استفاده برای تولید SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "توضیح متنی برای خروجی SVG مورد نظر." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "حداکثر ۴ تصویر مرجع برای راهنمایی تولید." + }, + "seed": { + "name": "seed", + "tooltip": "Seed برای تعیین اجرای مجدد node؛ نتایج واقعی صرف‌نظر از seed غیرقطعی هستند." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "حداقل p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "جریمه تکرار" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "تفکر", + "tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "حداقل p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "جریمه تکرار" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "تفکر", + "tooltip": "در حالت تفکر عمل کنید اگر مدل از آن پشتیبانی می‌کند." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "شرط مثبت", "tooltip": "شرط مثبت مورد استفاده برای آموزش." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "زمانی که training_dtype روی 'none' تنظیم شده و آموزش روی مدل quantized انجام می‌شود، در صورت فعال بودن، عملیات backward با quantized matmul انجام می‌شود." + }, "rank": { "name": "رتبه", "tooltip": "رتبه لایه‌های LoRA." diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 81c3db0056..56f02b6d17 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "سفارشی: نوار عنوان سیستم با منوی بالای ComfyUI جایگزین می‌شود" }, + "Comfy_Appearance_DisableAnimations": { + "name": "غیرفعال‌سازی انیمیشن‌ها", + "tooltip": "بیشتر انیمیشن‌ها و انتقال‌های CSS را غیرفعال می‌کند. زمانی که GPU نمایش برای تولید نیز استفاده می‌شود، سرعت استنتاج را افزایش می‌دهد." + }, "Comfy_Canvas_BackgroundImage": { "name": "تصویر پس‌زمینه بوم", "tooltip": "آدرس تصویر برای پس‌زمینه بوم. می‌توانید روی یک تصویر در پانل خروجی راست‌کلیک کرده و «تنظیم به عنوان پس‌زمینه» را انتخاب کنید یا تصویر دلخواه خود را با دکمه بارگذاری، بارگذاری نمایید." @@ -95,6 +99,10 @@ "name": "تعداد ارقام اعشاری گرد کردن ابزارک اعشاری [۰ = خودکار].", "tooltip": "(نیاز به بارگذاری مجدد صفحه)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "سرعت حرکت خودکار", + "tooltip": "حداکثر سرعت هنگام حرکت خودکار با کشیدن به لبه بوم. برای غیرفعال‌سازی حرکت خودکار، مقدار را روی ۰ قرار دهید." + }, "Comfy_Graph_CanvasInfo": { "name": "نمایش اطلاعات بوم در گوشه پایین سمت چپ (fps و غیره)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "نمایش هشدار مدل‌های مفقود" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "نمایش هشدار نودهای مفقود" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "مرتب‌سازی شناسه نودها هنگام ذخیره ورک‌فلو" }, diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 32cb7b6f13..fbc82ca684 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -15,6 +15,14 @@ "message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.", "title": "Connexion requise pour utiliser les nœuds API" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "Pour une expérience optimale, le constructeur d'applications utilise Nodes 2.0. Vous pouvez revenir en arrière après avoir construit l'application depuis le menu principal.", + "dismiss": "Fermer", + "dontShowAgain": "Ne plus afficher", + "title": "Passage à Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "Tous les {category}", "allModels": "Tous les modèles", @@ -556,19 +564,26 @@ "uploadCover": "+ Télécharger une couverture", "uploadProfilePicture": "+ Télécharger une photo de profil", "uploadWorkflowButton": "Télécharger mon workflow", + "usernameError": "3 à 42 caractères alphanumériques minuscules et tirets, doit commencer et se terminer par une lettre ou un chiffre", "usernameLabel": "Votre nom d'utilisateur (obligatoire)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "Informations supplémentaires", "back": "Retour", "createProfileCta": "Créer un profil", "createProfileToPublish": "Créez un profil pour publier sur ComfyHub", "exampleImage": "Image d'exemple {index}", + "exampleImagePosition": "Image d’exemple {index} sur {total}", "examplesDescription": "Ajoutez jusqu'à {total} images d'exemple supplémentaires", "maxExamples": "Vous pouvez sélectionner jusqu'à {max} exemples", "next": "Suivant", "publishButton": "Publier sur ComfyHub", + "publishFailedDescription": "Une erreur s’est produite lors de la publication de votre workflow. Veuillez réessayer.", + "publishFailedTitle": "Échec de la publication", + "removeExampleImage": "Supprimer l’image d’exemple", "selectAThumbnail": "Sélectionner une miniature", + "shareAs": "Partager en tant que", "showLessTags": "Afficher moins...", "showMoreTags": "Afficher plus...", "stepDescribe": "Décrivez votre workflow", @@ -583,6 +598,7 @@ "thumbnailPreview": "Aperçu de la miniature", "thumbnailVideo": "Vidéo", "title": "Publier sur ComfyHub", + "unsavedDescription": "Vous devez enregistrer votre workflow avant de le publier sur ComfyHub. Enregistrez-le maintenant pour continuer.", "uploadAnImage": "Cliquez pour parcourir ou faites glisser une image", "uploadComparison": "Télécharger avant et après", "uploadComparisonAfterPrompt": "Après", @@ -598,13 +614,7 @@ "workflowDescription": "Description du workflow", "workflowDescriptionPlaceholder": "Qu'est-ce qui rend votre workflow passionnant et unique ? Soyez précis pour que les utilisateurs sachent à quoi s'attendre.", "workflowName": "Nom du workflow", - "workflowNamePlaceholder": "Astuce : saisissez un nom descriptif facile à rechercher", - "workflowType": "Type de workflow", - "workflowTypeEditing": "Édition", - "workflowTypeImageGeneration": "Génération d'image", - "workflowTypePlaceholder": "Sélectionnez le type", - "workflowTypeUpscaling": "Upscaling", - "workflowTypeVideoGeneration": "Génération de vidéo" + "workflowNamePlaceholder": "Astuce : saisissez un nom descriptif facile à rechercher" }, "commands": { "clear": "Effacer le workflow", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "CONDITIONNEMENT", "CONTROL_NET": "RESEAU_DE_CONTROLE", + "CURVE": "COURBE", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FICHIER_3D", "FILE_3D_FBX": "FICHIER_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "FICHIERS_ENTRÉE_GEMINI", "GLIGEN": "GLIGEN", "GUIDER": "GUIDE", + "HISTOGRAM": "HISTOGRAMME", "HOOKS": "CROCHETS", "HOOK_KEYFRAMES": "CLEFS_DE_CROCHET", "IMAGE": "IMAGE", @@ -876,6 +888,8 @@ "resume": "Reprendre le téléchargement" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "Une erreur est survenue", "extensionFileHint": "Cela peut être dû au script suivant", "loadWorkflowTitle": "Chargement interrompu en raison d'une erreur de rechargement des données de workflow", @@ -924,6 +938,19 @@ "textToImage": "Texte vers image", "textToVideo": "Texte vers vidéo" }, + "execution": { + "decoding": "Décodage…", + "encoding": "Encodage…", + "generating": "Génération…", + "generatingVideo": "Génération de la vidéo…", + "loading": "Chargement…", + "processing": "Traitement…", + "processingVideo": "Traitement de la vidéo…", + "resizing": "Redimensionnement…", + "running": "Exécution…", + "saving": "Enregistrement…", + "training": "Entraînement…" + }, "exportToast": { "allExportsCompleted": "Toutes les exportations sont terminées", "downloadExport": "Télécharger l’export", @@ -1064,11 +1091,14 @@ "filterBy": "Filtrer par :", "filterByType": "Filtrer par {type}...", "findIssues": "Trouver des problèmes", + "findOnGithub": "Trouver sur GitHub", "frameNodes": "Encadrer les nœuds", "frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.", "frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.", + "gallery": "Galerie", "galleryImage": "Image de la galerie", "galleryThumbnail": "Miniature de la galerie", + "getHelpAction": "Obtenir de l'aide", "goToNode": "Aller au nœud", "graphNavigation": "Navigation dans le graphe", "halfSpeed": "0.5x", @@ -1077,6 +1107,8 @@ "icon": "Icône", "imageDoesNotExist": "L’image n’existe pas", "imageFailedToLoad": "Échec du chargement de l'image", + "imageGallery": "galerie d’images", + "imageLightbox": "Aperçu de l'image", "imagePreview": "Aperçu de l'image - Utilisez les flèches pour naviguer entre les images", "imageUrl": "URL de l'image", "import": "Importer", @@ -1090,13 +1122,38 @@ "installed": "Installé", "installing": "Installation", "interrupted": "Interrompu", - "itemSelected": "{selectedCount} élément sélectionné", "itemsCopiedToClipboard": "Éléments copiés dans le presse-papiers", "itemsSelected": "{selectedCount} éléments sélectionnés", "job": "Tâche", "jobIdCopied": "ID du travail copié dans le presse-papiers", "keybinding": "Raccourci clavier", "keybindingAlreadyExists": "Le raccourci clavier existe déjà", + "keybindingPresets": { + "default": "Préréglage par défaut", + "deletePreset": "Supprimer le préréglage", + "deletePresetFailed": "Échec de la suppression du préréglage \"{name}\"", + "deletePresetTitle": "Supprimer le préréglage actuel ?", + "deletePresetWarning": "Ce préréglage sera supprimé. Cette action est irréversible.", + "discardAndSwitch": "Ignorer et changer", + "exportPreset": "Exporter le préréglage", + "importKeybindingPreset": "Importer un préréglage de raccourcis", + "importPreset": "Importer un préréglage", + "invalidPresetFile": "Le fichier de préréglage doit être un JSON valide exporté depuis ComfyUI", + "invalidPresetName": "Le nom du préréglage ne doit pas être vide, \"default\", commencer par un point, contenir des séparateurs de chemin ou se terminer par .json", + "loadPresetFailed": "Échec du chargement du préréglage \"{name}\"", + "overwritePresetMessage": "Un préréglage nommé \"{name}\" existe déjà. Voulez-vous l'écraser ?", + "overwritePresetTitle": "Écraser le préréglage", + "presetDeleted": "Préréglage \"{name}\" supprimé", + "presetImported": "Préréglage de raccourcis importé", + "presetNamePrompt": "Entrez un nom pour le préréglage", + "presetSaved": "Préréglage \"{name}\" enregistré", + "resetToDefault": "Réinitialiser par défaut", + "saveAndSwitch": "Enregistrer et changer", + "saveAsNewPreset": "Enregistrer comme nouveau préréglage", + "saveChanges": "Enregistrer les modifications", + "unsavedChangesMessage": "Vous avez des modifications non enregistrées qui seront perdues si vous changez sans enregistrer.", + "unsavedChangesTo": "Modifications non enregistrées pour {name}" + }, "keybindings": "Raccourcis clavier", "learnMore": "En savoir plus", "listening": "Écoute en cours...", @@ -1153,6 +1210,8 @@ "output": "Sortie", "overwrite": "Écraser", "partner": "Partenaire", + "pause": "Pause", + "play": "Lecture", "playPause": "Lecture/Pause", "playRecording": "Lire l'enregistrement", "playbackSpeed": "Vitesse de lecture", @@ -1160,6 +1219,7 @@ "preloadError": "Une ressource requise n’a pas pu être chargée. Veuillez recharger la page.", "preloadErrorTitle": "Erreur de chargement", "preview": "APERÇU", + "previous": "Précédent", "previousImage": "Image précédente", "profile": "Profil", "progressCountOf": "sur", @@ -1234,6 +1294,8 @@ "showReport": "Afficher le rapport", "showRightPanel": "Afficher le panneau de droite", "singleSelectDropdown": "Menu déroulant à sélection unique", + "skipToEnd": "Aller à la fin", + "skipToStart": "Aller au début", "sort": "Trier", "source": "Source", "startRecording": "Commencer l’enregistrement", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.", "videoFailedToLoad": "Échec du chargement de la vidéo", "videoPreview": "Aperçu de la vidéo - Utilisez les flèches pour naviguer entre les vidéos", + "viewGrid": "Vue grille", "viewImageOfTotal": "Voir l'image {index} sur {total}", "viewVideoOfTotal": "Voir la vidéo {index} sur {total}", "volume": "Volume", @@ -1832,6 +1895,7 @@ "mirrorVertical": "Miroir vertical", "negative": "Négatif", "opacity": "Opacité", + "openMaskEditor": "Ouvrir dans l'éditeur de masque", "paintBucketSettings": "Paramètres du pot de peinture", "paintLayer": "Calque de peinture", "redo": "Rétablir", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "Les entrées que vous ajoutez en favori apparaîtront ici", "favoritesNoneHint": "Dans l’onglet Paramètres, cliquez sur {moreIcon} à côté de n’importe quelle entrée pour l’ajouter ici", "favoritesNoneTooltip": "Étoilez les widgets pour y accéder rapidement sans sélectionner de nœud", + "findOnGithubTooltip": "Rechercher des problèmes similaires sur GitHub", + "getHelpTooltip": "Signalez cette erreur et nous vous aiderons à la résoudre", "globalSettings": { "canvas": "CANEVA", "connectionLinks": "LIENS DE CONNEXION", @@ -3073,7 +3140,6 @@ "title": "Votre abonnement a été annulé" }, "changeTo": "Changer pour {plan}", - "chooseBestPlanWorkspace": "Choisissez la meilleure offre pour votre espace de travail", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo Comfy Cloud", "contactOwnerToSubscribe": "Contactez le propriétaire de l’espace de travail pour vous abonner", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "Des questions ou besoin d'une offre entreprise ?", + "inviteUpTo": "Invitez jusqu’à", "invoiceHistory": "Historique des factures", "learnMore": "En savoir plus", "managePayment": "Gérer le paiement", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "Crédits mensuels / membre", "monthlyCreditsRollover": "Ces crédits seront reportés au mois suivant", "mostPopular": "Le plus populaire", + "needTeamWorkspace": "Besoin d’un espace de travail d’équipe ?", "nextBillingCycle": "prochain cycle de facturation", "nextMonthInvoice": "Facture du mois prochain", "partnerNodesBalance": "Solde de crédits \"Nœuds Partenaires\"", "partnerNodesCredits": "Crédits Nœuds Partenaires", "partnerNodesDescription": "Pour exécuter des modèles commerciaux/propriétaires", "perMonth": "USD / mois", + "personalWorkspace": "Espace de travail personnel", "plansAndPricing": "Forfaits & tarifs", + "plansForWorkspace": "Formules pour {workspace}", "prepaidCreditsInfo": "Crédits achetés séparément qui n'expirent pas", "prepaidDescription": "Crédits prépayés", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "Se réabonner", "resubscribeSuccess": "Abonnement réactivé avec succès", "resubscribeTo": "Se réabonner à {plan}", + "soloUseOnly": "Usage solo uniquement", "subscribeForMore": "Mettre à niveau", "subscribeNow": "S'abonner maintenant", "subscribeTo": "S'abonner à {plan}", @@ -3177,6 +3248,7 @@ "subscribeToRun": "S'abonner", "subscribeToRunFull": "S'abonner pour exécuter", "subscriptionRequiredMessage": "Un abonnement est requis pour que les membres puissent exécuter des workflows sur le Cloud", + "teamWorkspace": "Espace de travail d’équipe", "tierNameYearly": "{name} Annuel", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "Dupliquer l'onglet", "removeFromBookmarks": "Retirer des Favoris" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "Espace créé mais configuration incomplète", + "createWorkspace": "Créer un espace de travail", + "namePlaceholder": "ex. : Équipe Marketing", + "nameValidationError": "Le nom doit comporter entre 1 et 50 caractères, incluant lettres, chiffres, espaces ou ponctuation courante.", + "newWorkspace": "Nouvel espace de travail", + "subtitle": "Basculez vers un espace existant ou créez-en un nouveau", + "subtitleNoWorkspaces": "Créez un nouvel espace de travail d’équipe pour partager des crédits", + "switch": "Basculer", + "title": "Espaces de travail d’équipe", + "yourTeamWorkspaces": "Vos espaces de travail d’équipe" + }, "templateWidgets": { "sort": { "searchPlaceholder": "Rechercher..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "Créer un nouvel espace de travail", + "failedToSwitch": "Échec du changement d’espace de travail", "maxWorkspacesReached": "Vous ne pouvez posséder que 10 espaces de travail. Supprimez-en un pour en créer un nouveau.", "personal": "Personnel", "roleMember": "Membre", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 76f2fa0027..032564558e 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Conversion de nombre", + "inputs": { + "value": { + "name": "valeur" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Commutateur", "inputs": { @@ -1736,6 +1752,10 @@ "name": "boucle_fermée", "tooltip": "Indique s'il faut fermer la boucle de la fenêtre de contexte ; applicable uniquement aux planifications en boucle." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "Liste des indices latents à conserver dans les tenseurs de conditionnement pour chaque fenêtre ; par exemple, définir cette valeur à '0' utilisera l'image de départ initiale pour chaque fenêtre." + }, "context_length": { "name": "longueur_contexte", "tooltip": "La longueur de la fenêtre de contexte." @@ -1767,6 +1787,10 @@ "model": { "name": "modèle", "tooltip": "Le modèle auquel appliquer les fenêtres de contexte pendant l'échantillonnage." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Indique s'il faut répartir plusieurs conditionnements (créés par ConditionCombine) dans chaque fenêtre selon l'index de région." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Éditeur de courbe", + "inputs": { + "curve": { + "name": "courbe" + }, + "histogram": { + "name": "histogramme" + } + }, + "outputs": { + "0": { + "name": "courbe", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Combo personnalisé", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "Première image issue du processus de réflexion du modèle. Disponible uniquement avec le niveau de réflexion ÉLEVÉ et la modalité IMAGE+TEXTE." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "Prolongez une vidéo existante avec une continuation fluide basée sur une invite textuelle.", + "display_name": "Extension vidéo Grok", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle", + "tooltip": "Le modèle à utiliser pour l’extension vidéo." + }, + "model_duration": { + "name": "durée" + }, + "prompt": { + "name": "invite", + "tooltip": "Description textuelle de ce qui doit se passer ensuite dans la vidéo." + }, + "seed": { + "name": "graine", + "tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes quel que soit la graine." + }, + "video": { + "name": "vidéo", + "tooltip": "Vidéo source à prolonger. Format MP4, 2-15 secondes." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "Générez une vidéo à partir d'une invite ou d'une image", "display_name": "Grok Video", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "Générez une vidéo guidée par des images de référence comme références de style et de contenu.", + "display_name": "Grok Référence-vers-Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle", + "tooltip": "Le modèle à utiliser pour la génération vidéo." + }, + "model_aspect_ratio": { + "name": "ratio d’aspect" + }, + "model_duration": { + "name": "durée" + }, + "model_resolution": { + "name": "résolution" + }, + "prompt": { + "name": "invite", + "tooltip": "Description textuelle de la vidéo souhaitée." + }, + "seed": { + "name": "graine", + "tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes quel que soit la graine." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "GrowMask", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "Définir un audio de référence pour le transfert d'identité de locuteur ID-LoRA. Encode un clip audio de référence dans le conditionnement et, en option, applique un guidage d'identité au modèle (passe supplémentaire sans référence, amplifiant l'effet d'identité du locuteur).", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE pour l'encodage." + }, + "end_percent": { + "name": "pourcentage_fin", + "tooltip": "Fin de la plage sigma où le guidage d'identité est actif." + }, + "identity_guidance_scale": { + "name": "échelle_guidage_identité", + "tooltip": "Intensité du guidage d'identité. Effectue une passe supplémentaire sans référence à chaque étape pour amplifier l'identité du locuteur. Mettre à 0 pour désactiver (pas de passe supplémentaire)." + }, + "model": { + "name": "modèle" + }, + "negative": { + "name": "négatif" + }, + "positive": { + "name": "positif" + }, + "reference_audio": { + "name": "audio_de_référence", + "tooltip": "Clip audio de référence dont l'identité du locuteur sera transférée. ~5 secondes recommandées (durée d'entraînement). Des clips plus courts ou plus longs peuvent dégrader le transfert d'identité vocale." + }, + "start_percent": { + "name": "pourcentage_début", + "tooltip": "Début de la plage sigma où le guidage d'identité est actif." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positif", + "tooltip": null + }, + "2": { + "name": "négatif", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVScheduler", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Vectorisez une image matricielle en SVG à l'aide de Quiver AI.", + "display_name": "Quiver Image vers SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "Rogner automatiquement sur le sujet principal." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "Image d'entrée à vectoriser." + }, + "model": { + "name": "model", + "tooltip": "Modèle à utiliser pour la vectorisation SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit le seed." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Générez un SVG à partir d'une invite textuelle avec Quiver AI.", + "display_name": "Quiver Texte vers SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "Conseils supplémentaires de style ou de mise en forme." + }, + "model": { + "name": "model", + "tooltip": "Modèle à utiliser pour la génération du SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "Description textuelle du SVG souhaité." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "Jusqu'à 4 images de référence pour guider la génération." + }, + "seed": { + "name": "seed", + "tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels restent non déterministes quel que soit le seed." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "pénalité_de_presence" + }, "sampling_mode_repetition_penalty": { "name": "pénalité_de_répétition" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "réflexion", + "tooltip": "Fonctionner en mode réflexion si le modèle le permet." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "pénalité_de_presence" + }, "sampling_mode_repetition_penalty": { "name": "pénalité_de_répétition" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "réflexion", + "tooltip": "Fonctionner en mode réflexion si le modèle le permet." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "positif", "tooltip": "Le conditionnement positif à utiliser pour l'entraînement." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "Lorsque le paramètre training_dtype est défini sur 'none' et que l'entraînement se fait sur un modèle quantifié, effectue la rétropropagation avec une multiplication matricielle quantifiée si activé." + }, "rank": { "name": "rang", "tooltip": "Le rang des couches LoRA." diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index 02da504679..d4b511fbfa 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "Choisissez l'option personnalisée pour masquer la barre de titre du système" }, + "Comfy_Appearance_DisableAnimations": { + "name": "Désactiver les animations", + "tooltip": "Désactive la plupart des animations et transitions CSS. Accélère l'inférence lorsque le GPU d'affichage est également utilisé pour la génération." + }, "Comfy_Canvas_BackgroundImage": { "name": "Image de fond du canevas", "tooltip": "URL de l'image pour le fond du canevas. Vous pouvez faire un clic droit sur une image dans le panneau de sortie et sélectionner « Définir comme fond » pour l'utiliser." @@ -95,6 +99,10 @@ "name": "Nombre de décimales pour l'arrondi du widget flottant [0 = auto].", "tooltip": "(nécessite le rechargement de la page)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Vitesse de défilement automatique", + "tooltip": "Vitesse maximale lors du défilement automatique en faisant glisser vers le bord du canevas. Réglez sur 0 pour désactiver le défilement automatique." + }, "Comfy_Graph_CanvasInfo": { "name": "Afficher les informations du canevas en bas à gauche (fps, etc.)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "Afficher l'avertissement des modèles manquants" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "Afficher l'avertissement des nœuds manquants" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "Trier les ID de nœuds lors de l'enregistrement du flux de travail" }, diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 7a83314bdb..ba9fec17da 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -15,6 +15,14 @@ "message": "このワークフローにはAPIノードが含まれており、実行するためにはアカウントにサインインする必要があります。", "title": "APIノードを使用するためにはサインインが必要です" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "最適な体験のため、App builderはNodes 2.0を使用します。アプリ作成後、メインメニューから元に戻すことができます。", + "dismiss": "閉じる", + "dontShowAgain": "今後表示しない", + "title": "Nodes 2.0 に切り替えました" + } + }, "assetBrowser": { "allCategory": "すべての{category}", "allModels": "すべてのモデル", @@ -556,19 +564,26 @@ "uploadCover": "+ カバー画像をアップロード", "uploadProfilePicture": "+ プロフィール画像をアップロード", "uploadWorkflowButton": "ワークフローをアップロード", + "usernameError": "3~42文字の半角英数字とハイフン、小文字で始まり終わる必要があります", "usernameLabel": "ユーザー名(必須)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "追加情報", "back": "戻る", "createProfileCta": "プロフィールを作成", "createProfileToPublish": "ComfyHubに公開するにはプロフィールを作成してください", "exampleImage": "サンプル画像 {index}", + "exampleImagePosition": "サンプル画像 {index} / {total}", "examplesDescription": "追加サンプル画像は最大{total}枚まで", "maxExamples": "最大{max}件まで選択できます", "next": "次へ", "publishButton": "ComfyHub へ公開", + "publishFailedDescription": "ワークフローの公開中に問題が発生しました。もう一度お試しください。", + "publishFailedTitle": "公開に失敗しました", + "removeExampleImage": "サンプル画像を削除", "selectAThumbnail": "サムネイルを選択", + "shareAs": "次の形式で共有", "showLessTags": "表示を減らす...", "showMoreTags": "さらに表示...", "stepDescribe": "ワークフローを説明する", @@ -583,6 +598,7 @@ "thumbnailPreview": "サムネイルプレビュー", "thumbnailVideo": "動画", "title": "ComfyHub へ公開", + "unsavedDescription": "ComfyHub に公開する前にワークフローを保存する必要があります。続行するには今すぐ保存してください。", "uploadAnImage": "クリックして選択 または画像をドラッグ", "uploadComparison": "ビフォーアフターをアップロード", "uploadComparisonAfterPrompt": "アフター", @@ -598,13 +614,7 @@ "workflowDescription": "ワークフローの説明", "workflowDescriptionPlaceholder": "あなたのワークフローの魅力や特徴は何ですか?具体的に記載することで、利用者が内容を理解しやすくなります。", "workflowName": "ワークフロー名", - "workflowNamePlaceholder": "ヒント:検索しやすい説明的な名前を入力してください", - "workflowType": "ワークフロータイプ", - "workflowTypeEditing": "編集", - "workflowTypeImageGeneration": "画像生成", - "workflowTypePlaceholder": "タイプを選択してください", - "workflowTypeUpscaling": "アップスケーリング", - "workflowTypeVideoGeneration": "動画生成" + "workflowNamePlaceholder": "ヒント:検索しやすい説明的な名前を入力してください" }, "commands": { "clear": "ワークフローをクリア", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "条件付け", "CONTROL_NET": "コントロールネット", + "CURVE": "カーブ", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "GEMINI入力ファイル", "GLIGEN": "GLIGEN", "GUIDER": "ガイダー", + "HISTOGRAM": "ヒストグラム", "HOOKS": "フック", "HOOK_KEYFRAMES": "フックキーフレーム", "IMAGE": "画像", @@ -876,6 +888,8 @@ "resume": "ダウンロードを再開" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "エラーが発生しました", "extensionFileHint": "これは次のスクリプトが原因かもしれません", "loadWorkflowTitle": "ワークフローデータの再読み込みエラーにより、読み込みが中止されました", @@ -924,6 +938,19 @@ "textToImage": "テキストから画像へ", "textToVideo": "テキストから動画へ" }, + "execution": { + "decoding": "デコード中…", + "encoding": "エンコード中…", + "generating": "生成中…", + "generatingVideo": "ビデオ生成中…", + "loading": "読み込み中…", + "processing": "処理中…", + "processingVideo": "ビデオ処理中…", + "resizing": "リサイズ中…", + "running": "実行中…", + "saving": "保存中…", + "training": "トレーニング中…" + }, "exportToast": { "allExportsCompleted": "すべてのエクスポートが完了しました", "downloadExport": "エクスポートをダウンロード", @@ -1064,11 +1091,14 @@ "filterBy": "フィルター:", "filterByType": "{type}でフィルター...", "findIssues": "問題を見つける", + "findOnGithub": "GitHubで探す", "frameNodes": "ノードをフレーム化", "frontendNewer": "フロントエンドのバージョン {frontendVersion} はバックエンドのバージョン {backendVersion} と互換性がない可能性があります。", "frontendOutdated": "フロントエンドのバージョン {frontendVersion} は古くなっています。バックエンドは {requiredVersion} 以上が必要です。", + "gallery": "ギャラリー", "galleryImage": "ギャラリー画像", "galleryThumbnail": "ギャラリーサムネイル", + "getHelpAction": "ヘルプを受ける", "goToNode": "ノードに移動", "graphNavigation": "グラフナビゲーション", "halfSpeed": "0.5倍速", @@ -1077,6 +1107,8 @@ "icon": "アイコン", "imageDoesNotExist": "画像が存在しません", "imageFailedToLoad": "画像の読み込みに失敗しました", + "imageGallery": "画像ギャラリー", + "imageLightbox": "画像プレビュー", "imagePreview": "画像プレビュー - 矢印キーで画像を切り替え", "imageUrl": "画像URL", "import": "インポート", @@ -1090,13 +1122,38 @@ "installed": "インストール済み", "installing": "インストール中", "interrupted": "中断されました", - "itemSelected": "{selectedCount}件選択済み", "itemsCopiedToClipboard": "項目をクリップボードにコピーしました", "itemsSelected": "{selectedCount}件選択済み", "job": "ジョブ", "jobIdCopied": "ジョブIDがクリップボードにコピーされました", "keybinding": "キーバインディング", "keybindingAlreadyExists": "このキー割り当てはすでに存在します", + "keybindingPresets": { + "default": "デフォルトプリセット", + "deletePreset": "プリセットを削除", + "deletePresetFailed": "プリセット「{name}」の削除に失敗しました", + "deletePresetTitle": "現在のプリセットを削除しますか?", + "deletePresetWarning": "このプリセットは削除されます。元に戻すことはできません。", + "discardAndSwitch": "破棄して切り替え", + "exportPreset": "プリセットをエクスポート", + "importKeybindingPreset": "キーバインドプリセットをインポート", + "importPreset": "プリセットをインポート", + "invalidPresetFile": "プリセットファイルはComfyUIからエクスポートされた有効なJSONである必要があります", + "invalidPresetName": "プリセット名は空、\"default\"、ドットで始まる、パス区切り文字を含む、または .json で終わることはできません", + "loadPresetFailed": "プリセット「{name}」の読み込みに失敗しました", + "overwritePresetMessage": "「{name}」という名前のプリセットが既に存在します。上書きしますか?", + "overwritePresetTitle": "プリセットの上書き", + "presetDeleted": "プリセット「{name}」を削除しました", + "presetImported": "キーバインドプリセットをインポートしました", + "presetNamePrompt": "プリセットの名前を入力してください", + "presetSaved": "プリセット「{name}」を保存しました", + "resetToDefault": "デフォルトにリセット", + "saveAndSwitch": "保存して切り替え", + "saveAsNewPreset": "新しいプリセットとして保存", + "saveChanges": "変更を保存", + "unsavedChangesMessage": "保存せずに切り替えると、未保存の変更が失われます。", + "unsavedChangesTo": "{name} に未保存の変更があります" + }, "keybindings": "キーバインド", "learnMore": "詳細を学ぶ", "listening": "リスニング中...", @@ -1153,6 +1210,8 @@ "output": "出力", "overwrite": "上書き", "partner": "パートナー", + "pause": "一時停止", + "play": "再生", "playPause": "再生/一時停止", "playRecording": "録音を再生", "playbackSpeed": "再生速度", @@ -1160,6 +1219,7 @@ "preloadError": "必要なリソースの読み込みに失敗しました。ページを再読み込みしてください。", "preloadErrorTitle": "読み込みエラー", "preview": "プレビュー", + "previous": "前へ", "previousImage": "前の画像", "profile": "プロフィール", "progressCountOf": "の", @@ -1234,6 +1294,8 @@ "showReport": "レポートを表示", "showRightPanel": "右パネルを表示", "singleSelectDropdown": "単一選択ドロップダウン", + "skipToEnd": "最後にスキップ", + "skipToStart": "最初にスキップ", "sort": "並び替え", "source": "ソース", "startRecording": "録音開始", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。", "videoFailedToLoad": "ビデオの読み込みに失敗しました", "videoPreview": "ビデオプレビュー - 矢印キーでビデオを切り替え", + "viewGrid": "グリッド表示", "viewImageOfTotal": "画像 {index} / {total} を表示", "viewVideoOfTotal": "ビデオ {index} / {total} を表示", "volume": "音量", @@ -1832,6 +1895,7 @@ "mirrorVertical": "上下反転", "negative": "ネガティブ", "opacity": "不透明度", + "openMaskEditor": "マスクエディタで開く", "paintBucketSettings": "ペイントバケツ設定", "paintLayer": "ペイントレイヤー", "redo": "やり直し", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "お気に入りに追加した入力がここに表示されます", "favoritesNoneHint": "パラメータタブで、任意の入力の{moreIcon}をクリックするとここに追加できます", "favoritesNoneTooltip": "ウィジェットにスターを付けると、ノードを選択せずにすぐアクセスできます", + "findOnGithubTooltip": "関連する問題をGitHubで検索", + "getHelpTooltip": "このエラーを報告すると、解決をサポートします", "globalSettings": { "canvas": "キャンバス", "connectionLinks": "接続リンク", @@ -3073,7 +3140,6 @@ "title": "サブスクリプションはキャンセルされました" }, "changeTo": "{plan}に変更", - "chooseBestPlanWorkspace": "ワークスペースに最適なプランを選択してください", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud ロゴ", "contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro(96GB VRAM)", "haveQuestions": "ご質問やエンタープライズについてのお問い合わせはこちら", + "inviteUpTo": "最大 {count} 人を招待", "invoiceHistory": "請求履歴", "learnMore": "詳細を見る", "managePayment": "支払いを管理", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "月間クレジット / メンバー", "monthlyCreditsRollover": "これらのクレジットは翌月に繰り越されます", "mostPopular": "最も人気", + "needTeamWorkspace": "チームワークスペースが必要ですか?", "nextBillingCycle": "次の請求サイクル", "nextMonthInvoice": "翌月の請求書", "partnerNodesBalance": "\"パートナーノード\" クレジット残高", "partnerNodesCredits": "パートナーノードクレジット", "partnerNodesDescription": "商用/独自モデルの実行用", "perMonth": "USD / 月", + "personalWorkspace": "個人ワークスペース", "plansAndPricing": "プランと価格", + "plansForWorkspace": "{workspace} のプラン", "prepaidCreditsInfo": "別途購入した有効期限のないクレジット", "prepaidDescription": "プリペイドクレジット", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "再購読する", "resubscribeSuccess": "サブスクリプションが再開されました", "resubscribeTo": "{plan}を再購読する", + "soloUseOnly": "個人利用のみ", "subscribeForMore": "アップグレード", "subscribeNow": "今すぐ購読", "subscribeTo": "{plan}に登録", @@ -3177,6 +3248,7 @@ "subscribeToRun": "購読する", "subscribeToRunFull": "実行を購読", "subscriptionRequiredMessage": "クラウドでワークフローを実行するにはメンバーにサブスクリプションが必要です", + "teamWorkspace": "チームワークスペース", "tierNameYearly": "{name} 年間", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "タブを複製", "removeFromBookmarks": "ブックマークから削除" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "ワークスペースは作成されましたが、セットアップが完了していません", + "createWorkspace": "ワークスペースを作成", + "namePlaceholder": "例:マーケティングチーム", + "nameValidationError": "名前は1~50文字で、英字、数字、スペース、または一般的な句読点を使用してください。", + "newWorkspace": "新しいワークスペース", + "subtitle": "既存のワークスペースに切り替えるか、新しいワークスペースを作成してください", + "subtitleNoWorkspaces": "クレジットを共有するために新しいチームワークスペースを作成しましょう", + "switch": "切り替え", + "title": "チームワークスペース", + "yourTeamWorkspaces": "あなたのチームワークスペース" + }, "templateWidgets": { "sort": { "searchPlaceholder": "検索..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "新しいワークスペースを作成", + "failedToSwitch": "ワークスペースの切り替えに失敗しました", "maxWorkspacesReached": "所有できるワークスペースは10個までです。新しく作成するには1つ削除してください。", "personal": "個人用", "roleMember": "メンバー", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index f6a42a4279..4be88457ce 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "数値変換", + "inputs": { + "value": { + "name": "値" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "スイッチ", "inputs": { @@ -1736,6 +1752,10 @@ "name": "closed_loop", "tooltip": "コンテキストウィンドウのループを閉じるかどうか。ループスケジュールにのみ適用されます。" }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "各ウィンドウのコンディショニングテンソルで保持するlatentインデックスのリストです。例えば「0」に設定すると、各ウィンドウで初期の開始画像が使用されます。" + }, "context_length": { "name": "コンテキスト長", "tooltip": "コンテキストウィンドウの長さ。" @@ -1767,6 +1787,10 @@ "model": { "name": "モデル", "tooltip": "サンプリング中にコンテキストウィンドウを適用するモデル。" + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "複数のコンディショニング(ConditionCombineで作成)を領域インデックスに基づいて各ウィンドウに分割するかどうか。" } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "カーブエディター", + "inputs": { + "curve": { + "name": "カーブ" + }, + "histogram": { + "name": "ヒストグラム" + } + }, + "outputs": { + "0": { + "name": "カーブ", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "カスタムコンボ", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "モデルの思考過程からの最初の画像です。thinking_levelがHIGHかつIMAGE+TEXTモダリティの場合のみ利用可能です。" } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "テキストプロンプトに基づいて、既存のビデオをシームレスに継続します。", + "display_name": "Grokビデオ拡張", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル", + "tooltip": "ビデオ拡張に使用するモデル。" + }, + "model_duration": { + "name": "継続時間" + }, + "prompt": { + "name": "プロンプト", + "tooltip": "ビデオの次に何が起こるべきかのテキスト説明。" + }, + "seed": { + "name": "シード", + "tooltip": "ノードを再実行するかどうかを決定するシード。実際の結果はシードに関係なく非決定的です。" + }, + "video": { + "name": "ビデオ", + "tooltip": "拡張する元のビデオ。MP4形式、2~15秒。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "プロンプトまたは画像から動画を生成します", "display_name": "Grok動画生成", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "リファレンス画像をスタイルや内容の参考として、ビデオを生成します。", + "display_name": "Grokリファレンス→ビデオ", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル", + "tooltip": "ビデオ生成に使用するモデル。" + }, + "model_aspect_ratio": { + "name": "アスペクト比" + }, + "model_duration": { + "name": "継続時間" + }, + "model_resolution": { + "name": "解像度" + }, + "prompt": { + "name": "プロンプト", + "tooltip": "希望するビデオのテキスト説明。" + }, + "seed": { + "name": "シード", + "tooltip": "ノードを再実行するかどうかを決定するシード。実際の結果はシードに関係なく非決定的です。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "マスクを拡大", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "ID-LoRA話者識別転送のためのリファレンス音声を設定します。リファレンス音声クリップをコンディショニングにエンコードし、必要に応じてモデルにアイデンティティガイダンス(リファレンスなしの追加フォワードパスで話者識別効果を強調)を適用します。", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "エンコード用のLTXV Audio VAE。" + }, + "end_percent": { + "name": "end_percent", + "tooltip": "アイデンティティガイダンスが有効になるシグマ範囲の終了点。" + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "アイデンティティガイダンスの強さ。各ステップでリファレンスなしの追加フォワードパスを実行し、話者識別を強調します。0に設定すると無効化されます(追加パスなし)。" + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "転送したい話者識別を持つリファレンス音声クリップ。約5秒間(学習時間)が推奨されます。短すぎたり長すぎたりすると話者識別転送の品質が低下する場合があります。" + }, + "start_percent": { + "name": "start_percent", + "tooltip": "アイデンティティガイダンスが有効になるシグマ範囲の開始点。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVスケジューラー", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Quiver AIを使ってラスタ画像をSVGにベクトル化します。", + "display_name": "Quiver画像からSVGへ", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "主要な被写体に自動でクロップします。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "ベクトル化する入力画像。" + }, + "model": { + "name": "model", + "tooltip": "SVGベクトル化に使用するモデル。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Quiver AIを使ってテキストプロンプトからSVGを生成します。", + "display_name": "QuiverテキストからSVGへ", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "追加のスタイルやフォーマットに関する指示。" + }, + "model": { + "name": "model", + "tooltip": "SVG生成に使用するモデル。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "希望するSVG出力のテキスト説明。" + }, + "reference_images": { + "name": "reference_images", + "tooltip": "生成をガイドするための最大4枚の参照画像。" + }, + "seed": { + "name": "seed", + "tooltip": "ノードを再実行するかどうかを決定するシード値。実際の結果はシードに関係なく非決定的です。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "最小p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "繰り返しペナルティ" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "思考モード", + "tooltip": "モデルが対応している場合、思考モードで動作します。" } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "最小p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "繰り返しペナルティ" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "思考モード", + "tooltip": "モデルが対応している場合、思考モードで動作します。" } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "ポジティブ条件付け", "tooltip": "学習に使用するポジティブな条件付け。" }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "training_dtype を「none」に設定し、量子化モデルでトレーニングする場合、有効にすると逆伝播時に量子化された行列積を使用します。" + }, "rank": { "name": "ランク", "tooltip": "LoRAレイヤーのランク。" diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 9d80bb4d10..017239152c 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "システムタイトルバーを非表示にするにはカスタムオプションを選択してください" }, + "Comfy_Appearance_DisableAnimations": { + "name": "アニメーションを無効化", + "tooltip": "ほとんどのCSSアニメーションとトランジションをオフにします。表示用GPUが生成にも使われている場合、推論速度が向上します。" + }, "Comfy_Canvas_BackgroundImage": { "name": "キャンバス背景画像", "tooltip": "キャンバスの背景画像のURLです。出力パネルで画像を右クリックし、「背景として設定」を選択すると使用できます。" @@ -95,6 +99,10 @@ "name": "浮動小数点ウィジェットの丸め小数点数 [0 = 自動]。", "tooltip": "(ページの再読み込みが必要)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "自動パン速度", + "tooltip": "キャンバス端にドラッグしたときの自動パンの最大速度。0に設定すると自動パンが無効になります。" + }, "Comfy_Graph_CanvasInfo": { "name": "左下隅にキャンバス情報を表示する(fpsなど)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "欠落しているモデルの警告を表示" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "欠落しているノードの警告を表示" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "ワークフローを保存する際にノードIDをソート" }, diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 1dc8b3d5f2..e00f263aff 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -15,6 +15,14 @@ "message": "이 워크플로에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.", "title": "API 노드 사용에 필요한 로그인" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "최상의 경험을 위해 App builder는 Nodes 2.0을 사용합니다. 앱 빌드 후 메인 메뉴에서 이전 버전으로 전환할 수 있습니다.", + "dismiss": "닫기", + "dontShowAgain": "다시 표시하지 않기", + "title": "Nodes 2.0으로 전환됨" + } + }, "assetBrowser": { "allCategory": "모든 {category}", "allModels": "모든 모델", @@ -556,19 +564,26 @@ "uploadCover": "+ 커버 업로드", "uploadProfilePicture": "+ 프로필 사진 업로드", "uploadWorkflowButton": "내 워크플로우 업로드하기", + "usernameError": "3~42자의 소문자 영문, 숫자, 하이픈만 사용 가능하며, 반드시 영문 또는 숫자로 시작하고 끝나야 합니다.", "usernameLabel": "사용자 이름 (필수)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "추가 정보", "back": "뒤로", "createProfileCta": "프로필 생성하기", "createProfileToPublish": "ComfyHub에 게시하려면 프로필을 생성하세요", "exampleImage": "예시 이미지 {index}", + "exampleImagePosition": "예시 이미지 {index}/{total}", "examplesDescription": "최대 {total}개의 추가 샘플 이미지를 추가할 수 있습니다", "maxExamples": "최대 {max}개의 예시를 선택할 수 있습니다", "next": "다음", "publishButton": "ComfyHub에 게시하기", + "publishFailedDescription": "워크플로우를 게시하는 중에 문제가 발생했습니다. 다시 시도해 주세요.", + "publishFailedTitle": "게시 실패", + "removeExampleImage": "예시 이미지 제거", "selectAThumbnail": "썸네일 선택", + "shareAs": "다음으로 공유", "showLessTags": "간단히 보기...", "showMoreTags": "더 보기...", "stepDescribe": "워크플로우 설명하기", @@ -583,6 +598,7 @@ "thumbnailPreview": "썸네일 미리보기", "thumbnailVideo": "비디오", "title": "ComfyHub에 게시하기", + "unsavedDescription": "워크플로우를 ComfyHub에 게시하기 전에 저장해야 합니다. 계속하려면 지금 저장하세요.", "uploadAnImage": "클릭하여 탐색하거나 이미지를 드래그하세요", "uploadComparison": "비교 이미지 업로드", "uploadComparisonAfterPrompt": "이후", @@ -598,13 +614,7 @@ "workflowDescription": "워크플로우 설명", "workflowDescriptionPlaceholder": "무엇이 워크플로우를 흥미롭고 특별하게 만드는지 구체적으로 작성해 주세요. 사람들이 무엇을 기대할 수 있는지 알 수 있도록 해주세요.", "workflowName": "워크플로우 이름", - "workflowNamePlaceholder": "팁: 검색하기 쉬운 설명적인 이름을 입력하세요", - "workflowType": "워크플로우 유형", - "workflowTypeEditing": "편집", - "workflowTypeImageGeneration": "이미지 생성", - "workflowTypePlaceholder": "유형 선택", - "workflowTypeUpscaling": "업스케일링", - "workflowTypeVideoGeneration": "비디오 생성" + "workflowNamePlaceholder": "팁: 검색하기 쉬운 설명적인 이름을 입력하세요" }, "commands": { "clear": "워크플로 지우기", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "조건", "CONTROL_NET": "컨트롤넷", + "CURVE": "곡선", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "GEMINI_INPUT_FILES", "GLIGEN": "GLIGEN", "GUIDER": "가이드", + "HISTOGRAM": "히스토그램", "HOOKS": "후크", "HOOK_KEYFRAMES": "후크 키프레임", "IMAGE": "이미지", @@ -876,6 +888,8 @@ "resume": "다운로드 재개" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "오류가 발생했습니다", "extensionFileHint": "다음 스크립트 때문일 수 있습니다", "loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다", @@ -924,6 +938,19 @@ "textToImage": "텍스트 → 이미지", "textToVideo": "텍스트 → 비디오" }, + "execution": { + "decoding": "디코딩 중…", + "encoding": "인코딩 중…", + "generating": "생성 중…", + "generatingVideo": "비디오 생성 중…", + "loading": "불러오는 중…", + "processing": "처리 중…", + "processingVideo": "비디오 처리 중…", + "resizing": "크기 조정 중…", + "running": "실행 중…", + "saving": "저장 중…", + "training": "학습 중…" + }, "exportToast": { "allExportsCompleted": "모든 내보내기 완료", "downloadExport": "내보내기 다운로드", @@ -1064,11 +1091,14 @@ "filterBy": "필터 기준:", "filterByType": "{type}별로 필터링...", "findIssues": "문제 찾기", + "findOnGithub": "GitHub에서 찾기", "frameNodes": "노드 프레임", "frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.", "frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래된 버전입니다. 백엔드는 {requiredVersion} 이상 버전이 필요합니다.", + "gallery": "갤러리", "galleryImage": "갤러리 이미지", "galleryThumbnail": "갤러리 썸네일", + "getHelpAction": "도움 받기", "goToNode": "노드로 이동", "graphNavigation": "그래프 탐색", "halfSpeed": "0.5배속", @@ -1077,6 +1107,8 @@ "icon": "아이콘", "imageDoesNotExist": "이미지가 존재하지 않습니다", "imageFailedToLoad": "이미지를 로드하지 못했습니다.", + "imageGallery": "이미지 갤러리", + "imageLightbox": "이미지 미리보기", "imagePreview": "이미지 미리보기 - 화살표 키를 사용하여 이미지 간 이동", "imageUrl": "이미지 URL", "import": "가져오기", @@ -1090,13 +1122,38 @@ "installed": "설치됨", "installing": "설치 중", "interrupted": "중단됨", - "itemSelected": "{selectedCount}개 선택됨", "itemsCopiedToClipboard": "항목이 클립보드에 복사되었습니다", "itemsSelected": "{selectedCount}개 선택됨", "job": "작업", "jobIdCopied": "작업 ID가 클립보드에 복사됨", "keybinding": "키 바인딩", "keybindingAlreadyExists": "단축키가 이미 존재합니다", + "keybindingPresets": { + "default": "기본 프리셋", + "deletePreset": "프리셋 삭제", + "deletePresetFailed": "\"{name}\" 프리셋을 삭제하지 못했습니다", + "deletePresetTitle": "현재 프리셋을 삭제하시겠습니까?", + "deletePresetWarning": "이 프리셋이 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", + "discardAndSwitch": "변경 사항 버리고 전환", + "exportPreset": "프리셋 내보내기", + "importKeybindingPreset": "키 바인딩 프리셋 가져오기", + "importPreset": "프리셋 가져오기", + "invalidPresetFile": "프리셋 파일은 ComfyUI에서 내보낸 유효한 JSON이어야 합니다", + "invalidPresetName": "프리셋 이름은 비어 있거나 \"default\"일 수 없으며, 점(.)으로 시작하거나 경로 구분자를 포함하거나 .json으로 끝날 수 없습니다", + "loadPresetFailed": "\"{name}\" 프리셋을 불러오지 못했습니다", + "overwritePresetMessage": "\"{name}\"이라는 이름의 프리셋이 이미 있습니다. 덮어쓰시겠습니까?", + "overwritePresetTitle": "프리셋 덮어쓰기", + "presetDeleted": "프리셋 \"{name}\"이(가) 삭제되었습니다", + "presetImported": "키 바인딩 프리셋이 가져와졌습니다", + "presetNamePrompt": "프리셋 이름을 입력하세요", + "presetSaved": "프리셋 \"{name}\"이(가) 저장되었습니다", + "resetToDefault": "기본값으로 재설정", + "saveAndSwitch": "저장하고 전환", + "saveAsNewPreset": "새 프리셋으로 저장", + "saveChanges": "변경 사항 저장", + "unsavedChangesMessage": "저장하지 않고 전환하면 변경 사항이 사라집니다.", + "unsavedChangesTo": "{name}에 저장되지 않은 변경 사항" + }, "keybindings": "단축키", "learnMore": "더 알아보기", "listening": "듣는 중...", @@ -1153,6 +1210,8 @@ "output": "출력", "overwrite": "덮어쓰기", "partner": "파트너", + "pause": "일시정지", + "play": "재생", "playPause": "재생/일시정지", "playRecording": "녹음 재생", "playbackSpeed": "재생 속도", @@ -1160,6 +1219,7 @@ "preloadError": "필수 리소스 로드에 실패했습니다. 페이지를 새로고침 해주세요.", "preloadErrorTitle": "로딩 오류", "preview": "미리보기", + "previous": "이전", "previousImage": "이전 이미지", "profile": "프로필", "progressCountOf": "중", @@ -1234,6 +1294,8 @@ "showReport": "보고서 보기", "showRightPanel": "오른쪽 패널 표시", "singleSelectDropdown": "단일 선택 드롭다운", + "skipToEnd": "끝으로 건너뛰기", + "skipToStart": "처음으로 건너뛰기", "sort": "정렬", "source": "소스", "startRecording": "녹음 시작", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.", "videoFailedToLoad": "비디오를 로드하지 못했습니다.", "videoPreview": "비디오 미리보기 - 화살표 키를 사용하여 비디오 간 이동", + "viewGrid": "그리드 보기", "viewImageOfTotal": "이미지 {index}/{total} 보기", "viewVideoOfTotal": "비디오 {index}/{total} 보기", "volume": "볼륨", @@ -1832,6 +1895,7 @@ "mirrorVertical": "수직 반전", "negative": "네거티브", "opacity": "불투명도", + "openMaskEditor": "마스크 에디터에서 열기", "paintBucketSettings": "페인트 버킷 설정", "paintLayer": "페인트 레이어", "redo": "다시 실행", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "즐겨찾기한 입력이 여기에 표시됩니다", "favoritesNoneHint": "파라미터 탭에서 원하는 입력의 {moreIcon}을 클릭해 여기에 추가하세요", "favoritesNoneTooltip": "노드를 선택하지 않고도 빠르게 접근하려면 위젯에 별표를 표시하세요", + "findOnGithubTooltip": "관련 문제를 GitHub에서 검색", + "getHelpTooltip": "이 오류를 신고하시면 해결을 도와드리겠습니다", "globalSettings": { "canvas": "캔버스", "connectionLinks": "연결 링크", @@ -3073,7 +3140,6 @@ "title": "구독이 취소되었습니다" }, "changeTo": "{plan}로 변경", - "chooseBestPlanWorkspace": "워크스페이스에 가장 적합한 플랜을 선택하세요", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud 로고", "contactOwnerToSubscribe": "워크스페이스 소유자에게 구독을 요청하세요", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "질문이 있거나 엔터프라이즈가 궁금하신가요?", + "inviteUpTo": "최대 {count}명 초대", "invoiceHistory": "청구서 기록", "learnMore": "더 알아보기", "managePayment": "결제 관리", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "월별 크레딧 / 멤버", "monthlyCreditsRollover": "이 크레딧은 다음 달로 이월됩니다", "mostPopular": "가장 인기 있음", + "needTeamWorkspace": "팀 워크스페이스가 필요하신가요?", "nextBillingCycle": "다음 결제 주기", "nextMonthInvoice": "다음 달 청구서", "partnerNodesBalance": "\"파트너 노드\" 크레딧 잔액", "partnerNodesCredits": "파트너 노드 크레딧", "partnerNodesDescription": "상용/독점 모델 실행용", "perMonth": "USD / 월", + "personalWorkspace": "개인 워크스페이스", "plansAndPricing": "플랜 및 가격", + "plansForWorkspace": "{workspace}의 플랜", "prepaidCreditsInfo": "별도 구매하여 만료되지 않는 크레딧", "prepaidDescription": "선불 크레딧", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "다시 구독하기", "resubscribeSuccess": "구독이 성공적으로 재활성화되었습니다", "resubscribeTo": "{plan} 다시 구독하기", + "soloUseOnly": "개인용 전용", "subscribeForMore": "업그레이드", "subscribeNow": "지금 구독하기", "subscribeTo": "{plan} 구독하기", @@ -3177,6 +3248,7 @@ "subscribeToRun": "구독", "subscribeToRunFull": "실행 구독", "subscriptionRequiredMessage": "클라우드에서 워크플로우를 실행하려면 멤버가 구독해야 합니다", + "teamWorkspace": "팀 워크스페이스", "tierNameYearly": "{name} 연간", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "탭 복제", "removeFromBookmarks": "북마크에서 제거" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "워크스페이스가 생성되었지만 설정이 완료되지 않았습니다", + "createWorkspace": "워크스페이스 만들기", + "namePlaceholder": "예: 마케팅팀", + "nameValidationError": "이름은 문자, 숫자, 공백 또는 일반 구두점을 사용하여 1~50자여야 합니다.", + "newWorkspace": "새 워크스페이스", + "subtitle": "기존 워크스페이스로 전환하거나 새 워크스페이스를 만드세요", + "subtitleNoWorkspaces": "크레딧을 공유할 새 팀 워크스페이스를 만드세요", + "switch": "전환", + "title": "팀 워크스페이스", + "yourTeamWorkspaces": "내 팀 워크스페이스" + }, "templateWidgets": { "sort": { "searchPlaceholder": "검색..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "새 워크스페이스 만들기", + "failedToSwitch": "워크스페이스 전환에 실패했습니다", "maxWorkspacesReached": "최대 10개의 워크스페이스만 소유할 수 있습니다. 새로 만들려면 하나를 삭제하세요.", "personal": "개인", "roleMember": "멤버", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index c93e91fa89..3bd7437a6f 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "숫자 변환", + "inputs": { + "value": { + "name": "값" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "스위치", "inputs": { @@ -1736,6 +1752,10 @@ "name": "closed_loop", "tooltip": "컨텍스트 윈도우 루프를 닫을지 여부입니다. 루프 스케줄에만 적용됩니다." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "각 윈도우의 conditioning 텐서에서 유지할 latent 인덱스 목록입니다. 예를 들어 '0'으로 설정하면 각 윈도우에 대해 초기 시작 이미지를 사용합니다." + }, "context_length": { "name": "context_length", "tooltip": "컨텍스트 윈도우의 길이입니다." @@ -1767,6 +1787,10 @@ "model": { "name": "model", "tooltip": "샘플링 중 컨텍스트 윈도우를 적용할 모델입니다." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "여러 개의 conditioning(ConditionCombine에서 생성됨)을 영역 인덱스에 따라 각 윈도우로 분할할지 여부입니다." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "곡선 편집기", + "inputs": { + "curve": { + "name": "곡선" + }, + "histogram": { + "name": "히스토그램" + } + }, + "outputs": { + "0": { + "name": "곡선", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "사용자 지정 콤보", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "모델의 사고 과정에서 생성된 첫 번째 이미지입니다. thinking_level이 HIGH이고 IMAGE+TEXT 모달리티일 때만 제공됩니다." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "텍스트 프롬프트를 기반으로 기존 비디오를 자연스럽게 이어서 확장합니다.", + "display_name": "Grok 비디오 확장", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "model": { + "name": "모델", + "tooltip": "비디오 확장에 사용할 모델입니다." + }, + "model_duration": { + "name": "지속 시간" + }, + "prompt": { + "name": "프롬프트", + "tooltip": "비디오에서 다음에 일어나야 할 일을 설명하는 텍스트입니다." + }, + "seed": { + "name": "시드", + "tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다." + }, + "video": { + "name": "비디오", + "tooltip": "확장할 원본 비디오입니다. MP4 형식, 2-15초." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "프롬프트 또는 이미지를 통해 비디오를 생성합니다", "display_name": "Grok 비디오", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "참조 이미지를 스타일 및 콘텐츠 참조로 사용하여 비디오를 생성합니다.", + "display_name": "Grok 참조-비디오", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "model": { + "name": "모델", + "tooltip": "비디오 생성에 사용할 모델입니다." + }, + "model_aspect_ratio": { + "name": "종횡비" + }, + "model_duration": { + "name": "지속 시간" + }, + "model_resolution": { + "name": "해상도" + }, + "prompt": { + "name": "프롬프트", + "tooltip": "원하는 비디오를 설명하는 텍스트입니다." + }, + "seed": { + "name": "시드", + "tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "마스크 확장", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "ID-LoRA 화자 정체성 전이를 위한 참조 오디오를 설정합니다. 참조 오디오 clip을 컨디셔닝에 인코딩하고, 선택적으로 모델에 정체성 가이던스를 적용합니다(참조 없이 추가로 한 번 더 forward pass를 실행하여 화자 정체성 효과를 증폭).", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "인코딩을 위한 LTXV Audio VAE." + }, + "end_percent": { + "name": "end_percent", + "tooltip": "정체성 가이던스가 활성화되는 sigma 범위의 끝 지점입니다." + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "정체성 가이던스의 강도입니다. 각 단계마다 참조 없이 추가로 forward pass를 실행하여 화자 정체성을 증폭합니다. 0으로 설정하면 비활성화됩니다(추가 pass 없음)." + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "전이할 화자 정체성을 가진 참조 오디오 clip입니다. 약 5초(학습 시간)를 권장합니다. 더 짧거나 긴 clip은 음성 정체성 전이 품질이 저하될 수 있습니다." + }, + "start_percent": { + "name": "start_percent", + "tooltip": "정체성 가이던스가 활성화되는 sigma 범위의 시작 지점입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXV 스케줄러", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Quiver AI를 사용하여 래스터 이미지를 SVG로 벡터화합니다.", + "display_name": "Quiver 이미지 → SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "주요 피사체에 맞게 자동으로 자릅니다." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "벡터화할 입력 이미지입니다." + }, + "model": { + "name": "model", + "tooltip": "SVG 벡터화에 사용할 모델입니다." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Quiver AI를 사용하여 텍스트 프롬프트로부터 SVG를 생성합니다.", + "display_name": "Quiver 텍스트 → SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "추가 스타일 또는 포맷팅 가이드라인입니다." + }, + "model": { + "name": "model", + "tooltip": "SVG 생성에 사용할 모델입니다." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "원하는 SVG 출력에 대한 텍스트 설명입니다." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "생성을 안내할 최대 4개의 참조 이미지입니다." + }, + "seed": { + "name": "seed", + "tooltip": "노드가 다시 실행되어야 하는지 결정하는 시드입니다. 실제 결과는 시드와 관계없이 비결정적입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "최소 p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "반복 패널티" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "생각 중", + "tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "최소 p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "반복 패널티" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "생각 중", + "tooltip": "모델이 지원하는 경우 생각 모드로 작동합니다." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "긍정 조건", "tooltip": "학습에 사용할 긍정 조건입니다." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "training_dtype가 'none'이고 양자화된 모델에서 학습할 때, 활성화 시 양자화된 matmul로 역전파를 수행합니다." + }, "rank": { "name": "랭크", "tooltip": "LoRA 계층의 랭크입니다." diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 92a8fdb430..25a7afe5cc 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "시스템 제목 표시 줄을 숨기려면 사용자 정의 옵션을 선택하세요" }, + "Comfy_Appearance_DisableAnimations": { + "name": "애니메이션 비활성화", + "tooltip": "대부분의 CSS 애니메이션과 전환 효과를 끕니다. 디스플레이 GPU가 생성에도 사용될 때 추론 속도가 빨라집니다." + }, "Comfy_Canvas_BackgroundImage": { "name": "캔버스 배경 이미지", "tooltip": "캔버스 배경에 사용할 이미지 URL입니다. 출력 패널에서 이미지를 마우스 오른쪽 버튼으로 클릭한 후 \"배경으로 설정\"을 선택해 사용할 수 있습니다." @@ -95,6 +99,10 @@ "name": "부동 소수점 위젯 반올림 소수 자리 수 [0 = 자동]", "tooltip": "(페이지 새로 고침 필요)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "자동 팬 속도", + "tooltip": "캔버스 가장자리로 드래그할 때 자동 팬의 최대 속도입니다. 0으로 설정하면 자동 팬이 비활성화됩니다." + }, "Comfy_Graph_CanvasInfo": { "name": "왼쪽 하단 모서리에 캔버스 정보 표시 (fps 등)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "누락된 모델 경고 표시" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "누락된 노드 경고 표시" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "워크플로 저장 시 노드 ID 정렬" }, diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 1e05e67480..08322eb613 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -15,6 +15,14 @@ "message": "Este fluxo de trabalho contém nós de API, que exigem que você esteja conectado à sua conta para executar.", "title": "Login necessário para usar nós de API" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "Para a melhor experiência, o construtor de aplicativos utiliza Nodes 2.0. Você pode voltar após construir o aplicativo no menu principal.", + "dismiss": "Fechar", + "dontShowAgain": "Não mostrar novamente", + "title": "Alternado para Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "Todas as {category}", "allModels": "Todos os Modelos", @@ -556,19 +564,26 @@ "uploadCover": "+ Enviar uma capa", "uploadProfilePicture": "+ Enviar uma foto de perfil", "uploadWorkflowButton": "Enviar meu fluxo de trabalho", + "usernameError": "3–42 caracteres alfanuméricos minúsculos e hífens, deve começar e terminar com uma letra ou número", "usernameLabel": "Seu nome de usuário (obrigatório)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "Informações adicionais", "back": "Voltar", "createProfileCta": "Criar um perfil", "createProfileToPublish": "Crie um perfil para publicar no ComfyHub", "exampleImage": "Imagem de exemplo {index}", + "exampleImagePosition": "Imagem de exemplo {index} de {total}", "examplesDescription": "Adicione até {total} imagens de exemplo adicionais", "maxExamples": "Você pode selecionar até {max} exemplos", "next": "Avançar", "publishButton": "Publicar no ComfyHub", + "publishFailedDescription": "Algo deu errado ao publicar seu fluxo de trabalho. Por favor, tente novamente.", + "publishFailedTitle": "Falha ao publicar", + "removeExampleImage": "Remover imagem de exemplo", "selectAThumbnail": "Selecione uma miniatura", + "shareAs": "Compartilhar como", "showLessTags": "Mostrar menos...", "showMoreTags": "Mostrar mais...", "stepDescribe": "Descreva seu fluxo de trabalho", @@ -583,6 +598,7 @@ "thumbnailPreview": "Prévia da miniatura", "thumbnailVideo": "Vídeo", "title": "Publicar no ComfyHub", + "unsavedDescription": "Você precisa salvar seu fluxo de trabalho antes de publicar no ComfyHub. Salve agora para continuar.", "uploadAnImage": "Clique para procurar ou arraste uma imagem", "uploadComparison": "Enviar antes e depois", "uploadComparisonAfterPrompt": "Depois", @@ -598,13 +614,7 @@ "workflowDescription": "Descrição do fluxo de trabalho", "workflowDescriptionPlaceholder": "O que torna seu fluxo de trabalho interessante e especial? Seja específico para que as pessoas saibam o que esperar.", "workflowName": "Nome do fluxo de trabalho", - "workflowNamePlaceholder": "Dica: insira um nome descritivo e fácil de pesquisar", - "workflowType": "Tipo de fluxo de trabalho", - "workflowTypeEditing": "Edição", - "workflowTypeImageGeneration": "Geração de imagem", - "workflowTypePlaceholder": "Selecione o tipo", - "workflowTypeUpscaling": "Upscaling", - "workflowTypeVideoGeneration": "Geração de vídeo" + "workflowNamePlaceholder": "Dica: insira um nome descritivo e fácil de pesquisar" }, "commands": { "clear": "Limpar fluxo de trabalho", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "CONDICIONAMENTO", "CONTROL_NET": "controlnet", + "CURVE": "CURVA", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "ARQUIVO_3D", "FILE_3D_FBX": "ARQUIVO_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "ARQUIVOS DE ENTRADA GEMINI", "GLIGEN": "GLIGEN", "GUIDER": "ORIENTADOR", + "HISTOGRAM": "HISTOGRAMA", "HOOKS": "GANCHOS", "HOOK_KEYFRAMES": "QUADROS-CHAVE DE GANCHO", "IMAGE": "IMAGEM", @@ -876,6 +888,8 @@ "resume": "Retomar download" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "Ocorreu um erro", "extensionFileHint": "Isso pode ser devido ao seguinte script", "loadWorkflowTitle": "Carregamento abortado devido a erro ao recarregar os dados do fluxo de trabalho", @@ -924,6 +938,19 @@ "textToImage": "Texto para imagem", "textToVideo": "Texto para vídeo" }, + "execution": { + "decoding": "Decodificando…", + "encoding": "Codificando…", + "generating": "Gerando…", + "generatingVideo": "Gerando vídeo…", + "loading": "Carregando…", + "processing": "Processando…", + "processingVideo": "Processando vídeo…", + "resizing": "Redimensionando…", + "running": "Executando…", + "saving": "Salvando…", + "training": "Treinando…" + }, "exportToast": { "allExportsCompleted": "Todas as exportações concluídas", "downloadExport": "Baixar exportação", @@ -1064,11 +1091,14 @@ "filterBy": "Filtrar por:", "filterByType": "Filtrar por {type}...", "findIssues": "Encontrar problemas", + "findOnGithub": "Encontrar no GitHub", "frameNodes": "Enquadrar nós", "frontendNewer": "A versão do frontend {frontendVersion} pode não ser compatível com a versão do backend {backendVersion}.", "frontendOutdated": "A versão do frontend {frontendVersion} está desatualizada. O backend requer {requiredVersion} ou superior.", + "gallery": "Galeria", "galleryImage": "Imagem da galeria", "galleryThumbnail": "Miniatura da galeria", + "getHelpAction": "Obter Ajuda", "goToNode": "Ir para o nó", "graphNavigation": "Navegação no grafo", "halfSpeed": "0,5x", @@ -1077,6 +1107,8 @@ "icon": "Ícone", "imageDoesNotExist": "Imagem não existe", "imageFailedToLoad": "Falha ao carregar imagem", + "imageGallery": "galeria de imagens", + "imageLightbox": "Pré-visualização da imagem", "imagePreview": "Pré-visualização da imagem - Use as setas para navegar entre as imagens", "imageUrl": "URL da imagem", "import": "Importar", @@ -1090,13 +1122,38 @@ "installed": "Instalado", "installing": "Instalando", "interrupted": "Interrompido", - "itemSelected": "{selectedCount} item selecionado", "itemsCopiedToClipboard": "Itens copiados para a área de transferência", "itemsSelected": "{selectedCount} itens selecionados", "job": "Tarefa", "jobIdCopied": "ID da tarefa copiado para a área de transferência", "keybinding": "Atalho", "keybindingAlreadyExists": "Atalho já existe em", + "keybindingPresets": { + "default": "Predefinição padrão", + "deletePreset": "Excluir predefinição", + "deletePresetFailed": "Falha ao excluir a predefinição \"{name}\"", + "deletePresetTitle": "Excluir a predefinição atual?", + "deletePresetWarning": "Esta predefinição será excluída. Isso não pode ser desfeito.", + "discardAndSwitch": "Descartar e trocar", + "exportPreset": "Exportar predefinição", + "importKeybindingPreset": "Importar predefinição de atalhos", + "importPreset": "Importar predefinição", + "invalidPresetFile": "O arquivo de predefinição deve ser um JSON válido exportado do ComfyUI", + "invalidPresetName": "O nome da predefinição não pode estar vazio, ser \"default\", começar com ponto, conter separadores de caminho ou terminar com .json", + "loadPresetFailed": "Falha ao carregar a predefinição \"{name}\"", + "overwritePresetMessage": "Uma predefinição chamada \"{name}\" já existe. Deseja sobrescrevê-la?", + "overwritePresetTitle": "Sobrescrever Predefinição", + "presetDeleted": "Predefinição \"{name}\" excluída", + "presetImported": "Predefinição de atalhos importada", + "presetNamePrompt": "Digite um nome para a predefinição", + "presetSaved": "Predefinição \"{name}\" salva", + "resetToDefault": "Restaurar para o padrão", + "saveAndSwitch": "Salvar e trocar", + "saveAsNewPreset": "Salvar como nova predefinição", + "saveChanges": "Salvar alterações", + "unsavedChangesMessage": "Você tem alterações não salvas que serão perdidas se você trocar sem salvar.", + "unsavedChangesTo": "Alterações não salvas em {name}" + }, "keybindings": "Atalhos de teclado", "learnMore": "Saiba mais", "listening": "Ouvindo...", @@ -1153,6 +1210,8 @@ "output": "Saída", "overwrite": "Sobrescrever", "partner": "Parceiro", + "pause": "Pausar", + "play": "Reproduzir", "playPause": "Reproduzir/Pausar", "playRecording": "Reproduzir gravação", "playbackSpeed": "Velocidade de reprodução", @@ -1160,6 +1219,7 @@ "preloadError": "Um recurso necessário falhou ao carregar. Por favor, recarregue a página.", "preloadErrorTitle": "Erro de Carregamento", "preview": "PRÉVIA", + "previous": "Anterior", "previousImage": "Imagem anterior", "profile": "Perfil", "progressCountOf": "de", @@ -1234,6 +1294,8 @@ "showReport": "Mostrar relatório", "showRightPanel": "Mostrar painel direito", "singleSelectDropdown": "Menu suspenso de seleção única", + "skipToEnd": "Ir para o fim", + "skipToStart": "Ir para o início", "sort": "Ordenar", "source": "Fonte", "startRecording": "Iniciar gravação", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} Visite https://docs.comfy.org/installation/update_comfyui#common-update-issues para instruções de atualização.", "videoFailedToLoad": "Falha ao carregar vídeo", "videoPreview": "Pré-visualização do vídeo - Use as setas para navegar entre os vídeos", + "viewGrid": "Visualização em grade", "viewImageOfTotal": "Visualizar imagem {index} de {total}", "viewVideoOfTotal": "Visualizar vídeo {index} de {total}", "volume": "Volume", @@ -1832,6 +1895,7 @@ "mirrorVertical": "Espelhar verticalmente", "negative": "Negativo", "opacity": "Opacidade", + "openMaskEditor": "Abrir no Editor de Máscara", "paintBucketSettings": "Configurações do balde de tinta", "paintLayer": "Camada de pintura", "redo": "Refazer", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "Entradas que você favoritar aparecerão aqui", "favoritesNoneHint": "Na aba Parâmetros, clique em {moreIcon} em qualquer entrada para adicioná-la aqui", "favoritesNoneTooltip": "Marque widgets com estrela para acessá-los rapidamente sem selecionar nós", + "findOnGithubTooltip": "Pesquisar problemas relacionados no GitHub", + "getHelpTooltip": "Reporte este erro e nós ajudaremos você a resolvê-lo", "globalSettings": { "canvas": "CANVAS", "connectionLinks": "CONEXÕES", @@ -3085,7 +3152,6 @@ "title": "Sua assinatura foi cancelada" }, "changeTo": "Mudar para {plan}", - "chooseBestPlanWorkspace": "Escolha o melhor plano para seu workspace", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Logo do Comfy Cloud", "contactOwnerToSubscribe": "Entre em contato com o proprietário do espaço de trabalho para assinar", @@ -3115,6 +3181,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "Tem dúvidas ou interesse em soluções empresariais?", + "inviteUpTo": "Convide até", "invoiceHistory": "Histórico de faturas", "learnMore": "Saiba mais", "managePayment": "Gerenciar pagamento", @@ -3140,13 +3207,16 @@ "monthlyCreditsPerMemberLabel": "Créditos mensais / membro", "monthlyCreditsRollover": "Esses créditos serão transferidos para o próximo mês", "mostPopular": "Mais popular", + "needTeamWorkspace": "Precisa de um espaço de trabalho em equipe?", "nextBillingCycle": "próximo ciclo de cobrança", "nextMonthInvoice": "Fatura do próximo mês", "partnerNodesBalance": "Saldo de Créditos \"Partner Nodes\"", "partnerNodesCredits": "Preços dos Partner Nodes", "partnerNodesDescription": "Para executar modelos comerciais/proprietários", "perMonth": "/ mês", + "personalWorkspace": "Espaço de Trabalho Pessoal", "plansAndPricing": "Planos e preços", + "plansForWorkspace": "Planos para {workspace}", "prepaidCreditsInfo": "Créditos pré-pagos expiram após 1 ano da data de compra.", "prepaidDescription": "Créditos pré-pagos", "preview": { @@ -3182,6 +3252,7 @@ "resubscribe": "Reassinar", "resubscribeSuccess": "Assinatura reativada com sucesso", "resubscribeTo": "Reassinar {plan}", + "soloUseOnly": "Apenas para uso individual", "subscribeForMore": "Fazer upgrade", "subscribeNow": "Assine Agora", "subscribeTo": "Assinar {plan}", @@ -3189,6 +3260,7 @@ "subscribeToRun": "Assinar", "subscribeToRunFull": "Assine para Executar", "subscriptionRequiredMessage": "Uma assinatura é necessária para que os membros executem fluxos de trabalho na Nuvem", + "teamWorkspace": "Espaço de Trabalho em Equipe", "tierNameYearly": "{name} Anual", "tiers": { "creator": { @@ -3240,6 +3312,18 @@ "duplicateTab": "Duplicar aba", "removeFromBookmarks": "Remover dos favoritos" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "Espaço de trabalho criado, mas configuração incompleta", + "createWorkspace": "Criar espaço de trabalho", + "namePlaceholder": "ex: Equipe de Marketing", + "nameValidationError": "O nome deve ter entre 1 e 50 caracteres, usando letras, números, espaços ou pontuação comum.", + "newWorkspace": "Novo espaço de trabalho", + "subtitle": "Altere para um existente ou crie um novo espaço de trabalho", + "subtitleNoWorkspaces": "Crie um novo espaço de trabalho em equipe para compartilhar créditos", + "switch": "Trocar", + "title": "Espaços de Trabalho em Equipe", + "yourTeamWorkspaces": "Seus espaços de trabalho em equipe" + }, "templateWidgets": { "sort": { "searchPlaceholder": "Buscar..." @@ -3626,6 +3710,7 @@ }, "workspaceSwitcher": { "createWorkspace": "Criar novo espaço de trabalho", + "failedToSwitch": "Falha ao trocar de espaço de trabalho", "maxWorkspacesReached": "Você só pode possuir 10 espaços de trabalho. Exclua um para criar um novo.", "personal": "Pessoal", "roleMember": "Membro", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 4bb95a87e6..76c59da84d 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Conversão de Número", + "inputs": { + "value": { + "name": "valor" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Alternar", "inputs": { @@ -1736,6 +1752,10 @@ "name": "ciclo_fechado", "tooltip": "Se deve fechar o ciclo da janela de contexto; aplicável apenas para agendamentos em loop." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "Lista de índices latentes a serem mantidos nos tensores de condicionamento para cada janela. Por exemplo, definir como '0' usará a imagem inicial para cada janela." + }, "context_length": { "name": "comprimento_contexto", "tooltip": "O comprimento da janela de contexto." @@ -1767,6 +1787,10 @@ "model": { "name": "modelo", "tooltip": "O modelo ao qual aplicar as janelas de contexto durante a amostragem." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Se deve dividir múltiplos condicionamentos (criados por ConditionCombine) para cada janela com base no índice da região." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Editor de Curva", + "inputs": { + "curve": { + "name": "curva" + }, + "histogram": { + "name": "histograma" + } + }, + "outputs": { + "0": { + "name": "curva", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Combo Personalizado", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "Primeira imagem do processo de pensamento do modelo. Disponível apenas com thinking_level ALTO e modalidade IMAGEM+TEXTO." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "Estenda um vídeo existente com uma continuação perfeita baseada em um prompt de texto.", + "display_name": "Grok Video Extend", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo", + "tooltip": "O modelo a ser usado para extensão de vídeo." + }, + "model_duration": { + "name": "duração" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descrição em texto do que deve acontecer a seguir no vídeo." + }, + "seed": { + "name": "semente", + "tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente." + }, + "video": { + "name": "vídeo", + "tooltip": "Vídeo de origem para estender. Formato MP4, 2-15 segundos." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "Gere vídeo a partir de um prompt ou de uma imagem", "display_name": "Grok Video", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "Gere vídeo guiado por imagens de referência como referências de estilo e conteúdo.", + "display_name": "Grok Referência-para-Vídeo", + "inputs": { + "control_after_generate": { + "name": "controle após gerar" + }, + "model": { + "name": "modelo", + "tooltip": "O modelo a ser usado para geração de vídeo." + }, + "model_aspect_ratio": { + "name": "proporção" + }, + "model_duration": { + "name": "duração" + }, + "model_resolution": { + "name": "resolução" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descrição em texto do vídeo desejado." + }, + "seed": { + "name": "semente", + "tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "Expandir Máscara", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "Defina o áudio de referência para transferência de identidade de locutor com ID-LoRA. Codifica um clipe de áudio de referência no condicionamento e, opcionalmente, ajusta o modelo com orientação de identidade (passagem extra sem referência, amplificando o efeito de identidade do locutor).", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE para codificação." + }, + "end_percent": { + "name": "percentual_final", + "tooltip": "Fim do intervalo sigma onde a orientação de identidade está ativa." + }, + "identity_guidance_scale": { + "name": "escala_de_orientação_de_identidade", + "tooltip": "Intensidade da orientação de identidade. Executa uma passagem extra sem referência a cada etapa para amplificar a identidade do locutor. Defina como 0 para desativar (sem passagem extra)." + }, + "model": { + "name": "modelo" + }, + "negative": { + "name": "negativo" + }, + "positive": { + "name": "positivo" + }, + "reference_audio": { + "name": "áudio_de_referência", + "tooltip": "Clipe de áudio de referência cuja identidade do locutor será transferida. Recomenda-se cerca de 5 segundos (duração do treinamento). Clipe mais curto ou mais longo pode prejudicar a transferência da identidade vocal." + }, + "start_percent": { + "name": "percentual_inicial", + "tooltip": "Início do intervalo sigma onde a orientação de identidade está ativa." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positivo", + "tooltip": null + }, + "2": { + "name": "negativo", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVScheduler", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Vetorização de uma imagem raster para SVG usando Quiver AI.", + "display_name": "Quiver Imagem para SVG", + "inputs": { + "auto_crop": { + "name": "corte_automático", + "tooltip": "Recortar automaticamente para o sujeito dominante." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "imagem", + "tooltip": "Imagem de entrada para vetorizar." + }, + "model": { + "name": "modelo", + "tooltip": "Modelo a ser usado para vetorização SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "semente", + "tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Gerar um SVG a partir de um prompt de texto usando Quiver AI.", + "display_name": "Quiver Texto para SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instruções", + "tooltip": "Orientações adicionais de estilo ou formatação." + }, + "model": { + "name": "modelo", + "tooltip": "Modelo a ser usado para geração de SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "Descrição em texto do SVG desejado." + }, + "reference_images": { + "name": "imagens_de_referência", + "tooltip": "Até 4 imagens de referência para guiar a geração." + }, + "seed": { + "name": "semente", + "tooltip": "Semente para determinar se o nó deve ser executado novamente; os resultados reais são não determinísticos independentemente da semente." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "penalidade_de_presença" + }, "sampling_mode_repetition_penalty": { "name": "penalidade_de_repetição" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "pensando", + "tooltip": "Operar no modo de pensamento se o modelo suportar." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "penalidade_de_presença" + }, "sampling_mode_repetition_penalty": { "name": "penalidade_de_repetição" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "pensando", + "tooltip": "Operar no modo de pensamento se o modelo suportar." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "positivo", "tooltip": "O condicionamento positivo a ser usado para o treinamento." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "Ao usar 'training_dtype' como 'none' e treinar em um modelo quantizado, realiza o backward com matmul quantizado quando ativado." + }, "rank": { "name": "rank", "tooltip": "O rank das camadas da LoRA." diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index d0a0d9b858..41f060fdf7 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "Personalizado: Substitui a barra de título do sistema pelo menu superior do ComfyUI" }, + "Comfy_Appearance_DisableAnimations": { + "name": "Desativar animações", + "tooltip": "Desativa a maioria das animações e transições CSS. Acelera a inferência quando a GPU de exibição também é usada para geração." + }, "Comfy_Canvas_BackgroundImage": { "name": "Imagem de fundo do canvas", "tooltip": "URL da imagem para o fundo do canvas. Você pode clicar com o botão direito em uma imagem no painel de saídas e selecionar \"Definir como fundo\" para usá-la, ou enviar sua própria imagem usando o botão de upload." @@ -95,6 +99,10 @@ "name": "Casas decimais de arredondamento do widget float [0 = auto].", "tooltip": "(requer recarregar a página)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Velocidade de auto-deslocamento", + "tooltip": "Velocidade máxima ao auto-deslocar arrastando até a borda da tela. Defina como 0 para desativar o auto-deslocamento." + }, "Comfy_Graph_CanvasInfo": { "name": "Mostrar informações do canvas no canto inferior esquerdo (fps, etc.)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "Mostrar aviso de modelos ausentes" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "Mostrar aviso de nós ausentes" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "Ordenar IDs dos nós ao salvar fluxo de trabalho" }, diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 2ef1de1c99..3764669882 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -15,6 +15,14 @@ "message": "Этот рабочий процесс содержит API Nodes, которые требуют входа в вашу учетную запись для выполнения.", "title": "Требуется вход для использования API Nodes" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "Для наилучшего опыта App builder использует Nodes 2.0. Вы можете вернуться к предыдущей версии после создания приложения через главное меню.", + "dismiss": "Закрыть", + "dontShowAgain": "Больше не показывать", + "title": "Переключено на Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "Все {category}", "allModels": "Все модели", @@ -556,19 +564,26 @@ "uploadCover": "+ Загрузить обложку", "uploadProfilePicture": "+ Загрузить фото профиля", "uploadWorkflowButton": "Загрузить мой workflow", + "usernameError": "От 3 до 42 строчных буквенно-цифровых символов и дефисов, имя должно начинаться и заканчиваться буквой или цифрой", "usernameLabel": "Ваш никнейм (обязательно)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "Дополнительная информация", "back": "Назад", "createProfileCta": "Создать профиль", "createProfileToPublish": "Создайте профиль для публикации в ComfyHub", "exampleImage": "Пример изображения {index}", + "exampleImagePosition": "Пример изображения {index} из {total}", "examplesDescription": "Добавьте до {total} дополнительных примеров изображений", "maxExamples": "Вы можете выбрать до {max} примеров", "next": "Далее", "publishButton": "Опубликовать в ComfyHub", + "publishFailedDescription": "Произошла ошибка при публикации вашего рабочего процесса. Пожалуйста, попробуйте еще раз.", + "publishFailedTitle": "Ошибка публикации", + "removeExampleImage": "Удалить пример изображения", "selectAThumbnail": "Выберите миниатюру", + "shareAs": "Поделиться как", "showLessTags": "Показать меньше...", "showMoreTags": "Показать больше...", "stepDescribe": "Опишите ваш workflow", @@ -583,6 +598,7 @@ "thumbnailPreview": "Превью миниатюры", "thumbnailVideo": "Видео", "title": "Публикация в ComfyHub", + "unsavedDescription": "Вы должны сохранить свой рабочий процесс перед публикацией в ComfyHub. Сохраните его сейчас, чтобы продолжить.", "uploadAnImage": "Нажмите для выбора или перетащите изображение", "uploadComparison": "Загрузить до и после", "uploadComparisonAfterPrompt": "После", @@ -598,13 +614,7 @@ "workflowDescription": "Описание workflow", "workflowDescriptionPlaceholder": "Что делает ваш workflow интересным и особенным? Будьте конкретны, чтобы люди знали, чего ожидать.", "workflowName": "Название workflow", - "workflowNamePlaceholder": "Совет: введите описательное название, которое легко найти", - "workflowType": "Тип workflow", - "workflowTypeEditing": "Редактирование", - "workflowTypeImageGeneration": "Генерация изображений", - "workflowTypePlaceholder": "Выберите тип", - "workflowTypeUpscaling": "Увеличение разрешения", - "workflowTypeVideoGeneration": "Генерация видео" + "workflowNamePlaceholder": "Совет: введите описательное название, которое легко найти" }, "commands": { "clear": "Очистить рабочий процесс", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "КОНДИЦИОНИРОВАНИЕ", "CONTROL_NET": "CONTROL_NET", + "CURVE": "КРИВАЯ", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "ВХОДНЫЕ_ФАЙЛЫ_GEMINI", "GLIGEN": "GLIGEN", "GUIDER": "ГИД", + "HISTOGRAM": "ГИСТОГРАММА", "HOOKS": "ХУКИ", "HOOK_KEYFRAMES": "КЛЮЧЕВЫЕ_КАДРЫ_ХУКА", "IMAGE": "ИЗОБРАЖЕНИЕ", @@ -876,6 +888,8 @@ "resume": "Возобновить загрузку" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "Произошла ошибка", "extensionFileHint": "Это может быть связано со следующим скриптом", "loadWorkflowTitle": "Загрузка прервана из-за ошибки при перезагрузке данных рабочего процесса", @@ -924,6 +938,19 @@ "textToImage": "Текст в изображение", "textToVideo": "Текст в видео" }, + "execution": { + "decoding": "Декодирование…", + "encoding": "Кодирование…", + "generating": "Генерация…", + "generatingVideo": "Генерация видео…", + "loading": "Загрузка…", + "processing": "Обработка…", + "processingVideo": "Обработка видео…", + "resizing": "Изменение размера…", + "running": "Выполнение…", + "saving": "Сохранение…", + "training": "Обучение…" + }, "exportToast": { "allExportsCompleted": "Все экспорты завершены", "downloadExport": "Скачать экспорт", @@ -1064,11 +1091,14 @@ "filterBy": "Фильтр по:", "filterByType": "Фильтр по {type}...", "findIssues": "Найти проблемы", + "findOnGithub": "Найти на GitHub", "frameNodes": "Фреймовые ноды", "frontendNewer": "Версия интерфейса {frontendVersion} может быть несовместима с версией сервера {backendVersion}.", "frontendOutdated": "Версия интерфейса {frontendVersion} устарела. Требуется версия не ниже {requiredVersion} для работы с сервером.", + "gallery": "Галерея", "galleryImage": "Изображение галереи", "galleryThumbnail": "Миниатюра галереи", + "getHelpAction": "Получить помощь", "goToNode": "Перейти к ноде", "graphNavigation": "Навигация по графу", "halfSpeed": "0.5x", @@ -1077,6 +1107,8 @@ "icon": "Иконка", "imageDoesNotExist": "Изображение не существует", "imageFailedToLoad": "Не удалось загрузить изображение", + "imageGallery": "галерея изображений", + "imageLightbox": "Просмотр изображения", "imagePreview": "Предварительный просмотр изображения - Используйте клавиши со стрелками для навигации между изображениями", "imageUrl": "URL изображения", "import": "Импорт", @@ -1090,13 +1122,38 @@ "installed": "Установлено", "installing": "Установка", "interrupted": "Прервано", - "itemSelected": "Выбран {selectedCount} элемент", "itemsCopiedToClipboard": "Элементы скопированы в буфер обмена", "itemsSelected": "Выбрано {selectedCount} элементов", "job": "Задача", "jobIdCopied": "ID задания скопирован в буфер обмена", "keybinding": "Привязка клавиш", "keybindingAlreadyExists": "Горячая клавиша уже существует", + "keybindingPresets": { + "default": "Стандартный пресет", + "deletePreset": "Удалить пресет", + "deletePresetFailed": "Не удалось удалить пресет «{name}»", + "deletePresetTitle": "Удалить текущий пресет?", + "deletePresetWarning": "Этот пресет будет удалён. Это действие необратимо.", + "discardAndSwitch": "Отменить и переключиться", + "exportPreset": "Экспортировать пресет", + "importKeybindingPreset": "Импортировать пресет горячих клавиш", + "importPreset": "Импортировать пресет", + "invalidPresetFile": "Файл пресета должен быть валидным JSON, экспортированным из ComfyUI", + "invalidPresetName": "Имя пресета не должно быть пустым, «default», начинаться с точки, содержать разделители пути или заканчиваться на .json", + "loadPresetFailed": "Не удалось загрузить пресет «{name}»", + "overwritePresetMessage": "Пресет с именем «{name}» уже существует. Перезаписать его?", + "overwritePresetTitle": "Перезаписать пресет", + "presetDeleted": "Пресет «{name}» удалён", + "presetImported": "Пресет горячих клавиш импортирован", + "presetNamePrompt": "Введите имя для пресета", + "presetSaved": "Пресет «{name}» сохранён", + "resetToDefault": "Сбросить к стандартным", + "saveAndSwitch": "Сохранить и переключиться", + "saveAsNewPreset": "Сохранить как новый пресет", + "saveChanges": "Сохранить изменения", + "unsavedChangesMessage": "У вас есть несохранённые изменения, которые будут потеряны, если вы переключитесь без сохранения.", + "unsavedChangesTo": "Несохранённые изменения в {name}" + }, "keybindings": "Горячие клавиши", "learnMore": "Узнать больше", "listening": "Слушаем...", @@ -1153,6 +1210,8 @@ "output": "Выход", "overwrite": "Перезаписать", "partner": "Партнёр", + "pause": "Пауза", + "play": "Воспроизвести", "playPause": "Воспроизвести/Пауза", "playRecording": "Воспроизвести запись", "playbackSpeed": "Скорость воспроизведения", @@ -1160,6 +1219,7 @@ "preloadError": "Не удалось загрузить необходимый ресурс. Пожалуйста, перезагрузите страницу.", "preloadErrorTitle": "Ошибка загрузки", "preview": "ПРЕДПРОСМОТР", + "previous": "Назад", "previousImage": "Предыдущее изображение", "profile": "Профиль", "progressCountOf": "из", @@ -1234,6 +1294,8 @@ "showReport": "Показать отчёт", "showRightPanel": "Показать правую панель", "singleSelectDropdown": "Выпадающий список единичного выбора", + "skipToEnd": "В конец", + "skipToStart": "В начало", "sort": "Сортировать", "source": "Источник", "startRecording": "Начать запись", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.", "videoFailedToLoad": "Не удалось загрузить видео", "videoPreview": "Предварительный просмотр видео - Используйте клавиши со стрелками для навигации между видео", + "viewGrid": "Вид сетки", "viewImageOfTotal": "Просмотр изображения {index} из {total}", "viewVideoOfTotal": "Просмотр видео {index} из {total}", "volume": "Громкость", @@ -1832,6 +1895,7 @@ "mirrorVertical": "Отразить по вертикали", "negative": "Негатив", "opacity": "Непрозрачность", + "openMaskEditor": "Открыть в редакторе масок", "paintBucketSettings": "Настройки заливки", "paintLayer": "Слой рисования", "redo": "Повторить", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "Здесь будут отображаться ваши избранные входы", "favoritesNoneHint": "Во вкладке Параметры нажмите {moreIcon} на любом входе, чтобы добавить его сюда", "favoritesNoneTooltip": "Отметьте виджеты звёздочкой для быстрого доступа без выбора узлов", + "findOnGithubTooltip": "Искать похожие проблемы на GitHub", + "getHelpTooltip": "Сообщите об этой ошибке, и мы поможем вам её решить", "globalSettings": { "canvas": "ХОЛСТ", "connectionLinks": "СВЯЗИ", @@ -3073,7 +3140,6 @@ "title": "Ваша подписка отменена" }, "changeTo": "Перейти на {plan}", - "chooseBestPlanWorkspace": "Выберите лучший тариф для вашего рабочего пространства", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Логотип Comfy Cloud", "contactOwnerToSubscribe": "Свяжитесь с владельцем рабочего пространства для оформления подписки", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (96ГБ VRAM)", "haveQuestions": "Есть вопросы или интересует корпоративное решение?", + "inviteUpTo": "Пригласить до", "invoiceHistory": "История счетов", "learnMore": "Узнать больше", "managePayment": "Управление оплатой", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "Ежемесячные кредиты / участник", "monthlyCreditsRollover": "Эти кредиты переносятся на следующий месяц", "mostPopular": "Самый популярный", + "needTeamWorkspace": "Нужно командное рабочее пространство?", "nextBillingCycle": "следующий платёжный цикл", "nextMonthInvoice": "Счет на следующий месяц", "partnerNodesBalance": "Баланс кредитов \"Партнёрских узлов\"", "partnerNodesCredits": "Кредиты партнёрских узлов", "partnerNodesDescription": "Для запуска коммерческих/проприетарных моделей", "perMonth": "USD / месяц", + "personalWorkspace": "Личное рабочее пространство", "plansAndPricing": "Планы и цены", + "plansForWorkspace": "Тарифы для {workspace}", "prepaidCreditsInfo": "Кредиты, приобретённые отдельно и не имеющие срока действия", "prepaidDescription": "Предоплаченные кредиты", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "Возобновить подписку", "resubscribeSuccess": "Подписка успешно возобновлена", "resubscribeTo": "Возобновить подписку на {plan}", + "soloUseOnly": "Только для индивидуального использования", "subscribeForMore": "Обновить", "subscribeNow": "Подписаться сейчас", "subscribeTo": "Подписаться на {plan}", @@ -3177,6 +3248,7 @@ "subscribeToRun": "Подписаться", "subscribeToRunFull": "Подписаться для запуска", "subscriptionRequiredMessage": "Для запуска рабочих процессов в облаке участникам требуется подписка", + "teamWorkspace": "Командное рабочее пространство", "tierNameYearly": "{name} Ежегодно", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "Дублировать вкладку", "removeFromBookmarks": "Удалить из закладок" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "Рабочее пространство создано, но настройка не завершена", + "createWorkspace": "Создать рабочее пространство", + "namePlaceholder": "например, Маркетинговая команда", + "nameValidationError": "Имя должно содержать от 1 до 50 символов: буквы, цифры, пробелы или стандартные знаки препинания.", + "newWorkspace": "Новое рабочее пространство", + "subtitle": "Переключитесь на существующее или создайте новое рабочее пространство", + "subtitleNoWorkspaces": "Создайте новое командное рабочее пространство для совместного использования кредитов", + "switch": "Переключить", + "title": "Командные рабочие пространства", + "yourTeamWorkspaces": "Ваши командные рабочие пространства" + }, "templateWidgets": { "sort": { "searchPlaceholder": "Поиск..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "Создать новое рабочее пространство", + "failedToSwitch": "Не удалось переключить рабочее пространство", "maxWorkspacesReached": "Вы можете владеть только 10 рабочими пространствами. Удалите одно, чтобы создать новое.", "personal": "Личное", "roleMember": "Участник", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index cc6bdb47bb..003ec86a58 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Преобразование числа", + "inputs": { + "value": { + "name": "значение" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Переключатель", "inputs": { @@ -1736,6 +1752,10 @@ "name": "замкнутый_цикл", "tooltip": "Замыкать ли цикл контекстного окна; применимо только к циклическим расписаниям." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "Список индексов латентных пространств, которые нужно сохранить в тензорах кондиционирования для каждого окна. Например, если указать '0', для каждого окна будет использоваться исходное стартовое изображение." + }, "context_length": { "name": "длина_контекста", "tooltip": "Длина контекстного окна." @@ -1767,6 +1787,10 @@ "model": { "name": "модель", "tooltip": "Модель, к которой применяются контекстные окна во время сэмплирования." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Разделять ли несколько кондиционирований (созданных с помощью ConditionCombine) по каждому окну на основе индекса региона." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Редактор кривых", + "inputs": { + "curve": { + "name": "кривая" + }, + "histogram": { + "name": "гистограмма" + } + }, + "outputs": { + "0": { + "name": "кривая", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Пользовательская комбинация", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "Первое изображение из процесса мышления модели. Доступно только при уровне мышления HIGH и модальности IMAGE+TEXT." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "Расширьте существующее видео с плавным продолжением на основе текстового запроса.", + "display_name": "Grok Video Extend", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "модель", + "tooltip": "Модель, используемая для расширения видео." + }, + "model_duration": { + "name": "длительность" + }, + "prompt": { + "name": "запрос", + "tooltip": "Текстовое описание того, что должно произойти дальше в видео." + }, + "seed": { + "name": "seed", + "tooltip": "Seed для определения необходимости повторного запуска узла; фактические результаты недетерминированы независимо от seed." + }, + "video": { + "name": "видео", + "tooltip": "Исходное видео для расширения. Формат MP4, 2-15 секунд." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "Генерировать видео по запросу или изображению", "display_name": "Grok Video", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "Генерируйте видео, используя референсные изображения в качестве стиля и содержания.", + "display_name": "Grok Reference-to-Video", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "модель", + "tooltip": "Модель, используемая для генерации видео." + }, + "model_aspect_ratio": { + "name": "соотношение сторон" + }, + "model_duration": { + "name": "длительность" + }, + "model_resolution": { + "name": "разрешение" + }, + "prompt": { + "name": "запрос", + "tooltip": "Текстовое описание желаемого видео." + }, + "seed": { + "name": "seed", + "tooltip": "Seed для определения необходимости повторного запуска узла; фактические результаты недетерминированы независимо от seed." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "Увеличить маску", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "Установите эталонное аудио для переноса идентичности диктора с помощью ID-LoRA. Кодирует эталонный аудиоклип в conditioning и при необходимости вносит изменения в модель с помощью identity guidance (дополнительный прямой проход без эталона, усиливающий эффект идентичности диктора).", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "LTXV Audio VAE для кодирования." + }, + "end_percent": { + "name": "end_percent", + "tooltip": "Конец диапазона сигмы, в котором активна identity guidance." + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "Сила identity guidance. Выполняет дополнительный прямой проход без эталона на каждом шаге для усиления идентичности диктора. Установите 0 для отключения (без дополнительного прохода)." + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "Эталонный аудиоклип, идентичность диктора которого будет перенесена. Рекомендуется ~5 секунд (длительность обучения). Более короткие или длинные клипы могут ухудшить перенос голосовой идентичности." + }, + "start_percent": { + "name": "start_percent", + "tooltip": "Начало диапазона сигмы, в котором активна identity guidance." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "Scheduler LTXV", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Векторизация растрового изображения в SVG с помощью Quiver AI.", + "display_name": "Quiver Image to SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "Автоматически обрезать по доминирующему объекту." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "Входное изображение для векторизации." + }, + "model": { + "name": "model", + "tooltip": "Модель для векторизации SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты недетерминированы независимо от сида." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Генерация SVG по текстовому запросу с помощью Quiver AI.", + "display_name": "Quiver Text to SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "Дополнительные рекомендации по стилю или форматированию." + }, + "model": { + "name": "model", + "tooltip": "Модель для генерации SVG." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "Текстовое описание желаемого SVG-результата." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "До 4 референсных изображений для направления генерации." + }, + "seed": { + "name": "seed", + "tooltip": "Сид для определения необходимости повторного запуска узла; фактические результаты недетерминированы независимо от сида." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "штраф за присутствие" + }, "sampling_mode_repetition_penalty": { "name": "штраф за повторение" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "мышление", + "tooltip": "Работать в режиме мышления, если модель это поддерживает." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "минимальное p" }, + "sampling_mode_presence_penalty": { + "name": "штраф за присутствие" + }, "sampling_mode_repetition_penalty": { "name": "штраф за повторение" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "мышление", + "tooltip": "Работать в режиме мышления, если модель это поддерживает." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "позитивное условие", "tooltip": "Позитивное условие, используемое для обучения." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "При использовании training_dtype 'none' и обучении на квантизированной модели, при включении выполняется обратное распространение с квантизированным матричным умножением." + }, "rank": { "name": "ранг", "tooltip": "Ранг слоёв LoRA." diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 5f4fcb3d73..6d61e1cf26 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "Выберите пользовательский вариант, чтобы скрыть системную строку заголовка" }, + "Comfy_Appearance_DisableAnimations": { + "name": "Отключить анимации", + "tooltip": "Отключает большинство CSS-анимаций и переходов. Ускоряет инференс, если видеокарта используется также для генерации." + }, "Comfy_Canvas_BackgroundImage": { "name": "Фоновое изображение холста", "tooltip": "URL изображения для фона холста. Вы можете кликнуть правой кнопкой мыши на изображении в панели результатов и выбрать «Установить как фон», чтобы использовать его." @@ -95,6 +99,10 @@ "name": "Количество знаков после запятой для округления плавающего виджета [0 = авто].", "tooltip": "(требуется перезагрузка страницы)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Скорость автопрокрутки", + "tooltip": "Максимальная скорость автопрокрутки при перетаскивании к краю холста. Установите 0 для отключения автопрокрутки." + }, "Comfy_Graph_CanvasInfo": { "name": "Показать информацию о холсте в нижнем левом углу (fps и т.д.)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "Показать предупреждение об отсутствующих моделях" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "Показать предупреждение об отсутствующих нодах" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "Сортировать ID нод при сохранении рабочего процесса" }, diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index a0870e0715..041547cbdf 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -15,6 +15,14 @@ "message": "Bu iş akışı, çalıştırmak için hesabınıza giriş yapmanızı gerektiren API Düğümleri içerir.", "title": "API Düğümlerini Kullanmak İçin Giriş Yapılması Gerekiyor" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "En iyi deneyim için, Uygulama oluşturucu Nodes 2.0'ı kullanır. Uygulamayı oluşturduktan sonra ana menüden geri dönebilirsiniz.", + "dismiss": "Kapat", + "dontShowAgain": "Bir daha gösterme", + "title": "Nodes 2.0'a Geçildi" + } + }, "assetBrowser": { "allCategory": "Tüm {category}", "allModels": "Tüm Modeller", @@ -556,19 +564,26 @@ "uploadCover": "+ Kapak yükle", "uploadProfilePicture": "+ Profil fotoğrafı yükle", "uploadWorkflowButton": "Çalışma akışımı yükle", + "usernameError": "3–42 küçük harfli alfanümerik karakter ve tire, bir harf veya rakam ile başlamalı ve bitmelidir", "usernameLabel": "Kullanıcı adınız (gerekli)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "Ek bilgi", "back": "Geri", "createProfileCta": "Profil oluştur", "createProfileToPublish": "ComfyHub'da yayınlamak için bir profil oluşturun", "exampleImage": "Örnek görsel {index}", + "exampleImagePosition": "Örnek görsel {index} / {total}", "examplesDescription": "En fazla {total} ek örnek görsel ekleyin", "maxExamples": "En fazla {max} örnek seçebilirsiniz", "next": "İleri", "publishButton": "ComfyHub'da Yayınla", + "publishFailedDescription": "Çalışma akışınızı yayınlarken bir hata oluştu. Lütfen tekrar deneyin.", + "publishFailedTitle": "Yayınlama başarısız", + "removeExampleImage": "Örnek görseli kaldır", "selectAThumbnail": "Bir küçük resim seçin", + "shareAs": "Olarak paylaş", "showLessTags": "Daha az göster...", "showMoreTags": "Daha fazla göster...", "stepDescribe": "Çalışma akışınızı açıklayın", @@ -583,6 +598,7 @@ "thumbnailPreview": "Küçük resim önizlemesi", "thumbnailVideo": "Video", "title": "ComfyHub'da Yayınla", + "unsavedDescription": "Çalışma akışınızı ComfyHub'da yayınlamadan önce kaydetmelisiniz. Devam etmek için şimdi kaydedin.", "uploadAnImage": "Göz atmak için tıklayın veya bir görsel sürükleyin", "uploadComparison": "Öncesi ve sonrası yükle", "uploadComparisonAfterPrompt": "Sonra", @@ -598,13 +614,7 @@ "workflowDescription": "Çalışma akışı açıklaması", "workflowDescriptionPlaceholder": "Çalışma akışınızı heyecan verici ve özel kılan nedir? İnsanların ne bekleyeceğini bilmesi için ayrıntılı olun.", "workflowName": "Çalışma akışı adı", - "workflowNamePlaceholder": "İpucu: Araması kolay, açıklayıcı bir isim girin", - "workflowType": "Çalışma akışı türü", - "workflowTypeEditing": "Düzenleme", - "workflowTypeImageGeneration": "Görüntü oluşturma", - "workflowTypePlaceholder": "Türü seçin", - "workflowTypeUpscaling": "Yükseltme", - "workflowTypeVideoGeneration": "Video oluşturma" + "workflowNamePlaceholder": "İpucu: Araması kolay, açıklayıcı bir isim girin" }, "commands": { "clear": "İş akışını temizle", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "KOŞULLANDIRMA", "CONTROL_NET": "KONTROL_AĞI", + "CURVE": "EĞRİ", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "GEMINI_GİRİŞ_DOSYALARI", "GLIGEN": "GLIGEN", "GUIDER": "REHBER", + "HISTOGRAM": "HİSTOGRAM", "HOOKS": "Kancalar", "HOOK_KEYFRAMES": "Kanca_anahtar_kareleri", "IMAGE": "GÖRÜNTÜ", @@ -876,6 +888,8 @@ "resume": "İndirmeye Devam Et" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "Bir hata oluştu", "extensionFileHint": "Bu, aşağıdaki komut dosyasından kaynaklanıyor olabilir", "loadWorkflowTitle": "İş akışı verileri yeniden yüklenirken hata nedeniyle yükleme iptal edildi", @@ -924,6 +938,19 @@ "textToImage": "Metinden Görüntüye", "textToVideo": "Metinden Videoya" }, + "execution": { + "decoding": "Kod çözülüyor…", + "encoding": "Kodlanıyor…", + "generating": "Oluşturuluyor…", + "generatingVideo": "Video oluşturuluyor…", + "loading": "Yükleniyor…", + "processing": "İşleniyor…", + "processingVideo": "Video işleniyor…", + "resizing": "Yeniden boyutlandırılıyor…", + "running": "Çalışıyor…", + "saving": "Kaydediliyor…", + "training": "Eğitiliyor…" + }, "exportToast": { "allExportsCompleted": "Tüm dışa aktarmalar tamamlandı", "downloadExport": "Dışa aktarmayı indir", @@ -1064,11 +1091,14 @@ "filterBy": "Filtrele:", "filterByType": "{type} ile filtrele...", "findIssues": "Sorunları Bul", + "findOnGithub": "GitHub'da bul", "frameNodes": "Çerçeve Düğümleri", "frontendNewer": "Ön uç sürümü {frontendVersion}, arka uç sürümü {backendVersion} ile uyumlu olmayabilir.", "frontendOutdated": "Ön uç sürümü {frontendVersion} güncel değil. Arka uç {requiredVersion} veya daha yüksek bir sürüm gerektiriyor.", + "gallery": "Galeri", "galleryImage": "Galeri görüntüsü", "galleryThumbnail": "Galeri küçük resmi", + "getHelpAction": "Yardım Al", "goToNode": "Düğüme Git", "graphNavigation": "Grafik gezintisi", "halfSpeed": "0.5x", @@ -1077,6 +1107,8 @@ "icon": "Simge", "imageDoesNotExist": "Görsel mevcut değil", "imageFailedToLoad": "Görsel yüklenemedi", + "imageGallery": "görsel galerisi", + "imageLightbox": "Görüntü önizlemesi", "imagePreview": "Görüntü önizlemesi - Görüntüler arasında gezinmek için ok tuşlarını kullanın", "imageUrl": "Görsel URL'si", "import": "İçe Aktar", @@ -1090,13 +1122,38 @@ "installed": "Yüklendi", "installing": "Yükleniyor", "interrupted": "Kesintiye uğradı", - "itemSelected": "{selectedCount} öğe seçildi", "itemsCopiedToClipboard": "Öğeler panoya kopyalandı", "itemsSelected": "{selectedCount} öğe seçildi", "job": "Görev", "jobIdCopied": "İş kimliği panoya kopyalandı", "keybinding": "Tuş Ataması", "keybindingAlreadyExists": "Tuş ataması zaten mevcut:", + "keybindingPresets": { + "default": "Varsayılan Ön Ayar", + "deletePreset": "Ön ayarı sil", + "deletePresetFailed": "\"{name}\" ön ayarı silinemedi", + "deletePresetTitle": "Mevcut ön ayar silinsin mi?", + "deletePresetWarning": "Bu ön ayar silinecek. Bu işlem geri alınamaz.", + "discardAndSwitch": "Vazgeç ve Geçiş Yap", + "exportPreset": "Ön ayarı dışa aktar", + "importKeybindingPreset": "Klavye kısayol ön ayarını içe aktar", + "importPreset": "Ön ayarı içe aktar", + "invalidPresetFile": "Ön ayar dosyası, ComfyUI'den dışa aktarılmış geçerli bir JSON olmalıdır", + "invalidPresetName": "Ön ayar adı boş, \"default\", nokta ile başlayan, yol ayırıcı içeren veya .json ile biten bir ad olamaz", + "loadPresetFailed": "\"{name}\" ön ayarı yüklenemedi", + "overwritePresetMessage": "\"{name}\" adlı bir ön ayar zaten var. Üzerine yazılsın mı?", + "overwritePresetTitle": "Ön Ayarı Üzerine Yaz", + "presetDeleted": "\"{name}\" ön ayarı silindi", + "presetImported": "Klavye kısayol ön ayarı içe aktarıldı", + "presetNamePrompt": "Ön ayar için bir ad girin", + "presetSaved": "\"{name}\" ön ayarı kaydedildi", + "resetToDefault": "Varsayılana sıfırla", + "saveAndSwitch": "Kaydet ve Geçiş Yap", + "saveAsNewPreset": "Yeni ön ayar olarak kaydet", + "saveChanges": "Değişiklikleri Kaydet", + "unsavedChangesMessage": "Kaydetmeden geçiş yaparsanız kaydedilmemiş değişiklikler kaybolacak.", + "unsavedChangesTo": "{name} için kaydedilmemiş değişiklikler" + }, "keybindings": "Kısayol Tuşları", "learnMore": "Daha fazla bilgi edinin", "listening": "Dinleniyor...", @@ -1153,6 +1210,8 @@ "output": "Çıktı", "overwrite": "Üzerine Yaz", "partner": "Partner", + "pause": "Duraklat", + "play": "Oynat", "playPause": "Oynat/Duraklat", "playRecording": "Kaydı Oynat", "playbackSpeed": "Oynatma Hızı", @@ -1160,6 +1219,7 @@ "preloadError": "Gerekli bir kaynak yüklenemedi. Lütfen sayfayı yeniden yükleyin.", "preloadErrorTitle": "Yükleme Hatası", "preview": "ÖNİZLEME", + "previous": "Önceki", "previousImage": "Önceki görsel", "profile": "Profil", "progressCountOf": "/", @@ -1234,6 +1294,8 @@ "showReport": "Raporu Göster", "showRightPanel": "Sağ paneli göster", "singleSelectDropdown": "Tekli seçim açılır menüsü", + "skipToEnd": "Sona Atla", + "skipToStart": "Başa Atla", "sort": "Sırala", "source": "Kaynak", "startRecording": "Kaydı Başlat", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}: {detail} Güncelleme talimatları için https://docs.comfy.org/installation/update_comfyui#common-update-issues adresini ziyaret edin.", "videoFailedToLoad": "Video yüklenemedi", "videoPreview": "Video önizlemesi - Videolar arasında gezinmek için ok tuşlarını kullanın", + "viewGrid": "Izgara görünümü", "viewImageOfTotal": "{total} görüntüden {index}. görüntüyü görüntüle", "viewVideoOfTotal": "{total} videodan {index}. videoyu görüntüle", "volume": "Ses", @@ -1832,6 +1895,7 @@ "mirrorVertical": "Dikey ayna", "negative": "Negatif", "opacity": "Opaklık", + "openMaskEditor": "Mask Düzenleyicide Aç", "paintBucketSettings": "Boya Kovası Ayarları", "paintLayer": "Boya Katmanı", "redo": "Yinele", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "Favorilere eklediğiniz girdiler burada görünecek", "favoritesNoneHint": "Parametreler sekmesinde, herhangi bir girdinin yanındaki {moreIcon} simgesine tıklayarak buraya ekleyebilirsiniz", "favoritesNoneTooltip": "Düğüm seçmeden hızlı erişim için widget'ları yıldızlayın", + "findOnGithubTooltip": "İlgili sorunlar için GitHub'da ara", + "getHelpTooltip": "Bu hatayı bildir, çözmen için sana yardımcı olalım", "globalSettings": { "canvas": "TUVAL", "connectionLinks": "BAĞLANTI LİNKLERİ", @@ -3073,7 +3140,6 @@ "title": "Aboneliğiniz iptal edildi" }, "changeTo": "{plan} planına geç", - "chooseBestPlanWorkspace": "Çalışma alanınız için en iyi planı seçin", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud Logosu", "contactOwnerToSubscribe": "Abone olmak için çalışma alanı sahibiyle iletişime geçin", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "Sorularınız mı var veya kurumsal çözüm mü arıyorsunuz?", + "inviteUpTo": "Şu kadar kişiyi davet et:", "invoiceHistory": "Fatura geçmişi", "learnMore": "Daha fazla bilgi edinin", "managePayment": "Ödemeyi Yönet", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "Aylık kredi / üye", "monthlyCreditsRollover": "Bu krediler sonraki aya devredilecek", "mostPopular": "En popüler", + "needTeamWorkspace": "Takım çalışma alanına mı ihtiyacınız var?", "nextBillingCycle": "sonraki fatura döngüsü", "nextMonthInvoice": "Gelecek ay faturası", "partnerNodesBalance": "\"Partner Düğümleri\" Kredi Bakiyesi", "partnerNodesCredits": "Partner Düğümleri kredileri", "partnerNodesDescription": "Ticari/özel modelleri çalıştırmak için", "perMonth": "USD / ay", + "personalWorkspace": "Kişisel Çalışma Alanı", "plansAndPricing": "Planlar ve fiyatlandırma", + "plansForWorkspace": "{workspace} için planlar", "prepaidCreditsInfo": "Ayrıca satın alınan ve son kullanma tarihi olmayan krediler", "prepaidDescription": "Ön ödemeli krediler", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "Yeniden abone ol", "resubscribeSuccess": "Abonelik başarıyla yeniden etkinleştirildi", "resubscribeTo": "{plan} planına yeniden abone ol", + "soloUseOnly": "Sadece bireysel kullanım", "subscribeForMore": "Yükselt", "subscribeNow": "Hemen Abone Ol", "subscribeTo": "{plan} abonesi ol", @@ -3177,6 +3248,7 @@ "subscribeToRun": "Abone Ol", "subscribeToRunFull": "Çalıştırmaya Abone Ol", "subscriptionRequiredMessage": "Üyelerin Bulut'ta iş akışlarını çalıştırabilmesi için abonelik gereklidir", + "teamWorkspace": "Takım Çalışma Alanı", "tierNameYearly": "{name} Yıllık", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "Sekmeyi Çoğalt", "removeFromBookmarks": "Yer İmlerinden Kaldır" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "Çalışma alanı oluşturuldu ancak kurulum tamamlanamadı", + "createWorkspace": "Çalışma alanı oluştur", + "namePlaceholder": "örn. Pazarlama Takımı", + "nameValidationError": "İsim, harf, rakam, boşluk veya yaygın noktalama işaretleriyle 1–50 karakter olmalıdır.", + "newWorkspace": "Yeni çalışma alanı", + "subtitle": "Mevcut birine geçin veya yeni bir çalışma alanı oluşturun", + "subtitleNoWorkspaces": "Kredileri paylaşmak için yeni bir takım çalışma alanı oluşturun", + "switch": "Geçiş yap", + "title": "Takım Çalışma Alanları", + "yourTeamWorkspaces": "Takım çalışma alanlarınız" + }, "templateWidgets": { "sort": { "searchPlaceholder": "Ara..." @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "Yeni çalışma alanı oluştur", + "failedToSwitch": "Çalışma alanı değiştirilemedi", "maxWorkspacesReached": "Yalnızca 10 çalışma alanına sahip olabilirsiniz. Yeni bir tane oluşturmak için birini silin.", "personal": "Kişisel", "roleMember": "Üye", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 9698a6b21e..ea6911c68f 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "Sayı Dönüştürme", + "inputs": { + "value": { + "name": "değer" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "Anahtar", "inputs": { @@ -1736,6 +1752,10 @@ "name": "kapalı döngü", "tooltip": "Bağlam penceresi döngüsünün kapatılıp kapatılmayacağı; sadece döngülü çizelgeler için geçerlidir." }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "Her pencere için koşullandırma tensörlerinde tutulacak latent indekslerin listesi; örneğin, bunu '0' olarak ayarlamak, her pencere için başlangıç görselini kullanır." + }, "context_length": { "name": "bağlam uzunluğu", "tooltip": "Bağlam penceresinin uzunluğu." @@ -1767,6 +1787,10 @@ "model": { "name": "model", "tooltip": "Örnekleme sırasında bağlam pencerelerinin uygulanacağı model." + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "Birden fazla koşullandırmanın (ConditionCombine tarafından oluşturulan) bölge indeksine göre her pencereye bölünüp bölünmeyeceği." } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "Eğri Düzenleyici", + "inputs": { + "curve": { + "name": "eğri" + }, + "histogram": { + "name": "histogram" + } + }, + "outputs": { + "0": { + "name": "eğri", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "Özel Kombinasyon", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "Modelin düşünme sürecinden ilk görsel. Sadece thinking_level HIGH ve IMAGE+TEXT modunda kullanılabilir." } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "Bir metin istemine dayalı olarak mevcut bir videoyu sorunsuz bir şekilde devam ettirin.", + "display_name": "Grok Video Uzatma", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model", + "tooltip": "Video uzatma için kullanılacak model." + }, + "model_duration": { + "name": "süre" + }, + "prompt": { + "name": "istem", + "tooltip": "Videoda sonraki adımda ne olacağını açıklayan metin." + }, + "seed": { + "name": "tohum", + "tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; gerçek sonuçlar tohumdan bağımsız olarak belirlenemezdir." + }, + "video": { + "name": "video", + "tooltip": "Uzatılacak kaynak video. MP4 formatında, 2-15 saniye." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "Bir istem veya görüntüden video oluştur", "display_name": "Grok Video", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "Stil ve içerik referansı olarak referans görsellerle yönlendirilen video oluşturun.", + "display_name": "Grok Referans-Video", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model", + "tooltip": "Video oluşturmak için kullanılacak model." + }, + "model_aspect_ratio": { + "name": "en-boy oranı" + }, + "model_duration": { + "name": "süre" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "prompt": { + "name": "istem", + "tooltip": "İstenen videonun metin açıklaması." + }, + "seed": { + "name": "tohum", + "tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; gerçek sonuçlar tohumdan bağımsız olarak belirlenemezdir." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "Maskeyi Büyüt", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "ID-LoRA konuşmacı kimliği aktarımı için referans ses ayarlayın. Bir referans ses klibini koşullandırmaya kodlar ve isteğe bağlı olarak modeli kimlik rehberliğiyle yamalar (referans olmadan ekstra bir ileri geçiş yaparak konuşmacı kimliği etkisini artırır).", + "display_name": "LTXV Referans Ses (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "Kodlama için LTXV Audio VAE." + }, + "end_percent": { + "name": "bitiş_yüzdesi", + "tooltip": "Kimlik rehberliğinin aktif olduğu sigma aralığının sonu." + }, + "identity_guidance_scale": { + "name": "kimlik_rehberliği_ölçeği", + "tooltip": "Kimlik rehberliği gücü. Her adımda referans olmadan ekstra bir ileri geçiş yaparak konuşmacı kimliğini güçlendirir. Devre dışı bırakmak için 0 olarak ayarlayın (ekstra geçiş yok)." + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negatif" + }, + "positive": { + "name": "pozitif" + }, + "reference_audio": { + "name": "referans_ses", + "tooltip": "Konuşmacı kimliğinin aktarılacağı referans ses klibi. ~5 saniye önerilir (eğitim süresi). Daha kısa veya uzun klipler ses kimliği aktarımını bozabilir." + }, + "start_percent": { + "name": "başlangıç_yüzdesi", + "tooltip": "Kimlik rehberliğinin aktif olduğu sigma aralığının başlangıcı." + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "pozitif", + "tooltip": null + }, + "2": { + "name": "negatif", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXVZamanlayıcı", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "Bir raster görseli Quiver AI kullanarak SVG'ye vektörleştirir.", + "display_name": "Quiver Görselden SVG'ye", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "Baskın nesneye otomatik kırp." + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "Vektörleştirilecek giriş görseli." + }, + "model": { + "name": "model", + "tooltip": "SVG vektörleştirme için kullanılacak model." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; gerçek sonuçlar tohumdan bağımsız olarak deterministik değildir." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "Quiver AI kullanarak bir metin isteminden SVG oluşturur.", + "display_name": "Quiver Metinden SVG'ye", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "Ek stil veya biçimlendirme yönergeleri." + }, + "model": { + "name": "model", + "tooltip": "SVG üretimi için kullanılacak model." + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "İstenen SVG çıktısının metin açıklaması." + }, + "reference_images": { + "name": "reference_images", + "tooltip": "Üretime rehberlik etmek için en fazla 4 referans görsel." + }, + "seed": { + "name": "seed", + "tooltip": "Düğümün tekrar çalıştırılıp çalıştırılmayacağını belirleyen tohum; gerçek sonuçlar tohumdan bağımsız olarak deterministik değildir." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "tekrar_cezası" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "düşünme", + "tooltip": "Model destekliyorsa düşünme modunda çalış." } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "min_p" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "tekrar_cezası" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "düşünme", + "tooltip": "Model destekliyorsa düşünme modunda çalış." } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "pozitif", "tooltip": "Eğitim için kullanılacak pozitif koşullandırma." }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "Eğer training_dtype 'none' olarak ayarlanmışsa ve quantized bir modelde eğitim yapılıyorsa, etkinleştirildiğinde backward işlemi quantized matmul ile yapılır." + }, "rank": { "name": "rütbe", "tooltip": "LoRA katmanlarının rütbesi." diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 81be6a13cf..ea83bcfd85 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "Özel: Sistem başlık çubuğunu ComfyUI'nin Üst menüsüyle değiştirin" }, + "Comfy_Appearance_DisableAnimations": { + "name": "Animasyonları devre dışı bırak", + "tooltip": "Çoğu CSS animasyonunu ve geçişini kapatır. Görüntüleme GPU'su aynı zamanda üretim için kullanıldığında çıkarımı hızlandırır." + }, "Comfy_Canvas_BackgroundImage": { "name": "Tuval arka plan resmi", "tooltip": "Tuval arka planı için resim URL'si. Çıktılar panelindeki bir resme sağ tıklayıp \"Arka Plan Olarak Ayarla\"yı seçerek kullanabilir veya yükleme düğmesini kullanarak kendi resminizi yükleyebilirsiniz." @@ -95,6 +99,10 @@ "name": "Ondalık sayı widget yuvarlama ondalık basamakları [0 = otomatik].", "tooltip": "(sayfanın yeniden yüklenmesini gerektirir)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "Otomatik kaydırma hızı", + "tooltip": "Tuval kenarına sürükleyerek otomatik kaydırma sırasında maksimum hız. Otomatik kaydırmayı devre dışı bırakmak için 0 olarak ayarlayın." + }, "Comfy_Graph_CanvasInfo": { "name": "Sol alt köşede tuval bilgilerini göster (fps, vb.)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "Eksik model uyarısını göster" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "Eksik düğüm uyarısını göster" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "İş akışını kaydederken düğüm kimliklerini sırala" }, diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 3265397378..8278d117ea 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -15,6 +15,14 @@ "message": "此工作流程包含 API 節點,您必須登入帳戶才能執行。", "title": "需要登入以使用 API 節點" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "為了最佳體驗,App builder 使用 Nodes 2.0。您可以在建構應用程式後,從主選單切換回來。", + "dismiss": "關閉", + "dontShowAgain": "不要再顯示", + "title": "已切換至 Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "所有 {category}", "allModels": "所有模型", @@ -556,19 +564,26 @@ "uploadCover": "+ 上傳封面", "uploadProfilePicture": "+ 上傳大頭貼", "uploadWorkflowButton": "上傳我的工作流程", + "usernameError": "3–42 個小寫英數字或連字號,且必須以字母或數字開頭與結尾", "usernameLabel": "你的用戶名稱(必填)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "其他資訊", "back": "返回", "createProfileCta": "建立個人檔案", "createProfileToPublish": "建立個人檔案以發佈到 ComfyHub", "exampleImage": "範例圖片 {index}", + "exampleImagePosition": "範例圖片 {index} / {total}", "examplesDescription": "最多可新增 {total} 張額外範例圖片", "maxExamples": "最多可選擇 {max} 個範例", "next": "下一步", "publishButton": "發佈到 ComfyHub", + "publishFailedDescription": "發佈您的工作流程時發生錯誤。請再試一次。", + "publishFailedTitle": "發佈失敗", + "removeExampleImage": "移除範例圖片", "selectAThumbnail": "選擇縮圖", + "shareAs": "分享為", "showLessTags": "顯示較少...", "showMoreTags": "顯示更多...", "stepDescribe": "描述你的工作流程", @@ -583,6 +598,7 @@ "thumbnailPreview": "縮圖預覽", "thumbnailVideo": "影片", "title": "發佈到 ComfyHub", + "unsavedDescription": "您必須先儲存您的工作流程,才能發佈到 ComfyHub。請先儲存以繼續。", "uploadAnImage": "點擊瀏覽或拖曳圖片", "uploadComparison": "上傳前後對比", "uploadComparisonAfterPrompt": "後", @@ -598,13 +614,7 @@ "workflowDescription": "工作流程描述", "workflowDescriptionPlaceholder": "你的工作流程有什麼令人興奮和特別之處?請具體說明,讓大家知道可以期待什麼。", "workflowName": "工作流程名稱", - "workflowNamePlaceholder": "提示:輸入容易搜尋且具描述性的名稱", - "workflowType": "工作流程類型", - "workflowTypeEditing": "編輯", - "workflowTypeImageGeneration": "影像生成", - "workflowTypePlaceholder": "選擇類型", - "workflowTypeUpscaling": "升級解析度", - "workflowTypeVideoGeneration": "影片生成" + "workflowNamePlaceholder": "提示:輸入容易搜尋且具描述性的名稱" }, "commands": { "clear": "清除工作流程", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "條件設定", "CONTROL_NET": "ControlNet", + "CURVE": "曲線", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "Gemini輸入檔案", "GLIGEN": "GLIGEN", "GUIDER": "引導器", + "HISTOGRAM": "直方圖", "HOOKS": "掛鉤", "HOOK_KEYFRAMES": "關鍵影格掛鉤", "IMAGE": "影像", @@ -876,6 +888,8 @@ "resume": "繼續下載" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "發生錯誤", "extensionFileHint": "這可能是由於以下指令碼所致", "loadWorkflowTitle": "由於重新載入工作流程資料時發生錯誤,已中止載入", @@ -924,6 +938,19 @@ "textToImage": "文字轉圖片", "textToVideo": "文字轉影片" }, + "execution": { + "decoding": "正在解碼…", + "encoding": "正在編碼…", + "generating": "正在生成…", + "generatingVideo": "正在生成影片…", + "loading": "正在載入…", + "processing": "正在處理…", + "processingVideo": "正在處理影片…", + "resizing": "正在調整大小…", + "running": "正在執行…", + "saving": "正在儲存…", + "training": "正在訓練…" + }, "exportToast": { "allExportsCompleted": "所有匯出已完成", "downloadExport": "下載匯出檔", @@ -1064,11 +1091,14 @@ "filterBy": "篩選依據:", "filterByType": "依 {type} 篩選...", "findIssues": "尋找問題", + "findOnGithub": "在 GitHub 上查找", "frameNodes": "框架節點", "frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。", "frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。", + "gallery": "圖庫", "galleryImage": "圖庫圖片", "galleryThumbnail": "圖庫縮圖", + "getHelpAction": "取得協助", "goToNode": "前往節點", "graphNavigation": "圖形導覽", "halfSpeed": "0.5倍速", @@ -1077,6 +1107,8 @@ "icon": "圖示", "imageDoesNotExist": "圖像不存在", "imageFailedToLoad": "無法載入圖片", + "imageGallery": "圖片庫", + "imageLightbox": "圖像預覽", "imagePreview": "圖片預覽 - 使用方向鍵在圖片間導航", "imageUrl": "圖片網址", "import": "匯入", @@ -1090,13 +1122,38 @@ "installed": "已安裝", "installing": "安裝中", "interrupted": "已中斷", - "itemSelected": "已選取 {selectedCount} 項", "itemsCopiedToClipboard": "已複製項目到剪貼簿", "itemsSelected": "已選取 {selectedCount} 項", "job": "工作", "jobIdCopied": "工作 ID 已複製到剪貼簿", "keybinding": "快捷鍵", "keybindingAlreadyExists": "快捷鍵已存在於", + "keybindingPresets": { + "default": "預設預設", + "deletePreset": "刪除預設", + "deletePresetFailed": "刪除預設「{name}」失敗", + "deletePresetTitle": "刪除目前的預設?", + "deletePresetWarning": "此預設將被刪除,無法復原。", + "discardAndSwitch": "放棄並切換", + "exportPreset": "匯出預設", + "importKeybindingPreset": "匯入快捷鍵預設", + "importPreset": "匯入預設", + "invalidPresetFile": "預設檔必須是從 ComfyUI 匯出的有效 JSON", + "invalidPresetName": "預設名稱不可為空、不可為「default」、不可以點開頭、不可包含路徑分隔符號,或以 .json 結尾", + "loadPresetFailed": "載入預設「{name}」失敗", + "overwritePresetMessage": "已存在名為「{name}」的預設。要覆蓋嗎?", + "overwritePresetTitle": "覆蓋預設", + "presetDeleted": "預設「{name}」已刪除", + "presetImported": "快捷鍵預設已匯入", + "presetNamePrompt": "請輸入預設名稱", + "presetSaved": "預設「{name}」已儲存", + "resetToDefault": "重設為預設值", + "saveAndSwitch": "儲存並切換", + "saveAsNewPreset": "另存為新預設", + "saveChanges": "儲存變更", + "unsavedChangesMessage": "您有未儲存的變更,如果不儲存就切換將會遺失這些變更。", + "unsavedChangesTo": "對「{name}」的未儲存變更" + }, "keybindings": "快捷鍵", "learnMore": "了解更多", "listening": "聆聽中...", @@ -1153,6 +1210,8 @@ "output": "輸出", "overwrite": "覆蓋", "partner": "合作夥伴", + "pause": "暫停", + "play": "播放", "playPause": "播放/暫停", "playRecording": "播放錄製", "playbackSpeed": "播放速度", @@ -1160,6 +1219,7 @@ "preloadError": "必要資源載入失敗。請重新整理頁面。", "preloadErrorTitle": "載入錯誤", "preview": "預覽", + "previous": "上一個", "previousImage": "上一張圖片", "profile": "個人檔案", "progressCountOf": "共", @@ -1234,6 +1294,8 @@ "showReport": "顯示報告", "showRightPanel": "顯示右側面板", "singleSelectDropdown": "單選下拉式選單", + "skipToEnd": "跳至結尾", + "skipToStart": "跳至開頭", "sort": "排序", "source": "來源", "startRecording": "開始錄音", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}:{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。", "videoFailedToLoad": "無法載入影片", "videoPreview": "影片預覽 - 使用方向鍵在影片間導航", + "viewGrid": "網格檢視", "viewImageOfTotal": "檢視第 {index} 張圖片(共 {total} 張)", "viewVideoOfTotal": "檢視第 {index} 個影片(共 {total} 個)", "volume": "音量", @@ -1832,6 +1895,7 @@ "mirrorVertical": "垂直鏡像", "negative": "負片", "opacity": "不透明度", + "openMaskEditor": "在遮罩編輯器中開啟", "paintBucketSettings": "油漆桶設定", "paintLayer": "繪圖圖層", "redo": "重做", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "月谷馬雷", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "羅丹", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "你收藏的輸入會顯示在這裡", "favoritesNoneHint": "在參數分頁,點擊任一輸入的 {moreIcon} 可加入這裡", "favoritesNoneTooltip": "將元件加星標,快速存取無需選擇節點", + "findOnGithubTooltip": "在 GitHub 問題中搜尋相關問題", + "getHelpTooltip": "回報此錯誤,我們會協助您解決", "globalSettings": { "canvas": "畫布", "connectionLinks": "連接線", @@ -3073,7 +3140,6 @@ "title": "您的訂閱已取消" }, "changeTo": "切換至 {plan}", - "chooseBestPlanWorkspace": "為您的工作區選擇最佳方案", "comfyCloud": "Comfy Cloud", "comfyCloudLogo": "Comfy Cloud 標誌", "contactOwnerToSubscribe": "請聯絡工作區擁有者以訂閱", @@ -3103,6 +3169,7 @@ }, "gpuLabel": "RTX 6000 Pro(96GB VRAM)", "haveQuestions": "有疑問或想了解企業方案?", + "inviteUpTo": "可邀請最多", "invoiceHistory": "發票記錄", "learnMore": "了解更多", "managePayment": "管理付款", @@ -3128,13 +3195,16 @@ "monthlyCreditsPerMemberLabel": "每月點數/成員", "monthlyCreditsRollover": "這些點數將結轉至下個月", "mostPopular": "最受歡迎", + "needTeamWorkspace": "需要團隊工作區?", "nextBillingCycle": "下個計費週期", "nextMonthInvoice": "下月發票", "partnerNodesBalance": "「合作夥伴節點」點數餘額", "partnerNodesCredits": "合作節點點數", "partnerNodesDescription": "用於執行商業/專有模型", "perMonth": "美元 / 月", + "personalWorkspace": "個人工作區", "plansAndPricing": "方案與價格", + "plansForWorkspace": "{workspace} 的方案", "prepaidCreditsInfo": "單獨購買且不會過期的點數", "prepaidDescription": "預付點數", "preview": { @@ -3170,6 +3240,7 @@ "resubscribe": "重新訂閱", "resubscribeSuccess": "訂閱已重新啟用", "resubscribeTo": "重新訂閱 {plan}", + "soloUseOnly": "僅限個人使用", "subscribeForMore": "升級", "subscribeNow": "立即訂閱", "subscribeTo": "訂閱 {plan}", @@ -3177,6 +3248,7 @@ "subscribeToRun": "訂閱", "subscribeToRunFull": "訂閱運行方案", "subscriptionRequiredMessage": "會員需訂閱才能在雲端執行工作流程", + "teamWorkspace": "團隊工作區", "tierNameYearly": "{name} 年度方案", "tiers": { "creator": { @@ -3228,6 +3300,18 @@ "duplicateTab": "複製分頁", "removeFromBookmarks": "從書籤移除" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "工作區已建立,但設定未完成", + "createWorkspace": "建立工作區", + "namePlaceholder": "例如:行銷團隊", + "nameValidationError": "名稱必須為 1–50 個字元,可使用字母、數字、空格或常見標點符號。", + "newWorkspace": "新工作區", + "subtitle": "切換至現有工作區或建立新工作區", + "subtitleNoWorkspaces": "建立新的團隊工作區以共享點數", + "switch": "切換", + "title": "團隊工作區", + "yourTeamWorkspaces": "你的團隊工作區" + }, "templateWidgets": { "sort": { "searchPlaceholder": "搜尋……" @@ -3614,6 +3698,7 @@ }, "workspaceSwitcher": { "createWorkspace": "建立新工作區", + "failedToSwitch": "切換工作區失敗", "maxWorkspacesReached": "您最多只能擁有 10 個工作區。請刪除一個以建立新工作區。", "personal": "個人", "roleMember": "成員", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index 4aad5cbd84..117b0042cc 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "數值轉換", + "inputs": { + "value": { + "name": "數值" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "切換", "inputs": { @@ -1736,6 +1752,10 @@ "name": "閉環", "tooltip": "是否關閉上下文窗口循環;僅適用於循環排程。" }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "每個視窗中要在 conditioning 張量中保留的 latent 索引列表,例如設為「0」將會在每個視窗中使用初始起始圖像。" + }, "context_length": { "name": "上下文長度", "tooltip": "上下文窗口的長度。" @@ -1767,6 +1787,10 @@ "model": { "name": "模型", "tooltip": "在取樣期間應用上下文窗口的模型。" + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "是否根據區域索引,將多個 conditioning(由 ConditionCombine 建立)分配到各個視窗。" } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "曲線編輯器", + "inputs": { + "curve": { + "name": "曲線" + }, + "histogram": { + "name": "直方圖" + } + }, + "outputs": { + "0": { + "name": "曲線", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "自訂組合", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "模型思考過程中的第一張圖像。僅在 thinking_level 設為 HIGH 且模態為 IMAGE+TEXT 時可用。" } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "根據文字提示,為現有影片無縫延伸內容。", + "display_name": "Grok 影片延伸", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "model": { + "name": "模型", + "tooltip": "用於影片延伸的模型。" + }, + "model_duration": { + "name": "時長" + }, + "prompt": { + "name": "提示詞", + "tooltip": "描述影片接下來應該發生什麼的文字說明。" + }, + "seed": { + "name": "種子", + "tooltip": "決定是否重新執行節點的種子值;實際結果無論種子如何都不具決定性。" + }, + "video": { + "name": "影片", + "tooltip": "要延伸的來源影片。MP4 格式,2-15 秒。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "從提示或圖像生成影片", "display_name": "Grok 影片", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "根據參考圖像作為風格與內容指引生成影片。", + "display_name": "Grok 參考生成影片", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "model": { + "name": "模型", + "tooltip": "用於影片生成的模型。" + }, + "model_aspect_ratio": { + "name": "長寬比" + }, + "model_duration": { + "name": "時長" + }, + "model_resolution": { + "name": "解析度" + }, + "prompt": { + "name": "提示詞", + "tooltip": "描述所需影片的文字說明。" + }, + "seed": { + "name": "種子", + "tooltip": "決定是否重新執行節點的種子值;實際結果無論種子如何都不具決定性。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "GrowMask", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "設定參考音訊以進行 ID-LoRA 說話人身份轉換。將參考音訊 clip 編碼為條件,並可選擇性地以身份指引修補模型(額外進行一次無參考的前向傳遞,以強化說話人身份效果)。", + "display_name": "LTXV Reference Audio (ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "用於編碼的 LTXV Audio VAE。" + }, + "end_percent": { + "name": "end_percent", + "tooltip": "身份指引啟用的 sigma 範圍結束百分比。" + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "身份指引強度。每一步會額外進行一次無參考的前向傳遞以強化說話人身份。設為 0 可停用(不進行額外傳遞)。" + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "要轉換說話人身份的參考音訊 clip。建議長度約 5 秒(訓練時長)。過短或過長的 clip 可能會降低聲音身份轉換效果。" + }, + "start_percent": { + "name": "start_percent", + "tooltip": "身份指引啟用的 sigma 範圍起始百分比。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXV 排程器", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "使用 Quiver AI 將點陣圖像向量化為 SVG。", + "display_name": "Quiver 圖像轉 SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "自動裁切至主要主體。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "要向量化的輸入圖像。" + }, + "model": { + "name": "model", + "tooltip": "用於 SVG 向量化的模型。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不具決定性。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "使用 Quiver AI 根據文字提示生成 SVG。", + "display_name": "Quiver 文字轉 SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "額外的風格或格式指引。" + }, + "model": { + "name": "model", + "tooltip": "用於 SVG 生成的模型。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "對期望 SVG 輸出的文字描述。" + }, + "reference_images": { + "name": "reference_images", + "tooltip": "最多 4 張參考圖像以輔助生成。" + }, + "seed": { + "name": "seed", + "tooltip": "決定節點是否重新執行的種子值;實際結果無論種子如何都不具決定性。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "最小 p" }, + "sampling_mode_presence_penalty": { + "name": "出現懲罰" + }, "sampling_mode_repetition_penalty": { "name": "重複懲罰" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "top_p" + }, + "thinking": { + "name": "思考模式", + "tooltip": "若模型支援,則以思考模式運作。" } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "最小 p 值" }, + "sampling_mode_presence_penalty": { + "name": "出現懲罰" + }, "sampling_mode_repetition_penalty": { "name": "重複懲罰" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "Top-P" + }, + "thinking": { + "name": "思考模式", + "tooltip": "若模型支援,則以思考模式運作。" } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "正向條件", "tooltip": "用於訓練的正向條件。" }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "當 training_dtype 設為 'none' 並在量化模型上訓練時,啟用後將以量化矩陣相乘進行反向傳播。" + }, "rank": { "name": "秩", "tooltip": "LoRA 層的秩。" diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 7b0a8e8606..d4b832e056 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "自訂:以 ComfyUI 的頂部選單取代系統標題列" }, + "Comfy_Appearance_DisableAnimations": { + "name": "停用動畫", + "tooltip": "關閉大多數 CSS 動畫與過渡效果。當顯示卡同時用於生成時,可加快推理速度。" + }, "Comfy_Canvas_BackgroundImage": { "name": "畫布背景圖片", "tooltip": "畫布背景的圖片網址。你可以在輸出面板中右鍵點擊圖片並選擇「設為背景」來使用,或是使用上傳按鈕上傳你自己的圖片。" @@ -95,6 +99,10 @@ "name": "浮點元件小數點位數 [0 = 自動]。", "tooltip": "(需重新載入頁面)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "自動平移速度", + "tooltip": "拖曳至畫布邊緣時自動平移的最大速度。設為 0 可停用自動平移。" + }, "Comfy_Graph_CanvasInfo": { "name": "在左下角顯示畫布資訊(fps 等)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "顯示缺少模型警告" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "顯示缺少節點警告" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "儲存工作流程時排序節點 ID" }, diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index ed9eb1e707..8181c784fb 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -15,6 +15,14 @@ "message": "此工作流包含API节点,需要您登录账户才能运行。", "title": "使用API节点需要登录" }, + "appBuilder": { + "vueNodeSwitch": { + "content": "为获得最佳体验,应用构建器使用 Nodes 2.0。构建应用后,您可以从主菜单切换回去。", + "dismiss": "关闭", + "dontShowAgain": "不再显示", + "title": "已切换到 Nodes 2.0" + } + }, "assetBrowser": { "allCategory": "全部{category}", "allModels": "全部模型", @@ -556,19 +564,26 @@ "uploadCover": "+ 上传封面", "uploadProfilePicture": "+ 上传头像", "uploadWorkflowButton": "上传我的工作流", + "usernameError": "用户名需为 3–42 个小写字母、数字或连字符,且必须以字母或数字开头和结尾", "usernameLabel": "你的用户名(必填)", "usernamePlaceholder": "@" }, "comfyHubPublish": { + "additionalInfo": "附加信息", "back": "返回", "createProfileCta": "创建个人资料", "createProfileToPublish": "创建个人资料以发布到 ComfyHub", "exampleImage": "示例图片 {index}", + "exampleImagePosition": "示例图像 {index} / {total}", "examplesDescription": "最多可添加 {total} 个额外示例图片", "maxExamples": "最多可选择 {max} 个示例", "next": "下一步", "publishButton": "发布到 ComfyHub", + "publishFailedDescription": "发布您的工作流时出现问题。请重试。", + "publishFailedTitle": "发布失败", + "removeExampleImage": "移除示例图像", "selectAThumbnail": "选择缩略图", + "shareAs": "分享为", "showLessTags": "显示更少...", "showMoreTags": "显示更多...", "stepDescribe": "描述你的工作流", @@ -583,6 +598,7 @@ "thumbnailPreview": "缩略图预览", "thumbnailVideo": "视频", "title": "发布到 ComfyHub", + "unsavedDescription": "在发布到 ComfyHub 之前,您必须先保存您的工作流。请先保存以继续。", "uploadAnImage": "点击浏览或拖拽图片", "uploadComparison": "上传前后对比", "uploadComparisonAfterPrompt": "后", @@ -598,13 +614,7 @@ "workflowDescription": "工作流描述", "workflowDescriptionPlaceholder": "是什么让你的工作流令人兴奋和特别?请具体说明,让大家知道可以期待什么。", "workflowName": "工作流名称", - "workflowNamePlaceholder": "提示:输入一个易于搜索的描述性名称", - "workflowType": "工作流类型", - "workflowTypeEditing": "编辑", - "workflowTypeImageGeneration": "图像生成", - "workflowTypePlaceholder": "选择类型", - "workflowTypeUpscaling": "放大", - "workflowTypeVideoGeneration": "视频生成" + "workflowNamePlaceholder": "提示:输入一个易于搜索的描述性名称" }, "commands": { "clear": "清空工作流", @@ -772,6 +782,7 @@ "COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3", "CONDITIONING": "条件", "CONTROL_NET": "ControlNet", + "CURVE": "曲线", "ELEVENLABS_VOICE": "ELEVENLABS_VOICE", "FILE_3D": "FILE_3D", "FILE_3D_FBX": "FILE_3D_FBX", @@ -785,6 +796,7 @@ "GEMINI_INPUT_FILES": "GEMINI 输入文件", "GLIGEN": "GLIGEN", "GUIDER": "引导器", + "HISTOGRAM": "直方图", "HOOKS": "约束", "HOOK_KEYFRAMES": "约束关键帧", "IMAGE": "图像", @@ -876,6 +888,8 @@ "resume": "恢复下载" }, "errorDialog": { + "accessRestrictedMessage": "Your account is not authorized for this feature.", + "accessRestrictedTitle": "Access Restricted", "defaultTitle": "发生错误", "extensionFileHint": "这可能是由于以下脚本", "loadWorkflowTitle": "由于重新加载工作流数据出错,加载被中止", @@ -924,6 +938,19 @@ "textToImage": "文本转图像", "textToVideo": "文本转视频" }, + "execution": { + "decoding": "正在解码…", + "encoding": "正在编码…", + "generating": "正在生成…", + "generatingVideo": "正在生成视频…", + "loading": "正在加载…", + "processing": "正在处理…", + "processingVideo": "正在处理视频…", + "resizing": "正在调整大小…", + "running": "正在运行…", + "saving": "正在保存…", + "training": "正在训练…" + }, "exportToast": { "allExportsCompleted": "全部导出完成", "downloadExport": "下载导出文件", @@ -1064,11 +1091,14 @@ "filterBy": "筛选方式:", "filterByType": "按{type}筛选...", "findIssues": "查找问题", + "findOnGithub": "在 GitHub 上查找", "frameNodes": "框选节点", "frontendNewer": "前端版本 {frontendVersion} 可能与后端版本 {backendVersion} 不相容。", "frontendOutdated": "前端版本 {frontendVersion} 已过时。后端需要 {requiredVersion} 或更高版本。", + "gallery": "图库", "galleryImage": "图库图片", "galleryThumbnail": "图库缩略图", + "getHelpAction": "获取帮助", "goToNode": "转到节点", "graphNavigation": "图形导航", "halfSpeed": "0.5倍", @@ -1077,6 +1107,8 @@ "icon": "图标", "imageDoesNotExist": "图像不存在", "imageFailedToLoad": "图像加载失败", + "imageGallery": "图片库", + "imageLightbox": "图像预览", "imagePreview": "图片预览 - 使用方向键切换图片", "imageUrl": "图片网址", "import": "导入", @@ -1090,13 +1122,38 @@ "installed": "已安装", "installing": "正在安装", "interrupted": "已中断", - "itemSelected": "已选择 {selectedCount} 项", "itemsCopiedToClipboard": "已复制到剪贴板", "itemsSelected": "已选择 {selectedCount} 项", "job": "任务", "jobIdCopied": "队列 ID 已复制到剪贴板", "keybinding": "按键绑定", "keybindingAlreadyExists": "快捷键已存在", + "keybindingPresets": { + "default": "默认预设", + "deletePreset": "删除预设", + "deletePresetFailed": "删除预设“{name}”失败", + "deletePresetTitle": "删除当前预设?", + "deletePresetWarning": "此预设将被删除,无法恢复。", + "discardAndSwitch": "放弃并切换", + "exportPreset": "导出预设", + "importKeybindingPreset": "导入快捷键预设", + "importPreset": "导入预设", + "invalidPresetFile": "预设文件必须是从 ComfyUI 导出的有效 JSON", + "invalidPresetName": "预设名称不能为空、不能为“default”、不能以点开头、不能包含路径分隔符,也不能以 .json 结尾", + "loadPresetFailed": "加载预设“{name}”失败", + "overwritePresetMessage": "已存在名为“{name}”的预设。是否覆盖?", + "overwritePresetTitle": "覆盖预设", + "presetDeleted": "预设“{name}”已删除", + "presetImported": "快捷键预设已导入", + "presetNamePrompt": "请输入预设名称", + "presetSaved": "预设“{name}”已保存", + "resetToDefault": "重置为默认", + "saveAndSwitch": "保存并切换", + "saveAsNewPreset": "另存为新预设", + "saveChanges": "保存更改", + "unsavedChangesMessage": "您有未保存的更改,如果不保存就切换,这些更改将会丢失。", + "unsavedChangesTo": "对 {name} 的未保存更改" + }, "keybindings": "快捷键", "learnMore": "了解更多", "listening": "正在监听...", @@ -1153,6 +1210,8 @@ "output": "输出", "overwrite": "覆盖", "partner": "合作伙伴", + "pause": "暂停", + "play": "播放", "playPause": "开启/暂停", "playRecording": "播放录音", "playbackSpeed": "播放速度", @@ -1160,6 +1219,7 @@ "preloadError": "所需资源加载失败。请重新加载页面。", "preloadErrorTitle": "加载错误", "preview": "预览", + "previous": "上一个", "previousImage": "上一张图像", "profile": "档案", "progressCountOf": "共", @@ -1234,6 +1294,8 @@ "showReport": "显示报告", "showRightPanel": "显示右侧面板", "singleSelectDropdown": "单选下拉框", + "skipToEnd": "跳到结尾", + "skipToStart": "跳到开头", "sort": "排序", "source": "来源", "startRecording": "开始录音", @@ -1265,6 +1327,7 @@ "versionMismatchWarningMessage": "{warning}:{detail} 请参阅 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。", "videoFailedToLoad": "视频加载失败", "videoPreview": "视频预览 - 使用方向键切换视频", + "viewGrid": "网格视图", "viewImageOfTotal": "查看第 {index} 张图片,共 {total} 张", "viewVideoOfTotal": "查看第 {index} 个视频,共 {total} 个", "volume": "音量", @@ -1832,6 +1895,7 @@ "mirrorVertical": "垂直翻转", "negative": "负面", "opacity": "不透明度", + "openMaskEditor": "在蒙版编辑器中打开", "paintBucketSettings": "填充设置", "paintLayer": "绘画层", "redo": "重做", @@ -2146,6 +2210,7 @@ "Moonvalley Marey": "Moonvalley Marey", "OpenAI": "OpenAI", "PixVerse": "PixVerse", + "Quiver": "Quiver", "Recraft": "Recraft", "Reve": "Reve", "Rodin": "Rodin", @@ -2421,6 +2486,8 @@ "favoritesNoneDesc": "你收藏的输入会显示在这里", "favoritesNoneHint": "在参数标签页,点击任意输入项的 {moreIcon} 可将其添加到这里", "favoritesNoneTooltip": "星标组件后可快速访问,无需选择节点", + "findOnGithubTooltip": "在 GitHub 问题中搜索相关问题", + "getHelpTooltip": "报告此错误,我们将协助您解决", "globalSettings": { "canvas": "画布", "connectionLinks": "连接线", @@ -3085,7 +3152,6 @@ "title": "您的订阅已被取消" }, "changeTo": "更改为 {plan}", - "chooseBestPlanWorkspace": "为您的工作区选择最佳方案", "comfyCloud": "Comfy 云", "comfyCloudLogo": "Comfy Cloud Logo", "contactOwnerToSubscribe": "请联系工作区所有者进行订阅", @@ -3115,6 +3181,7 @@ }, "gpuLabel": "RTX 6000 Pro (96GB VRAM)", "haveQuestions": "对企业级有疑问?", + "inviteUpTo": "可邀请多达", "invoiceHistory": "发票历史", "learnMore": "了解更多", "managePayment": "管理付款", @@ -3140,13 +3207,16 @@ "monthlyCreditsPerMemberLabel": "每成员每月积分", "monthlyCreditsRollover": "这些积分将结转到下个月", "mostPopular": "最受欢迎", + "needTeamWorkspace": "需要团队工作区?", "nextBillingCycle": "下一个计费周期", "nextMonthInvoice": "下月账单", "partnerNodesBalance": "\"合作伙伴节点\"积分余额", "partnerNodesCredits": "合作伙伴节点积分", "partnerNodesDescription": "用于运行商业/专有模型", "perMonth": "美元 / 月", + "personalWorkspace": "个人工作区", "plansAndPricing": "订阅和定价", + "plansForWorkspace": "{workspace} 的套餐", "prepaidCreditsInfo": "单独购买且不会过期的积分", "prepaidDescription": "预付款额度", "preview": { @@ -3182,6 +3252,7 @@ "resubscribe": "重新订阅", "resubscribeSuccess": "订阅已成功重新激活", "resubscribeTo": "重新订阅 {plan}", + "soloUseOnly": "仅限个人使用", "subscribeForMore": "升级", "subscribeNow": "立即订阅", "subscribeTo": "订阅 {plan}", @@ -3189,6 +3260,7 @@ "subscribeToRun": "订阅", "subscribeToRunFull": "订阅 Run", "subscriptionRequiredMessage": "成员在云端运行工作流需要订阅", + "teamWorkspace": "团队工作区", "tierNameYearly": "{name} 年度", "tiers": { "creator": { @@ -3240,6 +3312,18 @@ "duplicateTab": "复制标签", "removeFromBookmarks": "从书签中移除" }, + "teamWorkspacesDialog": { + "confirmCallbackFailed": "工作区已创建,但设置未完成", + "createWorkspace": "创建工作区", + "namePlaceholder": "例如:市场团队", + "nameValidationError": "名称需为1-50个字符,可包含字母、数字、空格或常用标点符号。", + "newWorkspace": "新建工作区", + "subtitle": "切换到已有工作区或创建新工作区", + "subtitleNoWorkspaces": "创建新的团队工作区以共享积分", + "switch": "切换", + "title": "团队工作区", + "yourTeamWorkspaces": "你的团队工作区" + }, "templateWidgets": { "sort": { "searchPlaceholder": "搜索中..." @@ -3626,6 +3710,7 @@ }, "workspaceSwitcher": { "createWorkspace": "创建新工作区", + "failedToSwitch": "切换工作区失败", "maxWorkspacesReached": "您最多只能拥有10个工作区。请删除一个以创建新工作区。", "personal": "个人", "roleMember": "成员", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index d12baffe06..8c47a414ca 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -1438,6 +1438,22 @@ } } }, + "ComfyNumberConvert": { + "display_name": "数字转换", + "inputs": { + "value": { + "name": "数值" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "tooltip": null + } + } + }, "ComfySwitchNode": { "display_name": "切换", "inputs": { @@ -1736,6 +1752,10 @@ "name": "闭环", "tooltip": "是否闭合上下文窗口循环;仅适用于循环调度。" }, + "cond_retain_index_list": { + "name": "cond_retain_index_list", + "tooltip": "用于每个窗口在条件张量中保留的latent索引列表,例如设置为“0”将为每个窗口使用初始起始图像。" + }, "context_length": { "name": "上下文长度", "tooltip": "上下文窗口的长度。" @@ -1767,6 +1787,10 @@ "model": { "name": "模型", "tooltip": "在采样期间应用上下文窗口的模型。" + }, + "split_conds_to_windows": { + "name": "split_conds_to_windows", + "tooltip": "是否将多个条件(由ConditionCombine创建)根据区域索引分配到各个窗口。" } }, "outputs": { @@ -2210,6 +2234,23 @@ } } }, + "CurveEditor": { + "display_name": "曲线编辑器", + "inputs": { + "curve": { + "name": "曲线" + }, + "histogram": { + "name": "直方图" + } + }, + "outputs": { + "0": { + "name": "曲线", + "tooltip": null + } + } + }, "CustomCombo": { "display_name": "自定义组合", "inputs": { @@ -3815,6 +3856,10 @@ }, "1": { "tooltip": null + }, + "2": { + "name": "thought_image", + "tooltip": "模型思考过程中的第一张图像。仅在thinking_level为HIGH且模态为IMAGE+TEXT时可用。" } } }, @@ -4080,6 +4125,39 @@ } } }, + "GrokVideoExtendNode": { + "description": "根据文本提示无缝扩展现有视频。", + "display_name": "Grok 视频扩展", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "模型", + "tooltip": "用于视频扩展的模型。" + }, + "model_duration": { + "name": "时长" + }, + "prompt": { + "name": "提示词", + "tooltip": "描述视频接下来内容的文本。" + }, + "seed": { + "name": "种子", + "tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是不确定的。" + }, + "video": { + "name": "视频", + "tooltip": "要扩展的视频源。MP4 格式,2-15 秒。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrokVideoNode": { "description": "根据提示或图像生成视频", "display_name": "Grok 视频", @@ -4120,6 +4198,41 @@ } } }, + "GrokVideoReferenceNode": { + "description": "根据参考图像的风格和内容生成视频。", + "display_name": "Grok 参考生成视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "模型", + "tooltip": "用于视频生成的模型。" + }, + "model_aspect_ratio": { + "name": "宽高比" + }, + "model_duration": { + "name": "时长" + }, + "model_resolution": { + "name": "分辨率" + }, + "prompt": { + "name": "提示词", + "tooltip": "描述期望视频的文本。" + }, + "seed": { + "name": "种子", + "tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是不确定的。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "GrowMask": { "display_name": "扩展遮罩", "inputs": { @@ -6736,6 +6849,54 @@ } } }, + "LTXVReferenceAudio": { + "description": "设置参考音频用于 ID-LoRA 说话人身份迁移。将参考音频 clip 编码为条件,并可选地通过身份引导修补模型(每步额外进行一次无参考的前向传播,以增强说话人身份效果)。", + "display_name": "LTXV 参考音频(ID-LoRA)", + "inputs": { + "audio_vae": { + "name": "audio_vae", + "tooltip": "用于编码的 LTXV Audio VAE。" + }, + "end_percent": { + "name": "end_percent", + "tooltip": "身份引导激活的 sigma 区间结束百分比。" + }, + "identity_guidance_scale": { + "name": "identity_guidance_scale", + "tooltip": "身份引导强度。每步会额外进行一次无参考的前向传播以增强说话人身份。设置为 0 可禁用(无额外前向传播)。" + }, + "model": { + "name": "model" + }, + "negative": { + "name": "negative" + }, + "positive": { + "name": "positive" + }, + "reference_audio": { + "name": "reference_audio", + "tooltip": "要迁移说话人身份的参考音频 clip。建议时长约 5 秒(训练时长)。过短或过长的音频可能会降低声音身份迁移效果。" + }, + "start_percent": { + "name": "start_percent", + "tooltip": "身份引导激活的 sigma 区间起始百分比。" + } + }, + "outputs": { + "0": { + "tooltip": null + }, + "1": { + "name": "positive", + "tooltip": null + }, + "2": { + "name": "negative", + "tooltip": null + } + } + }, "LTXVScheduler": { "display_name": "LTXV调度器", "inputs": { @@ -11949,6 +12110,91 @@ } } }, + "QuiverImageToSVGNode": { + "description": "使用Quiver AI将光栅图像矢量化为SVG。", + "display_name": "Quiver 图像转SVG", + "inputs": { + "auto_crop": { + "name": "auto_crop", + "tooltip": "自动裁剪到主要主体。" + }, + "control_after_generate": { + "name": "control after generate" + }, + "image": { + "name": "image", + "tooltip": "要矢量化的输入图像。" + }, + "model": { + "name": "model", + "tooltip": "用于SVG矢量化的模型。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_target_size": { + "name": "target_size" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "seed": { + "name": "seed", + "tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "QuiverTextToSVGNode": { + "description": "使用Quiver AI根据文本提示生成SVG。", + "display_name": "Quiver 文本转SVG", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "instructions": { + "name": "instructions", + "tooltip": "额外的风格或格式指导。" + }, + "model": { + "name": "model", + "tooltip": "用于SVG生成的模型。" + }, + "model_presence_penalty": { + "name": "presence_penalty" + }, + "model_temperature": { + "name": "temperature" + }, + "model_top_p": { + "name": "top_p" + }, + "prompt": { + "name": "prompt", + "tooltip": "对期望SVG输出的文本描述。" + }, + "reference_images": { + "name": "reference_images", + "tooltip": "最多可提供4张参考图像以辅助生成。" + }, + "seed": { + "name": "seed", + "tooltip": "用于决定节点是否重新运行的种子;无论种子如何,实际结果都是非确定性的。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "QwenImageDiffsynthControlnet": { "display_name": "QwenImageDiffsynthControlnet", "inputs": { @@ -15667,6 +15913,9 @@ "sampling_mode_min_p": { "name": "最小概率" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "重复惩罚" }, @@ -15681,6 +15930,10 @@ }, "sampling_mode_top_p": { "name": "Top P" + }, + "thinking": { + "name": "思考模式", + "tooltip": "如果模型支持,则以思考模式运行。" } }, "outputs": { @@ -15711,6 +15964,9 @@ "sampling_mode_min_p": { "name": "最小概率" }, + "sampling_mode_presence_penalty": { + "name": "presence_penalty" + }, "sampling_mode_repetition_penalty": { "name": "重复惩罚" }, @@ -15725,6 +15981,10 @@ }, "sampling_mode_top_p": { "name": "Top P" + }, + "thinking": { + "name": "思考模式", + "tooltip": "如果模型支持,则以思考模式运行。" } }, "outputs": { @@ -15994,6 +16254,10 @@ "name": "正向条件", "tooltip": "用于训练的正向条件。" }, + "quantized_backward": { + "name": "quantized_backward", + "tooltip": "当 training_dtype 设为 'none' 并在量化模型上训练时,启用后将使用量化矩阵乘法进行反向传播。" + }, "rank": { "name": "秩", "tooltip": "LoRA 层的秩。" diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 5b92fac642..075b434c12 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -25,6 +25,10 @@ }, "tooltip": "选择自定义选项以隐藏系统标题栏" }, + "Comfy_Appearance_DisableAnimations": { + "name": "禁用动画", + "tooltip": "关闭大多数 CSS 动画和过渡效果。当显示 GPU 也用于生成时,可加快推理速度。" + }, "Comfy_Canvas_BackgroundImage": { "name": "画布背景图像", "tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片,并选择“设为背景”来使用它。" @@ -95,6 +99,10 @@ "name": "浮点组件四舍五入的小数位数 [0 = 自动]。", "tooltip": "(需要重新加载页面)" }, + "Comfy_Graph_AutoPanSpeed": { + "name": "自动平移速度", + "tooltip": "拖动到画布边缘时自动平移的最大速度。设置为 0 可禁用自动平移。" + }, "Comfy_Graph_CanvasInfo": { "name": "在左下角显示画布信息(fps等)" }, @@ -454,9 +462,6 @@ "Comfy_Workflow_ShowMissingModelsWarning": { "name": "显示缺失模型警告" }, - "Comfy_Workflow_ShowMissingNodesWarning": { - "name": "显示缺失节点警告" - }, "Comfy_Workflow_SortNodeIdOnSave": { "name": "保存节点ID到工作流" }, diff --git a/src/main.ts b/src/main.ts index 939a63b580..a32242468c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -67,7 +67,14 @@ Sentry.init({ replaysOnErrorSampleRate: 0, // Only set these for non-cloud builds ...(isCloud - ? {} + ? { + integrations: [ + // Disable event target wrapping to reduce overhead on high-frequency + // DOM events (pointermove, mousemove, wheel). Sentry still captures + // errors via window.onerror and unhandledrejection. + Sentry.browserApiErrorsIntegration({ eventTarget: false }) + ] + } : { integrations: [], autoSessionTracking: false, diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index 36b3ae92a0..66ca4b777d 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -141,6 +141,7 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue' import IconGroup from '@/components/button/IconGroup.vue' import LoadingOverlay from '@/components/common/LoadingOverlay.vue' import Button from '@/components/ui/button/Button.vue' +import { isCloud } from '@/platform/distribution/types' import { useAssetsStore } from '@/stores/assetsStore' import { formatDuration, @@ -279,7 +280,8 @@ const formattedDuration = computed(() => { // Get metadata info based on file kind const metaInfo = computed(() => { if (!asset) return '' - if (fileKind.value === 'image' && imageDimensions.value) { + // TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590) + if (fileKind.value === 'image' && imageDimensions.value && !isCloud) { return `${imageDimensions.value.width}x${imageDimensions.value.height}` } if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) { diff --git a/src/platform/assets/components/MediaAudioTop.vue b/src/platform/assets/components/MediaAudioTop.vue index 64ea8bbcbf..777692e3a6 100644 --- a/src/platform/assets/components/MediaAudioTop.vue +++ b/src/platform/assets/components/MediaAudioTop.vue @@ -8,16 +8,20 @@ $t('assetBrowser.media.audioPlaceholder') }}
-
diff --git a/src/platform/cloud/onboarding/CloudAuthTimeoutView.vue b/src/platform/cloud/onboarding/CloudAuthTimeoutView.vue index 3ca7907623..5f676f9897 100644 --- a/src/platform/cloud/onboarding/CloudAuthTimeoutView.vue +++ b/src/platform/cloud/onboarding/CloudAuthTimeoutView.vue @@ -74,7 +74,7 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import Button from '@/components/ui/button/Button.vue' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' interface Props { errorMessage?: string @@ -83,7 +83,7 @@ interface Props { defineProps() const router = useRouter() -const { logout } = useFirebaseAuthActions() +const { logout } = useAuthActions() const showTechnicalDetails = ref(false) const handleRestart = async () => { diff --git a/src/platform/cloud/onboarding/CloudForgotPasswordView.vue b/src/platform/cloud/onboarding/CloudForgotPasswordView.vue index 37d90d67a0..f5acb3a8bc 100644 --- a/src/platform/cloud/onboarding/CloudForgotPasswordView.vue +++ b/src/platform/cloud/onboarding/CloudForgotPasswordView.vue @@ -76,11 +76,11 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import Button from '@/components/ui/button/Button.vue' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' const { t } = useI18n() const router = useRouter() -const authActions = useFirebaseAuthActions() +const authActions = useAuthActions() const email = ref('') const loading = ref(false) diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue index abdf2303e3..49c699f870 100644 --- a/src/platform/cloud/onboarding/CloudLoginView.vue +++ b/src/platform/cloud/onboarding/CloudLoginView.vue @@ -110,7 +110,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import Button from '@/components/ui/button/Button.vue' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue' import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding' import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath' @@ -120,7 +120,7 @@ import type { SignInData } from '@/schemas/signInSchema' const { t } = useI18n() const router = useRouter() const route = useRoute() -const authActions = useFirebaseAuthActions() +const authActions = useAuthActions() const isSecureContext = globalThis.isSecureContext const authError = ref('') const toastStore = useToastStore() diff --git a/src/platform/cloud/onboarding/CloudSignupView.vue b/src/platform/cloud/onboarding/CloudSignupView.vue index b3445f4ed4..10367252a2 100644 --- a/src/platform/cloud/onboarding/CloudSignupView.vue +++ b/src/platform/cloud/onboarding/CloudSignupView.vue @@ -42,7 +42,7 @@ {{ t('auth.login.freeTierBadge') }} @@ -133,7 +133,7 @@ import { useRoute, useRouter } from 'vue-router' import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue' import Button from '@/components/ui/button/Button.vue' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding' import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath' import { isCloud } from '@/platform/distribution/types' @@ -145,7 +145,7 @@ import { isInChina } from '@/utils/networkUtil' const { t } = useI18n() const router = useRouter() const route = useRoute() -const authActions = useFirebaseAuthActions() +const authActions = useAuthActions() const isSecureContext = globalThis.isSecureContext const authError = ref('') const userIsInChina = ref(false) @@ -182,14 +182,14 @@ const onSuccess = async () => { const signInWithGoogle = async () => { authError.value = '' - if (await authActions.signInWithGoogle()) { + if (await authActions.signInWithGoogle({ isNewUser: true })) { await onSuccess() } } const signInWithGithub = async () => { authError.value = '' - if (await authActions.signInWithGithub()) { + if (await authActions.signInWithGithub({ isNewUser: true })) { await onSuccess() } } diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index de86279241..d6091f39b7 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -25,8 +25,8 @@ const authActionMocks = vi.hoisted(() => ({ accessBillingPortal: vi.fn() })) -vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ - useFirebaseAuthActions: () => authActionMocks +vi.mock('@/composables/auth/useAuthActions', () => ({ + useAuthActions: () => authActionMocks })) vi.mock('@/composables/useErrorHandling', () => ({ diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index 9a144d82b7..4410cffd18 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { useBillingContext } from '@/composables/billing/useBillingContext' -import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' +import { useAuthActions } from '@/composables/auth/useAuthActions' import { useErrorHandling } from '@/composables/useErrorHandling' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil' @@ -16,7 +16,7 @@ import type { BillingCycle } from '../subscription/utils/subscriptionTierRank' const { t } = useI18n() const route = useRoute() const router = useRouter() -const { reportError, accessBillingPortal } = useFirebaseAuthActions() +const { reportError, accessBillingPortal } = useAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() const { isActiveSubscription, isInitialized, initialize } = useBillingContext() diff --git a/src/platform/cloud/onboarding/components/CloudSignInForm.vue b/src/platform/cloud/onboarding/components/CloudSignInForm.vue index 51981901e9..88a4f5ee55 100644 --- a/src/platform/cloud/onboarding/components/CloudSignInForm.vue +++ b/src/platform/cloud/onboarding/components/CloudSignInForm.vue @@ -89,9 +89,9 @@ import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { signInSchema } from '@/schemas/signInSchema' import type { SignInData } from '@/schemas/signInSchema' -import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useAuthStore } from '@/stores/authStore' -const authStore = useFirebaseAuthStore() +const authStore = useAuthStore() const loading = computed(() => authStore.loading) const { t } = useI18n() diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index e8097d146c..2338c2b9f9 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -31,8 +31,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ }) })) -vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ - useFirebaseAuthActions: () => ({ +vi.mock('@/composables/auth/useAuthActions', () => ({ + useAuthActions: () => ({ accessBillingPortal: mockAccessBillingPortal, reportError: mockReportError }) @@ -56,13 +56,13 @@ vi.mock('@/composables/useErrorHandling', () => ({ }) })) -vi.mock('@/stores/firebaseAuthStore', () => ({ - useFirebaseAuthStore: () => +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => reactive({ getAuthHeader: mockGetAuthHeader, userId: computed(() => mockUserId.value) }), - FirebaseAuthStoreError: class extends Error {} + AuthStoreError: class extends Error {} })) vi.mock('@/platform/telemetry', () => ({ @@ -107,6 +107,8 @@ const i18n = createI18n({ videoEstimateHelp: 'How is this calculated?', videoEstimateExplanation: 'Based on average usage.', videoEstimateTryTemplate: 'Try template', + soloUseOnly: 'Solo use only', + needTeamWorkspace: 'Need team workspace?', maxDuration: { standard: '30 min', creator: '30 min', @@ -296,4 +298,20 @@ describe('PricingTable', () => { expect(mockAccessBillingPortal).toHaveBeenCalledWith('standard-yearly') }) }) + + describe('team workspace link', () => { + it('should emit chooseTeamWorkspace when clicking "Need team workspace?" link', async () => { + const wrapper = createWrapper() + await flushPromises() + + const teamLink = wrapper + .findAll('button') + .find((btn) => btn.text().includes('Need team workspace?')) + + expect(teamLink).toBeDefined() + await teamLink?.trigger('click') + + expect(wrapper.emitted('chooseTeamWorkspace')).toHaveLength(1) + }) + }) }) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 7a90ac5dc0..6d918e9a38 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -1,5 +1,5 @@
-
+
-
+
{{ t('subscription.mostPopular') }}
@@ -67,7 +67,7 @@
-
+

+ {{ t('subscription.soloUseOnly') }} + + +

+ +
-
+